Merge branch 'loops/gitea' into architecture
This commit is contained in:
134
lib/git/conformance.sh
Executable file
134
lib/git/conformance.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/git/conformance.sh — run sx-git test suites, emit scoreboard.json + scoreboard.md.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUITES=(object ref dag worktree diff merge porcelain export import)
|
||||
|
||||
OUT_JSON="lib/git/scoreboard.json"
|
||||
OUT_MD="lib/git/scoreboard.md"
|
||||
|
||||
run_suite() {
|
||||
local suite=$1
|
||||
local file="lib/git/tests/${suite}.sx"
|
||||
local TMP
|
||||
TMP=$(mktemp)
|
||||
cat > "$TMP" << EPOCHS
|
||||
(epoch 1)
|
||||
(load "spec/stdlib.sx")
|
||||
(load "lib/r7rs.sx")
|
||||
(load "lib/persist/event.sx")
|
||||
(load "lib/persist/backend.sx")
|
||||
(load "lib/persist/log.sx")
|
||||
(load "lib/persist/kv.sx")
|
||||
(load "lib/artdag/dag.sx")
|
||||
(load "lib/datalog/tokenizer.sx")
|
||||
(load "lib/datalog/parser.sx")
|
||||
(load "lib/datalog/unify.sx")
|
||||
(load "lib/datalog/db.sx")
|
||||
(load "lib/datalog/builtins.sx")
|
||||
(load "lib/datalog/aggregates.sx")
|
||||
(load "lib/datalog/strata.sx")
|
||||
(load "lib/datalog/eval.sx")
|
||||
(load "lib/datalog/api.sx")
|
||||
(load "lib/datalog/magic.sx")
|
||||
(load "lib/git/object.sx")
|
||||
(load "lib/git/ref.sx")
|
||||
(load "lib/git/dag.sx")
|
||||
(load "lib/git/worktree.sx")
|
||||
(load "lib/git/diff.sx")
|
||||
(load "lib/git/merge.sx")
|
||||
(load "lib/git/porcelain.sx")
|
||||
(load "lib/git/sha1.sx")
|
||||
(load "lib/git/export.sx")
|
||||
(load "lib/git/import.sx")
|
||||
(epoch 2)
|
||||
(eval "(define git-test-pass 0)")
|
||||
(eval "(define git-test-fail 0)")
|
||||
(eval "(define git-test-failures (list))")
|
||||
(eval "(define git-test (fn (name got expected) (if (equal? got expected) (set! git-test-pass (+ git-test-pass 1)) (begin (set! git-test-fail (+ git-test-fail 1)) (set! git-test-failures (append git-test-failures (list (list name (inspect got) (inspect expected)))))))))")
|
||||
(epoch 3)
|
||||
(load "${file}")
|
||||
(epoch 4)
|
||||
(eval "(list git-test-pass git-test-fail)")
|
||||
EPOCHS
|
||||
|
||||
local OUTPUT
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||
rm -f "$TMP"
|
||||
|
||||
local LINE
|
||||
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
|
||||
if [ -z "$LINE" ]; then
|
||||
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
|
||||
| sed -E 's/^\(ok 4 //; s/\)$//')
|
||||
fi
|
||||
|
||||
local P F
|
||||
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
|
||||
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
|
||||
P=${P:-0}
|
||||
F=${F:-0}
|
||||
echo "${P} ${F}"
|
||||
}
|
||||
|
||||
declare -A SUITE_PASS
|
||||
declare -A SUITE_FAIL
|
||||
TOTAL_PASS=0
|
||||
TOTAL_FAIL=0
|
||||
|
||||
echo "Running sx-git conformance suite..." >&2
|
||||
for s in "${SUITES[@]}"; do
|
||||
read -r p f < <(run_suite "$s")
|
||||
SUITE_PASS[$s]=$p
|
||||
SUITE_FAIL[$s]=$f
|
||||
TOTAL_PASS=$((TOTAL_PASS + p))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + f))
|
||||
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
|
||||
done
|
||||
|
||||
# scoreboard.json
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
first=1
|
||||
for s in "${SUITES[@]}"; do
|
||||
if [ $first -eq 0 ]; then printf ',\n'; fi
|
||||
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
|
||||
first=0
|
||||
done
|
||||
printf '\n },\n'
|
||||
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
printf '}\n'
|
||||
} > "$OUT_JSON"
|
||||
|
||||
# scoreboard.md
|
||||
{
|
||||
printf '# sx-git Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/git/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for s in "${SUITES[@]}"; do
|
||||
p=${SUITE_PASS[$s]}
|
||||
f=${SUITE_FAIL[$s]}
|
||||
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
} > "$OUT_MD"
|
||||
|
||||
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||
|
||||
[ "$TOTAL_FAIL" -eq 0 ]
|
||||
180
lib/git/dag.sx
Normal file
180
lib/git/dag.sx
Normal file
@@ -0,0 +1,180 @@
|
||||
; lib/git/dag.sx — sx-git Phase 3: the commit DAG as queries.
|
||||
; The workhorse walks (log, ancestry, reachability, merge-base) run SX-side
|
||||
; over parent edges read straight from commit objects. The Datalog bridge
|
||||
; exports those edges as (git-parent child parent) facts under a deliberately
|
||||
; MINIMAL two-rule ancestor closure (every dl-query re-saturates the ruleset —
|
||||
; keep it lean, walk SX-side; see lib/relations/engine.sx for the precedent).
|
||||
; Requires: lib/git/object.sx; datalog stack only for the git/dag-db bridge.
|
||||
|
||||
(define
|
||||
git/parents
|
||||
(fn
|
||||
(repo cid)
|
||||
(let
|
||||
((c (git/read repo cid)))
|
||||
(if (git/commit? c) (git/commit-parents c) (list)))))
|
||||
|
||||
; ---- DFS postorder over parent edges, visited-set threaded through ----
|
||||
; returns (seen acc); acc lists each commit AFTER all its parents.
|
||||
(define
|
||||
git/dag-post
|
||||
(fn
|
||||
(repo cid seen acc)
|
||||
(if
|
||||
(has-key? seen cid)
|
||||
(list seen acc)
|
||||
(let
|
||||
((res (git/dag-post-list repo (git/parents repo cid) (assoc seen cid true) acc)))
|
||||
(list (first res) (append (nth res 1) (list cid)))))))
|
||||
|
||||
(define
|
||||
git/dag-post-list
|
||||
(fn
|
||||
(repo cids seen acc)
|
||||
(if
|
||||
(empty? cids)
|
||||
(list seen acc)
|
||||
(let
|
||||
((res (git/dag-post repo (first cids) seen acc)))
|
||||
(git/dag-post-list
|
||||
repo
|
||||
(rest cids)
|
||||
(first res)
|
||||
(nth res 1))))))
|
||||
|
||||
; all commits reachable from cid, INCLUDING cid (parents-first order)
|
||||
(define
|
||||
git/reachable
|
||||
(fn
|
||||
(repo cid)
|
||||
(nth (git/dag-post repo cid {} (list)) 1)))
|
||||
|
||||
(define
|
||||
git/reachable-all
|
||||
(fn
|
||||
(repo cids)
|
||||
(nth (git/dag-post-list repo cids {} (list)) 1)))
|
||||
|
||||
; ---- log: topological, children before parents (reverse DFS postorder) ----
|
||||
(define
|
||||
git/log
|
||||
(fn (repo head-cid) (reverse (git/reachable repo head-cid))))
|
||||
|
||||
(define
|
||||
git/log-messages
|
||||
(fn
|
||||
(repo head-cid)
|
||||
(map
|
||||
(fn (c) (git/commit-message (git/read repo c)))
|
||||
(git/log repo head-cid))))
|
||||
|
||||
; ---- ancestry ----
|
||||
; proper ancestors of cid (excludes cid itself unless reachable via a cycle,
|
||||
; which a well-formed commit DAG never has)
|
||||
(define
|
||||
git/ancestors
|
||||
(fn
|
||||
(repo cid)
|
||||
(nth
|
||||
(git/dag-post-list repo (git/parents repo cid) {} (list))
|
||||
1)))
|
||||
|
||||
(define
|
||||
git/dag-member?
|
||||
(fn (x xs) (reduce (fn (acc y) (or acc (equal? x y))) false xs)))
|
||||
|
||||
; reflexive, like `git merge-base --is-ancestor A B`: is a an ancestor of b?
|
||||
(define
|
||||
git/is-ancestor?
|
||||
(fn
|
||||
(repo a b)
|
||||
(if (equal? a b) true (git/dag-member? a (git/ancestors repo b)))))
|
||||
|
||||
; ---- merge-base (LCA) ----
|
||||
(define
|
||||
git/dag-set
|
||||
(fn (xs) (reduce (fn (acc x) (assoc acc x true)) {} xs)))
|
||||
|
||||
(define
|
||||
git/common-ancestors
|
||||
(fn
|
||||
(repo a b)
|
||||
(let
|
||||
((sb (git/dag-set (git/reachable repo b))))
|
||||
(filter (fn (c) (has-key? sb c)) (git/reachable repo a)))))
|
||||
|
||||
; all best common ancestors: common ancestors dominated by no other common
|
||||
; ancestor (c is dominated when it is a proper ancestor of another common d)
|
||||
(define
|
||||
git/merge-bases
|
||||
(fn
|
||||
(repo a b)
|
||||
(let
|
||||
((common (git/common-ancestors repo a b)))
|
||||
(artdag/sort-strings
|
||||
(filter
|
||||
(fn
|
||||
(c)
|
||||
(not
|
||||
(reduce
|
||||
(fn
|
||||
(acc d)
|
||||
(or
|
||||
acc
|
||||
(and
|
||||
(not (equal? c d))
|
||||
(git/dag-member? c (git/ancestors repo d)))))
|
||||
false
|
||||
common)))
|
||||
common)))))
|
||||
|
||||
(define
|
||||
git/merge-base
|
||||
(fn
|
||||
(repo a b)
|
||||
(let
|
||||
((bs (git/merge-bases repo a b)))
|
||||
(if (empty? bs) nil (first bs)))))
|
||||
|
||||
; ---- Datalog bridge: parent edges as facts, minimal ancestor closure ----
|
||||
(define
|
||||
git/dag-rules
|
||||
(quote
|
||||
((git-anc X Y <- (git-parent X Y))
|
||||
(git-anc X Y <- (git-parent X Z) (git-anc Z Y)))))
|
||||
|
||||
(define
|
||||
git/dag-facts
|
||||
(fn
|
||||
(repo heads)
|
||||
(reduce
|
||||
(fn
|
||||
(acc c)
|
||||
(append
|
||||
acc
|
||||
(map
|
||||
(fn (p) (list (quote git-parent) c p))
|
||||
(git/parents repo c))))
|
||||
(list)
|
||||
(git/reachable-all repo heads))))
|
||||
|
||||
(define
|
||||
git/dag-db
|
||||
(fn
|
||||
(repo heads)
|
||||
(dl-program-data (git/dag-facts repo heads) git/dag-rules)))
|
||||
|
||||
(define
|
||||
git/ancestors-dl
|
||||
(fn
|
||||
(db cid)
|
||||
(artdag/sort-strings
|
||||
(map
|
||||
(fn (s) (get s :Y))
|
||||
(dl-query db (list (quote git-anc) cid (quote Y)))))))
|
||||
|
||||
(define
|
||||
git/is-ancestor-dl?
|
||||
(fn
|
||||
(db a b)
|
||||
(> (len (dl-query db (list (quote git-anc) b a))) 0)))
|
||||
427
lib/git/diff.sx
Normal file
427
lib/git/diff.sx
Normal file
@@ -0,0 +1,427 @@
|
||||
; lib/git/diff.sx — sx-git Phase 5: structural tree diff + Myers line diff.
|
||||
; Tree diff = files-diff over flattened trees (path -> blob cid). Blob diff =
|
||||
; Myers O(ND) shortest edit script over lines, edit script ops
|
||||
; {:op "eq"|"del"|"add" :line l}, rendered as unified hunks (context 3).
|
||||
; Requires: lib/git/object.sx, lib/git/worktree.sx.
|
||||
|
||||
; ---- lines <-> data ----
|
||||
(define
|
||||
git/diff-take
|
||||
(fn
|
||||
(xs n)
|
||||
(if
|
||||
(or (= n 0) (empty? xs))
|
||||
(list)
|
||||
(cons (first xs) (git/diff-take (rest xs) (- n 1))))))
|
||||
|
||||
(define
|
||||
git/diff-lines
|
||||
(fn
|
||||
(s)
|
||||
(let
|
||||
((parts (split s "\n")))
|
||||
(if
|
||||
(and
|
||||
(> (len parts) 0)
|
||||
(equal? (nth parts (- (len parts) 1)) ""))
|
||||
(git/diff-take parts (- (len parts) 1))
|
||||
parts))))
|
||||
|
||||
; index-keyed dict as an O(1) vector
|
||||
(define
|
||||
git/dvec
|
||||
(fn
|
||||
(xs)
|
||||
(reduce
|
||||
(fn (acc p) (assoc acc (str (first p)) (nth p 1)))
|
||||
{}
|
||||
(map-indexed (fn (i x) (list i x)) xs))))
|
||||
(define git/dget (fn (v i) (get v (str i))))
|
||||
|
||||
; ---- Myers forward pass ----
|
||||
; v: dict k(str) -> furthest x on diagonal k. Reads of k±1 always hit the
|
||||
; previous round's parity, so in-round writes never corrupt the decision.
|
||||
(define
|
||||
git/myers-x
|
||||
(fn
|
||||
(v d k)
|
||||
(if
|
||||
(or
|
||||
(= k (- 0 d))
|
||||
(and
|
||||
(not (= k d))
|
||||
(< (git/dget v (- k 1)) (git/dget v (+ k 1)))))
|
||||
(git/dget v (+ k 1))
|
||||
(+ (git/dget v (- k 1)) 1))))
|
||||
|
||||
(define
|
||||
git/myers-snake
|
||||
(fn
|
||||
(av bv n m x y)
|
||||
(if
|
||||
(and (< x n) (< y m) (equal? (git/dget av x) (git/dget bv y)))
|
||||
(git/myers-snake av bv n m (+ x 1) (+ y 1))
|
||||
(list x y))))
|
||||
|
||||
; one round d over k = -d, -d+2, ..., d; returns (v done?)
|
||||
(define
|
||||
git/myers-round
|
||||
(fn
|
||||
(av bv n m v d k)
|
||||
(if
|
||||
(> k d)
|
||||
(list v false)
|
||||
(let
|
||||
((sn (git/myers-snake av bv n m (git/myers-x v d k) (- (git/myers-x v d k) k))))
|
||||
(let
|
||||
((v2 (assoc v (str k) (first sn))))
|
||||
(if
|
||||
(and (>= (first sn) n) (>= (nth sn 1) m))
|
||||
(list v2 true)
|
||||
(git/myers-round av bv n m v2 d (+ k 2))))))))
|
||||
|
||||
; trace[d] = v entering round d; returns (trace D)
|
||||
(define
|
||||
git/myers-run
|
||||
(fn
|
||||
(av bv n m v d trace)
|
||||
(let
|
||||
((trace2 (append trace (list v))))
|
||||
(let
|
||||
((res (git/myers-round av bv n m v d (- 0 d))))
|
||||
(if
|
||||
(nth res 1)
|
||||
(list trace2 d)
|
||||
(git/myers-run av bv n m (first res) (+ d 1) trace2))))))
|
||||
|
||||
; ---- Myers backtrack: walk (n,m) back to (0,0), cons ops into forward order ----
|
||||
(define
|
||||
git/myers-diag
|
||||
(fn
|
||||
(av x y px py ops)
|
||||
(if
|
||||
(and (> x px) (> y py))
|
||||
(git/myers-diag
|
||||
av
|
||||
(- x 1)
|
||||
(- y 1)
|
||||
px
|
||||
py
|
||||
(cons {:op "eq" :line (git/dget av (- x 1))} ops))
|
||||
(list x y ops))))
|
||||
|
||||
(define
|
||||
git/myers-back
|
||||
(fn
|
||||
(av bv trace d x y ops)
|
||||
(if
|
||||
(< d 0)
|
||||
ops
|
||||
(let
|
||||
((v (nth trace d)))
|
||||
(let
|
||||
((k (- x y)))
|
||||
(let
|
||||
((pk (if (or (= k (- 0 d)) (and (not (= k d)) (< (git/dget v (- k 1)) (git/dget v (+ k 1))))) (+ k 1) (- k 1))))
|
||||
(let
|
||||
((px (git/dget v pk)))
|
||||
(let
|
||||
((py (- px pk)))
|
||||
(let
|
||||
((r (git/myers-diag av x y px py ops)))
|
||||
(if
|
||||
(> d 0)
|
||||
(git/myers-back
|
||||
av
|
||||
bv
|
||||
trace
|
||||
(- d 1)
|
||||
px
|
||||
py
|
||||
(if
|
||||
(= (first r) px)
|
||||
(cons {:op "add" :line (git/dget bv py)} (nth r 2))
|
||||
(cons {:op "del" :line (git/dget av px)} (nth r 2))))
|
||||
(nth r 2)))))))))))
|
||||
|
||||
; ---- edit script over two strings ----
|
||||
(define
|
||||
git/diff-script-lines
|
||||
(fn
|
||||
(al bl)
|
||||
(let
|
||||
((rt (git/myers-run (git/dvec al) (git/dvec bl) (len al) (len bl) (assoc {} "1" 0) 0 (list))))
|
||||
(git/myers-back
|
||||
(git/dvec al)
|
||||
(git/dvec bl)
|
||||
(first rt)
|
||||
(nth rt 1)
|
||||
(len al)
|
||||
(len bl)
|
||||
(list)))))
|
||||
|
||||
(define
|
||||
git/diff-script
|
||||
(fn
|
||||
(a-data b-data)
|
||||
(git/diff-script-lines (git/diff-lines a-data) (git/diff-lines b-data))))
|
||||
|
||||
; reconstruction invariants: old = eq+del lines, new = eq+add lines
|
||||
(define
|
||||
git/diff-changes
|
||||
(fn
|
||||
(script)
|
||||
(len (filter (fn (o) (not (equal? (get o :op) "eq"))) script))))
|
||||
(define
|
||||
git/diff-old-lines
|
||||
(fn
|
||||
(script)
|
||||
(map
|
||||
(fn (o) (get o :line))
|
||||
(filter (fn (o) (not (equal? (get o :op) "add"))) script))))
|
||||
|
||||
; ---- unified rendering ----
|
||||
(define
|
||||
git/diff-new-lines
|
||||
(fn
|
||||
(script)
|
||||
(map
|
||||
(fn (o) (get o :line))
|
||||
(filter (fn (o) (not (equal? (get o :op) "del"))) script))))
|
||||
|
||||
(define
|
||||
git/diff-annotate
|
||||
(fn
|
||||
(script)
|
||||
(nth
|
||||
(reduce
|
||||
(fn
|
||||
(acc o)
|
||||
(let
|
||||
((a (first acc))
|
||||
(b (nth acc 1))
|
||||
(out (nth acc 2)))
|
||||
(cond
|
||||
((equal? (get o :op) "eq")
|
||||
(list
|
||||
(+ a 1)
|
||||
(+ b 1)
|
||||
(append out (list (merge o {:a a :b b})))))
|
||||
((equal? (get o :op) "del")
|
||||
(list
|
||||
(+ a 1)
|
||||
b
|
||||
(append out (list (merge o {:a a :b b})))))
|
||||
(else
|
||||
(list
|
||||
a
|
||||
(+ b 1)
|
||||
(append out (list (merge o {:a a :b b}))))))))
|
||||
(list 1 1 (list))
|
||||
script)
|
||||
2)))
|
||||
|
||||
(define
|
||||
git/diff-change-idxs
|
||||
(fn
|
||||
(script)
|
||||
(map
|
||||
(fn (p) (first p))
|
||||
(filter
|
||||
(fn (p) (not (equal? (get (nth p 1) :op) "eq")))
|
||||
(map-indexed (fn (i o) (list i o)) script)))))
|
||||
|
||||
(define
|
||||
git/diff-merge-ranges
|
||||
(fn
|
||||
(ranges)
|
||||
(reduce
|
||||
(fn
|
||||
(acc r)
|
||||
(if
|
||||
(empty? acc)
|
||||
(list r)
|
||||
(let
|
||||
((prev (nth acc (- (len acc) 1))))
|
||||
(if
|
||||
(<= (first r) (+ (nth prev 1) 1))
|
||||
(append
|
||||
(git/diff-take acc (- (len acc) 1))
|
||||
(list
|
||||
(list
|
||||
(first prev)
|
||||
(max (nth prev 1) (nth r 1)))))
|
||||
(append acc (list r))))))
|
||||
(list)
|
||||
ranges)))
|
||||
|
||||
(define
|
||||
git/diff-hunk-ranges
|
||||
(fn
|
||||
(script ctx)
|
||||
(git/diff-merge-ranges
|
||||
(map
|
||||
(fn
|
||||
(i)
|
||||
(list
|
||||
(max 0 (- i ctx))
|
||||
(min (- (len script) 1) (+ i ctx))))
|
||||
(git/diff-change-idxs script)))))
|
||||
|
||||
(define
|
||||
git/diff-slice
|
||||
(fn
|
||||
(xs from to)
|
||||
(map
|
||||
(fn (p) (nth p 1))
|
||||
(filter
|
||||
(fn (p) (and (>= (first p) from) (<= (first p) to)))
|
||||
(map-indexed (fn (i x) (list i x)) xs)))))
|
||||
|
||||
(define
|
||||
git/diff-op-char
|
||||
(fn
|
||||
(op)
|
||||
(cond ((equal? op "eq") " ") ((equal? op "del") "-") (else "+"))))
|
||||
|
||||
(define
|
||||
git/diff-hunk-render
|
||||
(fn
|
||||
(ops)
|
||||
(let
|
||||
((acount (len (filter (fn (o) (not (equal? (get o :op) "add"))) ops)))
|
||||
(bcount
|
||||
(len (filter (fn (o) (not (equal? (get o :op) "del"))) ops))))
|
||||
(let
|
||||
((astart (if (= acount 0) (- (get (first ops) :a) 1) (get (first ops) :a)))
|
||||
(bstart
|
||||
(if
|
||||
(= bcount 0)
|
||||
(- (get (first ops) :b) 1)
|
||||
(get (first ops) :b))))
|
||||
(str
|
||||
"@@ -"
|
||||
astart
|
||||
","
|
||||
acount
|
||||
" +"
|
||||
bstart
|
||||
","
|
||||
bcount
|
||||
" @@\n"
|
||||
(reduce
|
||||
(fn
|
||||
(acc o)
|
||||
(str acc (git/diff-op-char (get o :op)) (get o :line) "\n"))
|
||||
""
|
||||
ops))))))
|
||||
|
||||
; ---- object-level diffs ----
|
||||
(define
|
||||
git/diff-unified
|
||||
(fn
|
||||
(a-data b-data)
|
||||
(let
|
||||
((ann (git/diff-annotate (git/diff-script a-data b-data))))
|
||||
(reduce
|
||||
(fn
|
||||
(acc r)
|
||||
(str
|
||||
acc
|
||||
(git/diff-hunk-render
|
||||
(git/diff-slice ann (first r) (nth r 1)))))
|
||||
""
|
||||
(git/diff-hunk-ranges ann 3)))))
|
||||
|
||||
(define
|
||||
git/blob-diff
|
||||
(fn
|
||||
(repo b1 b2)
|
||||
(git/diff-script
|
||||
(git/blob-data (git/read repo b1))
|
||||
(git/blob-data (git/read repo b2)))))
|
||||
|
||||
(define
|
||||
git/tree-diff
|
||||
(fn
|
||||
(repo t1 t2)
|
||||
(git/files-diff (git/tree-flatten repo t1) (git/tree-flatten repo t2))))
|
||||
|
||||
; ---- whole-commit unified render: added, deleted, then modified paths ----
|
||||
(define
|
||||
git/commit-diff
|
||||
(fn
|
||||
(repo c1 c2)
|
||||
(git/tree-diff
|
||||
repo
|
||||
(git/commit-tree (git/read repo c1))
|
||||
(git/commit-tree (git/read repo c2)))))
|
||||
|
||||
(define
|
||||
git/diff-path-data
|
||||
(fn
|
||||
(repo flat path)
|
||||
(if
|
||||
(has-key? flat path)
|
||||
(git/blob-data (git/read repo (get flat path)))
|
||||
"")))
|
||||
|
||||
(define
|
||||
git/commit-diff-unified
|
||||
(fn
|
||||
(repo c1 c2)
|
||||
(let
|
||||
((f1 (git/tree-flatten repo (git/commit-tree (git/read repo c1))))
|
||||
(f2 (git/tree-flatten repo (git/commit-tree (git/read repo c2)))))
|
||||
(let
|
||||
((d (git/files-diff f1 f2)))
|
||||
(str
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(str
|
||||
acc
|
||||
"diff --sx a/"
|
||||
p
|
||||
" b/"
|
||||
p
|
||||
"\n--- /dev/null\n+++ b/"
|
||||
p
|
||||
"\n"
|
||||
(git/diff-unified "" (git/diff-path-data repo f2 p))))
|
||||
""
|
||||
(get d :added))
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(str
|
||||
acc
|
||||
"diff --sx a/"
|
||||
p
|
||||
" b/"
|
||||
p
|
||||
"\n--- a/"
|
||||
p
|
||||
"\n+++ /dev/null\n"
|
||||
(git/diff-unified (git/diff-path-data repo f1 p) "")))
|
||||
""
|
||||
(get d :deleted))
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(str
|
||||
acc
|
||||
"diff --sx a/"
|
||||
p
|
||||
" b/"
|
||||
p
|
||||
"\n--- a/"
|
||||
p
|
||||
"\n+++ b/"
|
||||
p
|
||||
"\n"
|
||||
(git/diff-unified
|
||||
(git/diff-path-data repo f1 p)
|
||||
(git/diff-path-data repo f2 p))))
|
||||
""
|
||||
(get d :modified)))))))
|
||||
225
lib/git/export.sx
Normal file
225
lib/git/export.sx
Normal file
@@ -0,0 +1,225 @@
|
||||
; lib/git/export.sx — git-wire EXPORT ADAPTER (flagged extension).
|
||||
; Converts native sx-git objects into byte-exact git loose-object payloads:
|
||||
; "<type> <len>\0<body>" with real git SHA-1 identity, golden-verified
|
||||
; against git CLI output. This is an adapter at the edge — the base model
|
||||
; stays native-CID (sx1:/sha256, typed extensible dicts). zlib/packfiles are
|
||||
; host-side concerns and stay out of scope: the adapter emits type+bytes+sha1.
|
||||
; Field mapping (commit/tag idents): :author/:email/:time/:tz with committer
|
||||
; overrides :committer/:committer-email/:committer-time/:committer-tz;
|
||||
; defaults email="<author>@sx", time=0, tz="+0000". Messages get a trailing
|
||||
; newline if missing. Extra native fields do NOT survive export (git wire
|
||||
; has nowhere to put them) — that loss is the point of native-first.
|
||||
; Requires: lib/git/object.sx, lib/git/sha1.sx, lib/artdag/dag.sx.
|
||||
|
||||
(define git/export-nul (list->string (list (integer->char 0))))
|
||||
|
||||
; ---- tree entry details ----
|
||||
; raw tree bytes use "40000" for subtrees (no leading zero); files default
|
||||
; to 100644 unless the entry carries an explicit :mode (e.g. "100755")
|
||||
(define
|
||||
git/export-mode
|
||||
(fn
|
||||
(e)
|
||||
(if
|
||||
(has-key? e :mode)
|
||||
(get e :mode)
|
||||
(if (equal? (get e :kind) "tree") "40000" "100644"))))
|
||||
|
||||
; git sorts tree entries by name bytes with directories keyed as "name/"
|
||||
(define
|
||||
git/export-sortkey
|
||||
(fn
|
||||
(tree name)
|
||||
(if
|
||||
(equal? (git/entry-kind (git/tree-entry-for tree name)) "tree")
|
||||
(str name "/")
|
||||
name)))
|
||||
|
||||
(define
|
||||
git/export-ins
|
||||
(fn
|
||||
(tree sorted n)
|
||||
(cond
|
||||
((empty? sorted) (list n))
|
||||
((artdag/str<? (git/export-sortkey tree n) (git/export-sortkey tree (first sorted)))
|
||||
(cons n sorted))
|
||||
(else (cons (first sorted) (git/export-ins tree (rest sorted) n))))))
|
||||
|
||||
(define
|
||||
git/export-sort-names
|
||||
(fn
|
||||
(tree names)
|
||||
(reduce (fn (acc n) (git/export-ins tree acc n)) (list) names)))
|
||||
|
||||
; ---- ident + message formatting ----
|
||||
(define git/export-or (fn (v dflt) (if (equal? v nil) dflt v)))
|
||||
|
||||
(define
|
||||
git/export-author-ident
|
||||
(fn
|
||||
(obj)
|
||||
(let
|
||||
((name (git/export-or (get obj :author) "sx")))
|
||||
(str
|
||||
name
|
||||
" <"
|
||||
(git/export-or (get obj :email) (str name "@sx"))
|
||||
"> "
|
||||
(git/export-or (get obj :time) 0)
|
||||
" "
|
||||
(git/export-or (get obj :tz) "+0000")))))
|
||||
|
||||
(define
|
||||
git/export-committer-ident
|
||||
(fn
|
||||
(obj)
|
||||
(let
|
||||
((name (git/export-or (get obj :committer) (git/export-or (get obj :author) "sx"))))
|
||||
(str
|
||||
name
|
||||
" <"
|
||||
(git/export-or
|
||||
(get obj :committer-email)
|
||||
(git/export-or (get obj :email) (str name "@sx")))
|
||||
"> "
|
||||
(git/export-or
|
||||
(get obj :committer-time)
|
||||
(git/export-or (get obj :time) 0))
|
||||
" "
|
||||
(git/export-or
|
||||
(get obj :committer-tz)
|
||||
(git/export-or (get obj :tz) "+0000"))))))
|
||||
|
||||
(define
|
||||
git/export-message
|
||||
(fn
|
||||
(obj)
|
||||
(let
|
||||
((m (git/export-or (get obj :message) "")))
|
||||
(let
|
||||
((n (string-length m)))
|
||||
(if
|
||||
(and
|
||||
(> n 0)
|
||||
(equal? (substring m (- n 1) n) "\n"))
|
||||
m
|
||||
(str m "\n"))))))
|
||||
|
||||
; ---- object payloads ----
|
||||
(define
|
||||
git/export-entry
|
||||
(fn
|
||||
(type body)
|
||||
(let
|
||||
((full (str type " " (string-length body) git/export-nul body)))
|
||||
{:type type :sha1 (git/sha1-hex full) :bytes full})))
|
||||
|
||||
(define
|
||||
git/export-tree-body
|
||||
(fn
|
||||
(tree table)
|
||||
(reduce
|
||||
(fn
|
||||
(acc name)
|
||||
(let
|
||||
((e (git/tree-entry-for tree name)))
|
||||
(str
|
||||
acc
|
||||
(git/export-mode e)
|
||||
" "
|
||||
name
|
||||
git/export-nul
|
||||
(git/hex->raw (get (get table (git/entry-cid e)) :sha1)))))
|
||||
""
|
||||
(git/export-sort-names tree (git/tree-names tree)))))
|
||||
|
||||
(define
|
||||
git/export-commit-body
|
||||
(fn
|
||||
(obj table)
|
||||
(str
|
||||
"tree "
|
||||
(get (get table (git/commit-tree obj)) :sha1)
|
||||
"\n"
|
||||
(reduce
|
||||
(fn (acc p) (str acc "parent " (get (get table p) :sha1) "\n"))
|
||||
""
|
||||
(git/commit-parents obj))
|
||||
"author "
|
||||
(git/export-author-ident obj)
|
||||
"\n"
|
||||
"committer "
|
||||
(git/export-committer-ident obj)
|
||||
"\n"
|
||||
"\n"
|
||||
(git/export-message obj))))
|
||||
|
||||
(define
|
||||
git/export-tag-body
|
||||
(fn
|
||||
(obj table)
|
||||
(str
|
||||
"object "
|
||||
(get (get table (git/tag-target obj)) :sha1)
|
||||
"\n"
|
||||
"type "
|
||||
(get (get table (git/tag-target obj)) :type)
|
||||
"\n"
|
||||
"tag "
|
||||
(git/tag-name obj)
|
||||
"\n"
|
||||
"tagger "
|
||||
(git/export-author-ident obj)
|
||||
"\n"
|
||||
"\n"
|
||||
(git/export-message obj))))
|
||||
|
||||
; ---- recursive closure export: table cid -> {:type :bytes :sha1} ----
|
||||
(define
|
||||
git/export-into
|
||||
(fn
|
||||
(repo cid table)
|
||||
(if
|
||||
(has-key? table cid)
|
||||
table
|
||||
(let
|
||||
((obj (git/read repo cid)))
|
||||
(cond
|
||||
((git/blob? obj)
|
||||
(assoc table cid (git/export-entry "blob" (git/blob-data obj))))
|
||||
((git/tree? obj)
|
||||
(let
|
||||
((t2 (reduce (fn (tb name) (git/export-into repo (git/entry-cid (git/tree-entry-for obj name)) tb)) table (git/tree-names obj))))
|
||||
(assoc
|
||||
t2
|
||||
cid
|
||||
(git/export-entry "tree" (git/export-tree-body obj t2)))))
|
||||
((git/commit? obj)
|
||||
(let
|
||||
((t2 (git/export-into repo (git/commit-tree obj) (reduce (fn (tb p) (git/export-into repo p tb)) table (git/commit-parents obj)))))
|
||||
(assoc
|
||||
t2
|
||||
cid
|
||||
(git/export-entry "commit" (git/export-commit-body obj t2)))))
|
||||
((git/tag? obj)
|
||||
(let
|
||||
((t2 (git/export-into repo (git/tag-target obj) table)))
|
||||
(assoc
|
||||
t2
|
||||
cid
|
||||
(git/export-entry "tag" (git/export-tag-body obj t2)))))
|
||||
(else table))))))
|
||||
|
||||
(define
|
||||
git/export-closure
|
||||
(fn (repo cid) (git/export-into repo cid {})))
|
||||
|
||||
; export one object (with its dependencies): {:type :bytes :sha1}
|
||||
(define git/export (fn (repo cid) (get (git/export-closure repo cid) cid)))
|
||||
|
||||
(define git/export-sha (fn (repo cid) (get (git/export repo cid) :sha1)))
|
||||
|
||||
; host-writable set: {:head <sha1> :objects {sha1 -> {:type :bytes}}}
|
||||
(define
|
||||
git/export-set
|
||||
(fn (repo cid) (let ((table (git/export-closure repo cid))) {:head (get (get table cid) :sha1) :objects (reduce (fn (acc c) (let ((e (get table c))) (assoc acc (get e :sha1) {:type (get e :type) :bytes (get e :bytes)}))) {} (keys table))})))
|
||||
244
lib/git/import.sx
Normal file
244
lib/git/import.sx
Normal file
@@ -0,0 +1,244 @@
|
||||
; lib/git/import.sx — git-wire IMPORT (inverse of export.sx).
|
||||
; Parses loose-object payloads "<type> <len>\0<body>" back into native
|
||||
; objects, bottom-up over an export-set-shaped table {sha1 -> {:bytes ...}}.
|
||||
; Wire round-trip (bytes -> native -> bytes) is byte-exact: messages kept
|
||||
; verbatim, non-default tree modes preserved as entry :mode, committer
|
||||
; fields stored only when they differ from the author (so export's defaults
|
||||
; regenerate identical bytes). Native cids of imported blobs/trees with
|
||||
; default modes equal the originals'.
|
||||
; Requires: lib/git/object.sx, lib/git/sha1.sx, lib/git/export.sx.
|
||||
|
||||
; ---- scanning ----
|
||||
(define
|
||||
git/import-index-of
|
||||
(fn
|
||||
(s ch i n)
|
||||
(cond
|
||||
((>= i n) -1)
|
||||
((equal? (substring s i (+ i 1)) ch) i)
|
||||
(else (git/import-index-of s ch (+ i 1) n)))))
|
||||
|
||||
(define
|
||||
git/import-find
|
||||
(fn (s ch from) (git/import-index-of s ch from (string-length s))))
|
||||
|
||||
(define
|
||||
git/import-rfind
|
||||
(fn
|
||||
(s ch i)
|
||||
(cond
|
||||
((< i 0) -1)
|
||||
((equal? (substring s i (+ i 1)) ch) i)
|
||||
(else (git/import-rfind s ch (- i 1))))))
|
||||
|
||||
(define
|
||||
git/import-find2
|
||||
(fn
|
||||
(s i n)
|
||||
(cond
|
||||
((> (+ i 2) n) -1)
|
||||
((equal? (substring s i (+ i 2)) "\n\n") i)
|
||||
(else (git/import-find2 s (+ i 1) n)))))
|
||||
|
||||
(define
|
||||
git/raw->hex-go
|
||||
(fn
|
||||
(raw i n acc)
|
||||
(if
|
||||
(>= i n)
|
||||
acc
|
||||
(git/raw->hex-go
|
||||
raw
|
||||
(+ i 1)
|
||||
n
|
||||
(str
|
||||
acc
|
||||
(git/sha1-byte-hex (char-code (substring raw i (+ i 1)))))))))
|
||||
|
||||
(define
|
||||
git/raw->hex
|
||||
(fn (raw) (git/raw->hex-go raw 0 (string-length raw) "")))
|
||||
|
||||
; ---- payload header ----
|
||||
(define
|
||||
git/import-payload
|
||||
(fn
|
||||
(bytes)
|
||||
(let
|
||||
((sp (git/import-find bytes " " 0))
|
||||
(z (git/import-find bytes git/export-nul 0)))
|
||||
{:type (substring bytes 0 sp) :body (substring bytes (+ z 1) (string-length bytes))})))
|
||||
|
||||
; ---- tree body: "<mode> <name>\0<20 raw sha>" sequence ----
|
||||
(define
|
||||
git/import-tree-entries
|
||||
(fn
|
||||
(body i n acc)
|
||||
(if
|
||||
(>= i n)
|
||||
acc
|
||||
(let
|
||||
((sp (git/import-find body " " i)))
|
||||
(let
|
||||
((z (git/import-find body git/export-nul sp)))
|
||||
(git/import-tree-entries
|
||||
body
|
||||
(+ z 21)
|
||||
n
|
||||
(append acc (list {:name (substring body (+ sp 1) z) :sha (git/raw->hex (substring body (+ z 1) (+ z 21))) :mode (substring body i sp)}))))))))
|
||||
|
||||
; ---- ident: "name <email> time tz" ----
|
||||
(define
|
||||
git/import-ident
|
||||
(fn
|
||||
(line)
|
||||
(let
|
||||
((lt (git/import-rfind line "<" (- (string-length line) 1)))
|
||||
(gt (git/import-rfind line ">" (- (string-length line) 1))))
|
||||
(let
|
||||
((rest (substring line (+ gt 2) (string-length line))))
|
||||
(let ((sp (git/import-find rest " " 0))) {:name (substring line 0 (max 0 (- lt 1))) :tz (substring rest (+ sp 1) (string-length rest)) :email (substring line (+ lt 1) gt) :time (parse-int (substring rest 0 sp))})))))
|
||||
|
||||
; ---- commit / tag bodies ----
|
||||
(define
|
||||
git/import-headers
|
||||
(fn
|
||||
(body)
|
||||
(let
|
||||
((cut (git/import-find2 body 0 (string-length body))))
|
||||
{:message (substring body (+ cut 2) (string-length body)) :lines (split (substring body 0 cut) "\n")})))
|
||||
|
||||
(define
|
||||
git/import-commit-parse
|
||||
(fn
|
||||
(body)
|
||||
(let
|
||||
((h (git/import-headers body)))
|
||||
(reduce
|
||||
(fn
|
||||
(acc line)
|
||||
(cond
|
||||
((starts-with? line "tree ")
|
||||
(assoc
|
||||
acc
|
||||
:tree (substring line 5 (string-length line))))
|
||||
((starts-with? line "parent ")
|
||||
(assoc
|
||||
acc
|
||||
:parents (append
|
||||
(get acc :parents)
|
||||
(list (substring line 7 (string-length line))))))
|
||||
((starts-with? line "author ")
|
||||
(assoc
|
||||
acc
|
||||
:author (git/import-ident
|
||||
(substring line 7 (string-length line)))))
|
||||
((starts-with? line "committer ")
|
||||
(assoc
|
||||
acc
|
||||
:committer (git/import-ident
|
||||
(substring line 10 (string-length line)))))
|
||||
(else acc)))
|
||||
{:message (get h :message) :parents (list)}
|
||||
(get h :lines)))))
|
||||
|
||||
(define
|
||||
git/import-tag-parse
|
||||
(fn
|
||||
(body)
|
||||
(let
|
||||
((h (git/import-headers body)))
|
||||
(reduce
|
||||
(fn
|
||||
(acc line)
|
||||
(cond
|
||||
((starts-with? line "object ")
|
||||
(assoc
|
||||
acc
|
||||
:object (substring line 7 (string-length line))))
|
||||
((starts-with? line "tag ")
|
||||
(assoc
|
||||
acc
|
||||
:tag (substring line 4 (string-length line))))
|
||||
((starts-with? line "tagger ")
|
||||
(assoc
|
||||
acc
|
||||
:tagger (git/import-ident
|
||||
(substring line 7 (string-length line)))))
|
||||
(else acc)))
|
||||
{:message (get h :message)}
|
||||
(get h :lines)))))
|
||||
|
||||
; ident dict -> native commit meta fields
|
||||
(define git/import-author-meta (fn (a) {:tz (get a :tz) :email (get a :email) :time (get a :time) :author (get a :name)}))
|
||||
|
||||
(define
|
||||
git/import-commit-meta
|
||||
(fn
|
||||
(p)
|
||||
(let
|
||||
((a (get p :author)) (c (get p :committer)))
|
||||
(merge
|
||||
(merge (git/import-author-meta a) {:message (get p :message)})
|
||||
(if (or (equal? c nil) (= a c)) {} {:committer (get c :name) :committer-tz (get c :tz) :committer-email (get c :email) :committer-time (get c :time)})))))
|
||||
|
||||
; ---- bottom-up import over a sha->{:bytes} table; memo: sha -> native cid ----
|
||||
(define
|
||||
git/import-sha
|
||||
(fn
|
||||
(repo objects sha memo)
|
||||
(if
|
||||
(has-key? memo sha)
|
||||
memo
|
||||
(let
|
||||
((p (git/import-payload (get (get objects sha) :bytes))))
|
||||
(cond
|
||||
((equal? (get p :type) "blob")
|
||||
(assoc memo sha (git/write-blob repo (get p :body))))
|
||||
((equal? (get p :type) "tree")
|
||||
(let
|
||||
((res (reduce (fn (acc pe) (let ((m2 (git/import-sha repo objects (get pe :sha) (first acc)))) (list m2 (assoc (nth acc 1) (get pe :name) (let ((base {:kind (if (equal? (get pe :mode) "40000") "tree" "blob") :cid (get m2 (get pe :sha))})) (if (or (equal? (get pe :mode) "40000") (equal? (get pe :mode) "100644")) base (merge base {:mode (get pe :mode)}))))))) (list memo {}) (git/import-tree-entries (get p :body) 0 (string-length (get p :body)) (list)))))
|
||||
(assoc
|
||||
(first res)
|
||||
sha
|
||||
(git/write repo (git/tree (nth res 1))))))
|
||||
((equal? (get p :type) "commit")
|
||||
(let
|
||||
((cp (git/import-commit-parse (get p :body))))
|
||||
(let
|
||||
((m2 (git/import-sha repo objects (get cp :tree) (reduce (fn (mm ps) (git/import-sha repo objects ps mm)) memo (get cp :parents)))))
|
||||
(assoc
|
||||
m2
|
||||
sha
|
||||
(git/write
|
||||
repo
|
||||
(git/commit
|
||||
(get m2 (get cp :tree))
|
||||
(map (fn (ps) (get m2 ps)) (get cp :parents))
|
||||
(git/import-commit-meta cp)))))))
|
||||
((equal? (get p :type) "tag")
|
||||
(let
|
||||
((tp (git/import-tag-parse (get p :body))))
|
||||
(let
|
||||
((m2 (git/import-sha repo objects (get tp :object) memo)))
|
||||
(assoc
|
||||
m2
|
||||
sha
|
||||
(git/write
|
||||
repo
|
||||
(git/tag
|
||||
(get m2 (get tp :object))
|
||||
(get tp :tag)
|
||||
(merge
|
||||
(git/import-author-meta (get tp :tagger))
|
||||
{:message (get tp :message)})))))))
|
||||
(else memo))))))
|
||||
|
||||
; import a {:head :objects} set; returns the native cid of the head object
|
||||
(define
|
||||
git/import-set
|
||||
(fn
|
||||
(repo set)
|
||||
(get
|
||||
(git/import-sha repo (get set :objects) (get set :head) {})
|
||||
(get set :head))))
|
||||
224
lib/git/merge.sx
Normal file
224
lib/git/merge.sx
Normal file
@@ -0,0 +1,224 @@
|
||||
; lib/git/merge.sx — sx-git Phase 6: 3-way merge.
|
||||
; Textual diff3: regions (non-eq runs of the Myers script base->side) are
|
||||
; clustered by strict base-interval overlap (same-point insertions from both
|
||||
; sides also cluster); one-sided clusters apply cleanly, two-sided clusters
|
||||
; take the shared result or emit diff3 conflict markers with the base section.
|
||||
; Tree merge: per-path 3-way over flattened trees with blob-level textual
|
||||
; auto-merge; delete/modify keeps the surviving side and flags the path.
|
||||
; Commit merge: up-to-date / fast-forward detection, else merge via merge-base
|
||||
; (empty base for unrelated histories).
|
||||
; Requires: lib/git/object.sx, dag.sx, worktree.sx, diff.sx.
|
||||
|
||||
(define
|
||||
git/m3-concat
|
||||
(fn (ls) (reduce (fn (acc l) (append acc l)) (list) ls)))
|
||||
|
||||
; ---- regions: non-eq runs as {:s :e :repl} over base indices ----
|
||||
(define
|
||||
git/m3-regions
|
||||
(fn
|
||||
(script)
|
||||
(let
|
||||
((st (reduce (fn (acc o) (let ((op (get o :op))) (cond ((equal? op "eq") (if (get acc :open) {:repl (list) :s 0 :open false :out (append (get acc :out) (list {:repl (get acc :repl) :e (get acc :bi) :s (get acc :s)})) :bi (+ (get acc :bi) 1)} (assoc acc :bi (+ (get acc :bi) 1)))) ((equal? op "del") (if (get acc :open) (assoc acc :bi (+ (get acc :bi) 1)) (merge acc {:s (get acc :bi) :open true :bi (+ (get acc :bi) 1)}))) (else (if (get acc :open) (assoc acc :repl (append (get acc :repl) (list (get o :line)))) (merge acc {:repl (list (get o :line)) :s (get acc :bi) :open true})))))) {:repl (list) :s 0 :open false :out (list) :bi 0} script)))
|
||||
(if
|
||||
(get st :open)
|
||||
(append (get st :out) (list {:repl (get st :repl) :e (get st :bi) :s (get st :s)}))
|
||||
(get st :out)))))
|
||||
|
||||
; ---- stable sort by :s (insert after equals: a-side stays before b-side) ----
|
||||
(define
|
||||
git/m3-ins
|
||||
(fn
|
||||
(sorted r)
|
||||
(cond
|
||||
((empty? sorted) (list r))
|
||||
((< (get r :s) (get (first sorted) :s)) (cons r sorted))
|
||||
(else (cons (first sorted) (git/m3-ins (rest sorted) r))))))
|
||||
|
||||
(define
|
||||
git/m3-sort
|
||||
(fn (rs) (reduce (fn (acc r) (git/m3-ins acc r)) (list) rs)))
|
||||
|
||||
; ---- clustering ----
|
||||
(define
|
||||
git/m3-has-insert-at?
|
||||
(fn
|
||||
(ms p)
|
||||
(reduce
|
||||
(fn (acc m) (or acc (and (= (get m :s) p) (= (get m :e) p))))
|
||||
false
|
||||
ms)))
|
||||
|
||||
(define
|
||||
git/m3-cluster-overlap?
|
||||
(fn
|
||||
(cur r)
|
||||
(or
|
||||
(< (get r :s) (get cur :ce))
|
||||
(and
|
||||
(= (get r :s) (get r :e))
|
||||
(= (get r :s) (get cur :ce))
|
||||
(git/m3-has-insert-at? (get cur :members) (get r :s))))))
|
||||
|
||||
(define
|
||||
git/m3-clusters
|
||||
(fn
|
||||
(regions)
|
||||
(let
|
||||
((res (reduce (fn (acc r) (let ((cur (nth acc 1))) (if (equal? cur nil) (list (first acc) {:ce (get r :e) :members (list r) :cs (get r :s)}) (if (git/m3-cluster-overlap? cur r) (list (first acc) {:ce (max (get cur :ce) (get r :e)) :members (append (get cur :members) (list r)) :cs (get cur :cs)}) (list (append (first acc) (list cur)) {:ce (get r :e) :members (list r) :cs (get r :s)}))))) (list (list) nil) regions)))
|
||||
(if
|
||||
(equal? (nth res 1) nil)
|
||||
(first res)
|
||||
(append (first res) (list (nth res 1)))))))
|
||||
|
||||
; ---- apply one side's regions across a base span ----
|
||||
(define
|
||||
git/m3-bslice
|
||||
(fn
|
||||
(bv from to)
|
||||
(if
|
||||
(>= from to)
|
||||
(list)
|
||||
(cons (git/dget bv from) (git/m3-bslice bv (+ from 1) to)))))
|
||||
|
||||
(define
|
||||
git/m3-apply-side
|
||||
(fn
|
||||
(bv members side cs ce)
|
||||
(let
|
||||
((res (reduce (fn (acc m) (list (get m :e) (append (nth acc 1) (append (git/m3-bslice bv (first acc) (get m :s)) (get m :repl))))) (list cs (list)) (filter (fn (m) (equal? (get m :side) side)) members))))
|
||||
(append (nth res 1) (git/m3-bslice bv (first res) ce)))))
|
||||
|
||||
; ---- evaluate a cluster: clean lines or a conflict block ----
|
||||
(define
|
||||
git/m3-eval-cluster
|
||||
(fn
|
||||
(bv cl)
|
||||
(let
|
||||
((ms (get cl :members)))
|
||||
(let
|
||||
((has-a (reduce (fn (acc m) (or acc (equal? (get m :side) "a"))) false ms))
|
||||
(has-b
|
||||
(reduce
|
||||
(fn (acc m) (or acc (equal? (get m :side) "b")))
|
||||
false
|
||||
ms))
|
||||
(aout (git/m3-apply-side bv ms "a" (get cl :cs) (get cl :ce)))
|
||||
(bout (git/m3-apply-side bv ms "b" (get cl :cs) (get cl :ce))))
|
||||
(cond
|
||||
((and has-a has-b (not (= aout bout))) {:conflict true :lines (git/m3-concat (list (list "<<<<<<< ours") aout (list "||||||| base") (git/m3-bslice bv (get cl :cs) (get cl :ce)) (list "=======") bout (list ">>>>>>> theirs")))})
|
||||
(has-a {:conflict false :lines aout})
|
||||
(else {:conflict false :lines bout}))))))
|
||||
|
||||
; ---- diff3 over line lists ----
|
||||
(define
|
||||
git/merge3-lines
|
||||
(fn
|
||||
(base-lines a-lines b-lines)
|
||||
(let
|
||||
((bv (git/dvec base-lines)))
|
||||
(let
|
||||
((all (git/m3-sort (append (map (fn (r) (assoc r :side "a")) (git/m3-regions (git/diff-script-lines base-lines a-lines))) (map (fn (r) (assoc r :side "b")) (git/m3-regions (git/diff-script-lines base-lines b-lines)))))))
|
||||
(let
|
||||
((res (reduce (fn (acc cl) (let ((ev (git/m3-eval-cluster bv cl))) (list (get cl :ce) (append (nth acc 1) (append (git/m3-bslice bv (first acc) (get cl :cs)) (get ev :lines))) (+ (nth acc 2) (if (get ev :conflict) 1 0))))) (list 0 (list) 0) (git/m3-clusters all))))
|
||||
{:clean (= (nth res 2) 0) :conflicts (nth res 2) :lines (append (nth res 1) (git/m3-bslice bv (first res) (len base-lines)))})))))
|
||||
|
||||
(define
|
||||
git/m3-unlines
|
||||
(fn
|
||||
(ls)
|
||||
(if
|
||||
(empty? ls)
|
||||
""
|
||||
(str
|
||||
(reduce (fn (acc l) (str acc "\n" l)) (first ls) (rest ls))
|
||||
"\n"))))
|
||||
|
||||
; textual 3-way over strings: {:clean :conflicts :text}
|
||||
(define
|
||||
git/merge3-text
|
||||
(fn
|
||||
(base ours theirs)
|
||||
(let
|
||||
((r (git/merge3-lines (git/diff-lines base) (git/diff-lines ours) (git/diff-lines theirs))))
|
||||
{:clean (get r :clean) :text (git/m3-unlines (get r :lines)) :conflicts (get r :conflicts)})))
|
||||
|
||||
; ---- tree building from path -> blob cid (no data reread) ----
|
||||
(define
|
||||
git/tree-from-cids
|
||||
(fn
|
||||
(repo files)
|
||||
(let
|
||||
((g (git/wt-group files)))
|
||||
(let
|
||||
((with-blobs (reduce (fn (acc name) (assoc acc name (git/tree-entry "blob" (get (get g :files) name)))) {} (keys (get g :files)))))
|
||||
(let
|
||||
((entries (reduce (fn (acc dir) (assoc acc dir (git/tree-entry "tree" (git/tree-from-cids repo (get (get g :dirs) dir))))) with-blobs (keys (get g :dirs)))))
|
||||
(git/write repo (git/tree entries)))))))
|
||||
|
||||
; ---- per-path 3-way tree merge: {:files path->cid :conflicts (paths)} ----
|
||||
(define
|
||||
git/m3-union-paths
|
||||
(fn (fa fb fc) (artdag/sort-strings (keys (merge (merge fa fb) fc)))))
|
||||
|
||||
(define
|
||||
git/merge-trees
|
||||
(fn
|
||||
(repo base-t a-t b-t)
|
||||
(let
|
||||
((fb (if (equal? base-t nil) {} (git/tree-flatten repo base-t)))
|
||||
(fa (git/tree-flatten repo a-t))
|
||||
(ft (git/tree-flatten repo b-t)))
|
||||
(reduce
|
||||
(fn
|
||||
(acc path)
|
||||
(let
|
||||
((bc (get fb path)) (ac (get fa path)) (tc (get ft path)))
|
||||
(cond
|
||||
((equal? ac tc)
|
||||
(if
|
||||
(equal? ac nil)
|
||||
acc
|
||||
(assoc acc :files (assoc (get acc :files) path ac))))
|
||||
((equal? bc ac)
|
||||
(if
|
||||
(equal? tc nil)
|
||||
acc
|
||||
(assoc acc :files (assoc (get acc :files) path tc))))
|
||||
((equal? bc tc)
|
||||
(if
|
||||
(equal? ac nil)
|
||||
acc
|
||||
(assoc acc :files (assoc (get acc :files) path ac))))
|
||||
((or (equal? ac nil) (equal? tc nil)) (merge acc {:conflicts (append (get acc :conflicts) (list path)) :files (assoc (get acc :files) path (if (equal? ac nil) tc ac))}))
|
||||
(else
|
||||
(let
|
||||
((m (git/merge3-text (if (equal? bc nil) "" (git/blob-data (git/read repo bc))) (git/blob-data (git/read repo ac)) (git/blob-data (git/read repo tc)))))
|
||||
(let
|
||||
((cid (git/write-blob repo (get m :text))))
|
||||
(if
|
||||
(get m :clean)
|
||||
(assoc acc :files (assoc (get acc :files) path cid))
|
||||
(merge acc {:conflicts (append (get acc :conflicts) (list path)) :files (assoc (get acc :files) path cid)}))))))))
|
||||
{:conflicts (list) :files {}}
|
||||
(git/m3-union-paths fb fa ft)))))
|
||||
|
||||
; ---- commit-level merge ----
|
||||
; can merging `theirs` into `ours` fast-forward?
|
||||
(define git/ff? (fn (repo ours theirs) (git/is-ancestor? repo ours theirs)))
|
||||
|
||||
(define
|
||||
git/merge-commits
|
||||
(fn
|
||||
(repo ours theirs)
|
||||
(cond
|
||||
((git/is-ancestor? repo theirs ours) {:cid ours :result "up-to-date"})
|
||||
((git/is-ancestor? repo ours theirs) {:cid theirs :result "fast-forward"})
|
||||
(else
|
||||
(let
|
||||
((mb (git/merge-base repo ours theirs)))
|
||||
(let
|
||||
((mt (git/merge-trees repo (if (equal? mb nil) nil (git/commit-tree (git/read repo mb))) (git/commit-tree (git/read repo ours)) (git/commit-tree (git/read repo theirs)))))
|
||||
(let
|
||||
((tc (git/tree-from-cids repo (get mt :files))))
|
||||
(if (empty? (get mt :conflicts)) {:conflicts (list) :tree tc :result "merged"} {:conflicts (get mt :conflicts) :tree tc :result "conflicts"}))))))))
|
||||
81
lib/git/object.sx
Normal file
81
lib/git/object.sx
Normal file
@@ -0,0 +1,81 @@
|
||||
; lib/git/object.sx — sx-git Phase 1: blob/tree/commit/tag as content-addressed
|
||||
; TYPED objects over the persist kv store. Identity = host digest of the
|
||||
; canonical serialization (artdag/canon: sorted dict keys, escaped strings) —
|
||||
; native CIDs, NOT git wire bytes. Objects are plain dicts: typed, extensible;
|
||||
; unknown fields round-trip and participate in the CID.
|
||||
; Requires: lib/persist/backend.sx, lib/persist/kv.sx, lib/artdag/dag.sx.
|
||||
|
||||
; ---- canonical form + content id ----
|
||||
(define git/canon (fn (obj) (artdag/canon obj)))
|
||||
|
||||
(define
|
||||
git/cid
|
||||
(fn (obj) (str "sx1:" (crypto-sha256 (artdag/canon obj)))))
|
||||
|
||||
; ---- repo handle: a persist backend + key prefix (many repos per db) ----
|
||||
(define git/repo-named (fn (db name) {:prefix name :db db}))
|
||||
(define git/repo (fn (db) (git/repo-named db "git")))
|
||||
(define git/repo-db (fn (repo) (get repo :db)))
|
||||
(define git/obj-key (fn (repo cid) (str (get repo :prefix) "/obj/" cid)))
|
||||
|
||||
; ---- constructors ----
|
||||
(define git/blob (fn (data) {:data data :type "blob"}))
|
||||
|
||||
; entries: dict of name -> entry, entry = {:kind "blob"|"tree" :cid cid ...}
|
||||
(define git/tree (fn (entries) {:type "tree" :entries entries}))
|
||||
(define git/tree-entry (fn (kind cid) {:kind kind :cid cid}))
|
||||
|
||||
; meta: open dict (:author :message :time ... anything); protected keys win
|
||||
(define git/commit (fn (tree parents meta) (merge meta {:type "commit" :tree tree :parents parents})))
|
||||
|
||||
(define git/tag (fn (target name meta) (merge meta {:name name :type "tag" :target target})))
|
||||
|
||||
; ---- predicates / accessors ----
|
||||
(define git/object-type (fn (obj) (get obj :type)))
|
||||
(define
|
||||
git/blob?
|
||||
(fn (obj) (and (dict? obj) (equal? (get obj :type) "blob"))))
|
||||
(define
|
||||
git/tree?
|
||||
(fn (obj) (and (dict? obj) (equal? (get obj :type) "tree"))))
|
||||
(define
|
||||
git/commit?
|
||||
(fn (obj) (and (dict? obj) (equal? (get obj :type) "commit"))))
|
||||
(define
|
||||
git/tag?
|
||||
(fn (obj) (and (dict? obj) (equal? (get obj :type) "tag"))))
|
||||
|
||||
(define git/blob-data (fn (obj) (get obj :data)))
|
||||
(define git/tree-entries (fn (obj) (get obj :entries)))
|
||||
(define git/tree-entry-for (fn (obj name) (get (get obj :entries) name)))
|
||||
(define
|
||||
git/tree-names
|
||||
(fn (obj) (artdag/sort-strings (keys (get obj :entries)))))
|
||||
(define git/entry-cid (fn (entry) (get entry :cid)))
|
||||
(define git/entry-kind (fn (entry) (get entry :kind)))
|
||||
(define git/commit-tree (fn (obj) (get obj :tree)))
|
||||
(define git/commit-parents (fn (obj) (get obj :parents)))
|
||||
(define git/commit-author (fn (obj) (get obj :author)))
|
||||
(define git/commit-message (fn (obj) (get obj :message)))
|
||||
(define git/tag-target (fn (obj) (get obj :target)))
|
||||
(define git/tag-name (fn (obj) (get obj :name)))
|
||||
|
||||
; ---- object store: write/read/has, keyed by cid ----
|
||||
(define
|
||||
git/write
|
||||
(fn
|
||||
(repo obj)
|
||||
(let
|
||||
((cid (git/cid obj)))
|
||||
(begin (persist/kv-put (get repo :db) (git/obj-key repo cid) obj) cid))))
|
||||
|
||||
(define
|
||||
git/read
|
||||
(fn (repo cid) (persist/kv-get (get repo :db) (git/obj-key repo cid))))
|
||||
|
||||
(define
|
||||
git/has?
|
||||
(fn (repo cid) (persist/kv-has? (get repo :db) (git/obj-key repo cid))))
|
||||
|
||||
; convenience: write a blob straight from data
|
||||
(define git/write-blob (fn (repo data) (git/write repo (git/blob data))))
|
||||
241
lib/git/porcelain.sx
Normal file
241
lib/git/porcelain.sx
Normal file
@@ -0,0 +1,241 @@
|
||||
; lib/git/porcelain.sx — sx-git Phase 7: the user-facing verbs.
|
||||
; init/add/commit/branch/checkout/tag/reset/merge/log/diff composed from
|
||||
; object+ref+dag+worktree+diff+merge. Branch advances go through ref CAS.
|
||||
; A conflicted merge parks the other head in <prefix>/MERGE_HEAD (like git's
|
||||
; MERGE_HEAD) and stages the marker tree; resolve with git/add! then
|
||||
; git/merge-commit!, which writes the two-parent commit and clears it.
|
||||
; Requires: all previous lib/git modules.
|
||||
|
||||
(define
|
||||
git/init!
|
||||
(fn
|
||||
(db name)
|
||||
(let
|
||||
((repo (git/repo-named db name)))
|
||||
(begin (git/head-set! repo "main") (git/index-init! repo nil) repo))))
|
||||
|
||||
; advance whatever HEAD points at: the branch via CAS, or the detached pin
|
||||
(define
|
||||
git/porc-advance!
|
||||
(fn
|
||||
(repo old new)
|
||||
(let
|
||||
((target (git/head-target repo)))
|
||||
(if
|
||||
(equal? target nil)
|
||||
(git/head-detach! repo new)
|
||||
(git/ref-cas! repo target old new)))))
|
||||
|
||||
; commit the index; parent = current HEAD (none on an unborn branch)
|
||||
(define
|
||||
git/commit!
|
||||
(fn
|
||||
(repo meta)
|
||||
(let
|
||||
((tree (git/index-tree! repo)))
|
||||
(let
|
||||
((parent (git/head repo)))
|
||||
(let
|
||||
((cid (git/write repo (git/commit tree (if (equal? parent nil) (list) (list parent)) meta))))
|
||||
(begin
|
||||
(git/porc-advance! repo parent cid)
|
||||
(git/index-write! repo {:base tree :staged {}})
|
||||
cid))))))
|
||||
|
||||
; branch at HEAD (create-only)
|
||||
(define
|
||||
git/branch!
|
||||
(fn (repo name) (git/branch-create! repo name (git/head repo))))
|
||||
|
||||
; switch HEAD + index to a branch; returns the materialized files dict
|
||||
(define
|
||||
git/checkout!
|
||||
(fn
|
||||
(repo branch)
|
||||
(let
|
||||
((cid (git/branch-get repo branch)))
|
||||
(begin
|
||||
(git/head-set! repo branch)
|
||||
(git/index-init! repo cid)
|
||||
(if (equal? cid nil) {} (git/commit-files repo cid))))))
|
||||
|
||||
(define
|
||||
git/checkout-detached!
|
||||
(fn
|
||||
(repo cid)
|
||||
(begin
|
||||
(git/head-detach! repo cid)
|
||||
(git/index-init! repo cid)
|
||||
(git/commit-files repo cid))))
|
||||
|
||||
; annotated tag: tag OBJECT at HEAD, ref points at the tag object
|
||||
(define
|
||||
git/tag!
|
||||
(fn
|
||||
(repo name meta)
|
||||
(let
|
||||
((cid (git/write repo (git/tag (git/head repo) name meta))))
|
||||
(begin (git/tag-set! repo name cid) cid))))
|
||||
|
||||
(define
|
||||
git/tag-lightweight!
|
||||
(fn
|
||||
(repo name)
|
||||
(begin (git/tag-set! repo name (git/head repo)) (git/head repo))))
|
||||
|
||||
; reset current branch to cid; "soft" keeps the index, "mixed" resets it
|
||||
(define
|
||||
git/reset!
|
||||
(fn
|
||||
(repo cid mode)
|
||||
(begin
|
||||
(git/porc-advance! repo (git/head repo) cid)
|
||||
(when (equal? mode "mixed") (git/index-init! repo cid))
|
||||
cid)))
|
||||
|
||||
; ---- merge a branch into HEAD ----
|
||||
(define
|
||||
git/merge-head-key
|
||||
(fn (repo) (str (get repo :prefix) "/MERGE_HEAD")))
|
||||
(define
|
||||
git/merge-in-progress?
|
||||
(fn
|
||||
(repo)
|
||||
(not
|
||||
(equal? (persist/kv-get (get repo :db) (git/merge-head-key repo)) nil))))
|
||||
|
||||
(define
|
||||
git/merge!
|
||||
(fn
|
||||
(repo branch meta)
|
||||
(let
|
||||
((ours (git/head repo)) (theirs (git/branch-get repo branch)))
|
||||
(let
|
||||
((m (git/merge-commits repo ours theirs)))
|
||||
(cond
|
||||
((equal? (get m :result) "up-to-date") m)
|
||||
((equal? (get m :result) "fast-forward")
|
||||
(begin
|
||||
(git/porc-advance! repo ours theirs)
|
||||
(git/index-init! repo theirs)
|
||||
m))
|
||||
((equal? (get m :result) "merged")
|
||||
(let
|
||||
((cid (git/write repo (git/commit (get m :tree) (list ours theirs) meta))))
|
||||
(begin
|
||||
(git/porc-advance! repo ours cid)
|
||||
(git/index-write! repo {:base (get m :tree) :staged {}})
|
||||
{:conflicts (list) :cid cid :result "merged"})))
|
||||
(else
|
||||
(begin
|
||||
(persist/kv-put
|
||||
(get repo :db)
|
||||
(git/merge-head-key repo)
|
||||
theirs)
|
||||
(git/index-write! repo {:base (get m :tree) :staged {}})
|
||||
m)))))))
|
||||
|
||||
; conclude a conflicted merge: commit the (resolved) index with two parents
|
||||
(define
|
||||
git/merge-commit!
|
||||
(fn
|
||||
(repo meta)
|
||||
(let
|
||||
((theirs (persist/kv-get (get repo :db) (git/merge-head-key repo))))
|
||||
(if
|
||||
(equal? theirs nil)
|
||||
{:error "no merge in progress"}
|
||||
(let
|
||||
((tree (git/index-tree! repo)))
|
||||
(let
|
||||
((parent (git/head repo)))
|
||||
(let
|
||||
((cid (git/write repo (git/commit tree (list parent theirs) meta))))
|
||||
(begin
|
||||
(git/porc-advance! repo parent cid)
|
||||
(git/index-write! repo {:base tree :staged {}})
|
||||
(persist/kv-delete (get repo :db) (git/merge-head-key repo))
|
||||
cid))))))))
|
||||
|
||||
; ---- porcelain diff/log ----
|
||||
(define
|
||||
git/log-branch
|
||||
(fn (repo branch) (git/log repo (git/branch-get repo branch))))
|
||||
|
||||
; unified diff across two path->data dicts (added, deleted, then modified)
|
||||
(define
|
||||
git/diff-files-unified
|
||||
(fn
|
||||
(fa fb)
|
||||
(let
|
||||
((d (git/files-diff (git/files-cids fa) (git/files-cids fb))))
|
||||
(str
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(str
|
||||
acc
|
||||
"diff --sx a/"
|
||||
p
|
||||
" b/"
|
||||
p
|
||||
"\n--- /dev/null\n+++ b/"
|
||||
p
|
||||
"\n"
|
||||
(git/diff-unified "" (get fb p))))
|
||||
""
|
||||
(get d :added))
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(str
|
||||
acc
|
||||
"diff --sx a/"
|
||||
p
|
||||
" b/"
|
||||
p
|
||||
"\n--- a/"
|
||||
p
|
||||
"\n+++ /dev/null\n"
|
||||
(git/diff-unified (get fa p) "")))
|
||||
""
|
||||
(get d :deleted))
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(str
|
||||
acc
|
||||
"diff --sx a/"
|
||||
p
|
||||
" b/"
|
||||
p
|
||||
"\n--- a/"
|
||||
p
|
||||
"\n+++ b/"
|
||||
p
|
||||
"\n"
|
||||
(git/diff-unified (get fa p) (get fb p))))
|
||||
""
|
||||
(get d :modified))))))
|
||||
|
||||
(define
|
||||
git/porc-head-files
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((h (git/head repo)))
|
||||
(if (equal? h nil) {} (git/commit-files repo h)))))
|
||||
|
||||
; staged: HEAD vs index; unstaged: index vs the caller's worktree value
|
||||
(define
|
||||
git/diff-staged
|
||||
(fn
|
||||
(repo)
|
||||
(git/diff-files-unified
|
||||
(git/porc-head-files repo)
|
||||
(git/index-files repo))))
|
||||
(define
|
||||
git/diff-unstaged
|
||||
(fn
|
||||
(repo worktree-files)
|
||||
(git/diff-files-unified (git/index-files repo) worktree-files)))
|
||||
197
lib/git/ref.sx
Normal file
197
lib/git/ref.sx
Normal file
@@ -0,0 +1,197 @@
|
||||
; lib/git/ref.sx — sx-git Phase 2: refs as name -> cid over persist kv.
|
||||
; A ref value is {:cid cid} (direct) or {:symref name} (symbolic). Atomic
|
||||
; update = persist/kv-cas (old-value expect); create-only = kv-put-new.
|
||||
; Every direct-ref move is recorded in an append-only reflog stream
|
||||
; (persist log facet), one stream per ref name.
|
||||
; Requires: lib/git/object.sx (repo handle), lib/persist/kv.sx, log.sx.
|
||||
|
||||
(define git/ref-key (fn (repo name) (str (get repo :prefix) "/ref/" name)))
|
||||
(define
|
||||
git/reflog-stream
|
||||
(fn (repo name) (str (get repo :prefix) "/reflog/" name)))
|
||||
|
||||
(define
|
||||
git/reflog-record!
|
||||
(fn
|
||||
(repo name old new)
|
||||
(persist/append
|
||||
(get repo :db)
|
||||
(git/reflog-stream repo name)
|
||||
"ref-update"
|
||||
0
|
||||
{:new new :old old})))
|
||||
|
||||
; ---- raw ref values ----
|
||||
(define
|
||||
git/ref-read
|
||||
(fn (repo name) (persist/kv-get (get repo :db) (git/ref-key repo name))))
|
||||
(define git/symref? (fn (v) (and (dict? v) (has-key? v :symref))))
|
||||
|
||||
; direct cid or nil — does NOT follow symrefs
|
||||
(define
|
||||
git/ref-get
|
||||
(fn
|
||||
(repo name)
|
||||
(let ((v (git/ref-read repo name))) (if (dict? v) (get v :cid) nil))))
|
||||
|
||||
; ---- writes ----
|
||||
(define
|
||||
git/ref-set!
|
||||
(fn
|
||||
(repo name cid)
|
||||
(let
|
||||
((old (git/ref-get repo name)))
|
||||
(begin
|
||||
(persist/kv-put (get repo :db) (git/ref-key repo name) {:cid cid})
|
||||
(git/reflog-record! repo name old cid)
|
||||
cid))))
|
||||
|
||||
(define
|
||||
git/symref-set!
|
||||
(fn
|
||||
(repo name target)
|
||||
(begin
|
||||
(persist/kv-put (get repo :db) (git/ref-key repo name) {:symref target})
|
||||
target)))
|
||||
|
||||
(define
|
||||
git/ref-delete!
|
||||
(fn
|
||||
(repo name)
|
||||
(persist/kv-delete (get repo :db) (git/ref-key repo name))))
|
||||
|
||||
; ---- resolution (symref chains, bounded) ----
|
||||
(define
|
||||
git/ref-resolve-n
|
||||
(fn
|
||||
(repo name depth)
|
||||
(if
|
||||
(<= depth 0)
|
||||
nil
|
||||
(let
|
||||
((v (git/ref-read repo name)))
|
||||
(cond
|
||||
((git/symref? v)
|
||||
(git/ref-resolve-n repo (get v :symref) (- depth 1)))
|
||||
((dict? v) (get v :cid))
|
||||
(else nil))))))
|
||||
|
||||
(define
|
||||
git/ref-resolve
|
||||
(fn (repo name) (git/ref-resolve-n repo name 10)))
|
||||
|
||||
; ---- atomic update: expected old cid (nil = must not exist) ----
|
||||
(define
|
||||
git/ref-cas!
|
||||
(fn
|
||||
(repo name expected new)
|
||||
(let
|
||||
((res (persist/kv-cas (get repo :db) (git/ref-key repo name) (if (equal? expected nil) nil {:cid expected}) {:cid new})))
|
||||
(if
|
||||
(and (dict? res) (has-key? res :conflict))
|
||||
{:actual (if (dict? (get res :actual)) (get (get res :actual) :cid) nil) :conflict true}
|
||||
(begin (git/reflog-record! repo name expected new) new)))))
|
||||
|
||||
; ---- listing ----
|
||||
(define
|
||||
git/ref-names
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((pfx (str (get repo :prefix) "/ref/")))
|
||||
(artdag/sort-strings
|
||||
(map
|
||||
(fn (k) (substring k (string-length pfx) (string-length k)))
|
||||
(filter
|
||||
(fn (k) (starts-with? k pfx))
|
||||
(persist/kv-keys (get repo :db))))))))
|
||||
|
||||
(define
|
||||
git/refs-under
|
||||
(fn
|
||||
(repo group)
|
||||
(map
|
||||
(fn (n) (substring n (string-length group) (string-length n)))
|
||||
(filter (fn (n) (starts-with? n group)) (git/ref-names repo)))))
|
||||
|
||||
; ---- branches ----
|
||||
(define git/branch-ref (fn (name) (str "heads/" name)))
|
||||
|
||||
(define
|
||||
git/branch-set!
|
||||
(fn (repo name cid) (git/ref-set! repo (git/branch-ref name) cid)))
|
||||
(define
|
||||
git/branch-get
|
||||
(fn (repo name) (git/ref-get repo (git/branch-ref name))))
|
||||
(define
|
||||
git/branch-cas!
|
||||
(fn
|
||||
(repo name expected new)
|
||||
(git/ref-cas! repo (git/branch-ref name) expected new)))
|
||||
(define
|
||||
git/branch-delete!
|
||||
(fn (repo name) (git/ref-delete! repo (git/branch-ref name))))
|
||||
(define git/branches (fn (repo) (git/refs-under repo "heads/")))
|
||||
|
||||
; create-only: conflict if the branch already exists
|
||||
(define
|
||||
git/branch-create!
|
||||
(fn
|
||||
(repo name cid)
|
||||
(let
|
||||
((res (persist/kv-put-new (get repo :db) (git/ref-key repo (git/branch-ref name)) {:cid cid})))
|
||||
(if
|
||||
(and (dict? res) (has-key? res :conflict))
|
||||
{:actual (get (get res :actual) :cid) :conflict true}
|
||||
(begin (git/reflog-record! repo (git/branch-ref name) nil cid) cid)))))
|
||||
|
||||
; ---- lightweight tag refs (annotated tag objects live in the object store) ----
|
||||
(define git/tag-refname (fn (name) (str "tags/" name)))
|
||||
(define
|
||||
git/tag-set!
|
||||
(fn (repo name cid) (git/ref-set! repo (git/tag-refname name) cid)))
|
||||
(define
|
||||
git/tag-get
|
||||
(fn (repo name) (git/ref-get repo (git/tag-refname name))))
|
||||
(define git/tag-names (fn (repo) (git/refs-under repo "tags/")))
|
||||
|
||||
; ---- HEAD ----
|
||||
(define
|
||||
git/head-set!
|
||||
(fn (repo branch) (git/symref-set! repo "HEAD" (git/branch-ref branch))))
|
||||
|
||||
(define
|
||||
git/head-detach!
|
||||
(fn
|
||||
(repo cid)
|
||||
(begin
|
||||
(persist/kv-put (get repo :db) (git/ref-key repo "HEAD") {:cid cid})
|
||||
cid)))
|
||||
|
||||
(define git/head (fn (repo) (git/ref-resolve repo "HEAD")))
|
||||
|
||||
; branch ref name HEAD points at, or nil when detached/unset
|
||||
(define
|
||||
git/head-target
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((v (git/ref-read repo "HEAD")))
|
||||
(if (git/symref? v) (get v :symref) nil))))
|
||||
|
||||
(define
|
||||
git/detached?
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((v (git/ref-read repo "HEAD")))
|
||||
(and (dict? v) (has-key? v :cid)))))
|
||||
|
||||
; ---- reflog: oldest-first list of {:old :new} ----
|
||||
(define
|
||||
git/reflog
|
||||
(fn
|
||||
(repo name)
|
||||
(map
|
||||
(fn (e) (persist/event-data e))
|
||||
(persist/read (get repo :db) (git/reflog-stream repo name)))))
|
||||
16
lib/git/scoreboard.json
Normal file
16
lib/git/scoreboard.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"suites": {
|
||||
"object": {"pass": 38, "fail": 0},
|
||||
"ref": {"pass": 38, "fail": 0},
|
||||
"dag": {"pass": 30, "fail": 0},
|
||||
"worktree": {"pass": 26, "fail": 0},
|
||||
"diff": {"pass": 27, "fail": 0},
|
||||
"merge": {"pass": 28, "fail": 0},
|
||||
"porcelain": {"pass": 40, "fail": 0},
|
||||
"export": {"pass": 25, "fail": 0},
|
||||
"import": {"pass": 15, "fail": 0}
|
||||
},
|
||||
"total_pass": 267,
|
||||
"total_fail": 0,
|
||||
"total": 267
|
||||
}
|
||||
16
lib/git/scoreboard.md
Normal file
16
lib/git/scoreboard.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# sx-git Conformance Scoreboard
|
||||
|
||||
_Generated by `lib/git/conformance.sh`_
|
||||
|
||||
| Suite | Pass | Fail | Total |
|
||||
|-------|-----:|-----:|------:|
|
||||
| object | 38 | 0 | 38 |
|
||||
| ref | 38 | 0 | 38 |
|
||||
| dag | 30 | 0 | 30 |
|
||||
| worktree | 26 | 0 | 26 |
|
||||
| diff | 27 | 0 | 27 |
|
||||
| merge | 28 | 0 | 28 |
|
||||
| porcelain | 40 | 0 | 40 |
|
||||
| export | 25 | 0 | 25 |
|
||||
| import | 15 | 0 | 15 |
|
||||
| **Total** | **267** | **0** | **267** |
|
||||
299
lib/git/sha1.sx
Normal file
299
lib/git/sha1.sx
Normal file
@@ -0,0 +1,299 @@
|
||||
; lib/git/sha1.sx — SHA-1 in pure SX (host bitwise prims, no deps).
|
||||
; Exists ONLY for the git-wire export adapter: native sx-git identity stays
|
||||
; sx1:/sha256 (object.sx); SHA-1 is what the exported byte format demands.
|
||||
; Strings are treated as byte strings (char-code on 1-byte substrings).
|
||||
|
||||
(define git/sha1-mask 4294967295)
|
||||
(define git/u32 (fn (x) (bitwise-and x git/sha1-mask)))
|
||||
|
||||
(define
|
||||
git/rotl
|
||||
(fn
|
||||
(x n)
|
||||
(git/u32
|
||||
(bitwise-or
|
||||
(arithmetic-shift x n)
|
||||
(arithmetic-shift x (- n 32))))))
|
||||
|
||||
; ---- byte plumbing ----
|
||||
(define
|
||||
git/sha1-take
|
||||
(fn
|
||||
(xs n)
|
||||
(if
|
||||
(or (= n 0) (empty? xs))
|
||||
(list)
|
||||
(cons (first xs) (git/sha1-take (rest xs) (- n 1))))))
|
||||
|
||||
(define
|
||||
git/sha1-drop
|
||||
(fn
|
||||
(xs n)
|
||||
(if
|
||||
(or (= n 0) (empty? xs))
|
||||
xs
|
||||
(git/sha1-drop (rest xs) (- n 1)))))
|
||||
|
||||
(define
|
||||
git/sha1-sb
|
||||
(fn
|
||||
(s i n acc)
|
||||
(if
|
||||
(>= i n)
|
||||
(reverse acc)
|
||||
(git/sha1-sb
|
||||
s
|
||||
(+ i 1)
|
||||
n
|
||||
(cons (char-code (substring s i (+ i 1))) acc)))))
|
||||
|
||||
(define
|
||||
git/sha1-str-bytes
|
||||
(fn (s) (git/sha1-sb s 0 (string-length s) (list))))
|
||||
|
||||
(define
|
||||
git/sha1-zeros
|
||||
(fn
|
||||
(k)
|
||||
(if
|
||||
(= k 0)
|
||||
(list)
|
||||
(cons 0 (git/sha1-zeros (- k 1))))))
|
||||
|
||||
(define
|
||||
git/sha1-be8
|
||||
(fn
|
||||
(v)
|
||||
(map
|
||||
(fn
|
||||
(sh)
|
||||
(bitwise-and (arithmetic-shift v (- 0 sh)) 255))
|
||||
(list
|
||||
56
|
||||
48
|
||||
40
|
||||
32
|
||||
24
|
||||
16
|
||||
8
|
||||
0))))
|
||||
|
||||
; append 0x80, zero-pad to 56 mod 64, then the 64-bit big-endian bit length
|
||||
(define
|
||||
git/sha1-pad
|
||||
(fn
|
||||
(bytes)
|
||||
(let
|
||||
((n (len bytes)))
|
||||
(let
|
||||
((zeros (remainder (+ (- 56 (remainder (+ n 1) 64)) 64) 64)))
|
||||
(append
|
||||
bytes
|
||||
(append
|
||||
(cons 128 (git/sha1-zeros zeros))
|
||||
(git/sha1-be8 (* n 8))))))))
|
||||
|
||||
; ---- message schedule: w as an index-keyed dict ----
|
||||
(define
|
||||
git/sha1-w-init-go
|
||||
(fn
|
||||
(bs j w)
|
||||
(if
|
||||
(= j 16)
|
||||
w
|
||||
(git/sha1-w-init-go
|
||||
(git/sha1-drop bs 4)
|
||||
(+ j 1)
|
||||
(assoc
|
||||
w
|
||||
(str j)
|
||||
(bitwise-or
|
||||
(arithmetic-shift (nth bs 0) 24)
|
||||
(bitwise-or
|
||||
(arithmetic-shift (nth bs 1) 16)
|
||||
(bitwise-or
|
||||
(arithmetic-shift (nth bs 2) 8)
|
||||
(nth bs 3)))))))))
|
||||
|
||||
(define
|
||||
git/sha1-w-expand
|
||||
(fn
|
||||
(w t)
|
||||
(if
|
||||
(= t 80)
|
||||
w
|
||||
(git/sha1-w-expand
|
||||
(assoc
|
||||
w
|
||||
(str t)
|
||||
(git/rotl
|
||||
(bitwise-xor
|
||||
(bitwise-xor
|
||||
(get w (str (- t 3)))
|
||||
(get w (str (- t 8))))
|
||||
(bitwise-xor
|
||||
(get w (str (- t 14)))
|
||||
(get w (str (- t 16)))))
|
||||
1))
|
||||
(+ t 1)))))
|
||||
|
||||
; ---- rounds ----
|
||||
(define
|
||||
git/sha1-f
|
||||
(fn
|
||||
(t b c d)
|
||||
(cond
|
||||
((< t 20)
|
||||
(bitwise-or
|
||||
(bitwise-and b c)
|
||||
(bitwise-and (bitwise-and (bitwise-not b) git/sha1-mask) d)))
|
||||
((< t 40) (bitwise-xor (bitwise-xor b c) d))
|
||||
((< t 60)
|
||||
(bitwise-or
|
||||
(bitwise-or (bitwise-and b c) (bitwise-and b d))
|
||||
(bitwise-and c d)))
|
||||
(else (bitwise-xor (bitwise-xor b c) d)))))
|
||||
|
||||
(define
|
||||
git/sha1-k
|
||||
(fn
|
||||
(t)
|
||||
(cond
|
||||
((< t 20) 1518500249)
|
||||
((< t 40) 1859775393)
|
||||
((< t 60) 2400959708)
|
||||
(else 3395469782))))
|
||||
|
||||
(define
|
||||
git/sha1-rounds
|
||||
(fn
|
||||
(w t a b c d e)
|
||||
(if
|
||||
(= t 80)
|
||||
(list a b c d e)
|
||||
(git/sha1-rounds
|
||||
w
|
||||
(+ t 1)
|
||||
(git/u32
|
||||
(+
|
||||
(+
|
||||
(+ (+ (git/rotl a 5) (git/sha1-f t b c d)) e)
|
||||
(git/sha1-k t))
|
||||
(get w (str t))))
|
||||
a
|
||||
(git/rotl b 30)
|
||||
c
|
||||
d))))
|
||||
|
||||
(define
|
||||
git/sha1-blocks
|
||||
(fn
|
||||
(bs hs)
|
||||
(if
|
||||
(empty? bs)
|
||||
hs
|
||||
(let
|
||||
((w (git/sha1-w-expand (git/sha1-w-init-go (git/sha1-take bs 64) 0 {}) 16)))
|
||||
(let
|
||||
((r (git/sha1-rounds w 0 (nth hs 0) (nth hs 1) (nth hs 2) (nth hs 3) (nth hs 4))))
|
||||
(git/sha1-blocks
|
||||
(git/sha1-drop bs 64)
|
||||
(list
|
||||
(git/u32 (+ (nth hs 0) (nth r 0)))
|
||||
(git/u32 (+ (nth hs 1) (nth r 1)))
|
||||
(git/u32 (+ (nth hs 2) (nth r 2)))
|
||||
(git/u32 (+ (nth hs 3) (nth r 3)))
|
||||
(git/u32 (+ (nth hs 4) (nth r 4))))))))))
|
||||
|
||||
(define
|
||||
git/sha1-words
|
||||
(fn
|
||||
(s)
|
||||
(git/sha1-blocks
|
||||
(git/sha1-pad (git/sha1-str-bytes s))
|
||||
(list 1732584193 4023233417 2562383102 271733878 3285377520))))
|
||||
|
||||
; ---- digest forms ----
|
||||
(define
|
||||
git/sha1-word-bytes
|
||||
(fn
|
||||
(v)
|
||||
(list
|
||||
(bitwise-and (arithmetic-shift v -24) 255)
|
||||
(bitwise-and (arithmetic-shift v -16) 255)
|
||||
(bitwise-and (arithmetic-shift v -8) 255)
|
||||
(bitwise-and v 255))))
|
||||
|
||||
(define
|
||||
git/sha1-digest-bytes
|
||||
(fn
|
||||
(s)
|
||||
(reduce
|
||||
(fn (acc v) (append acc (git/sha1-word-bytes v)))
|
||||
(list)
|
||||
(git/sha1-words s))))
|
||||
|
||||
(define git/sha1-hexd "0123456789abcdef")
|
||||
(define
|
||||
git/sha1-byte-hex
|
||||
(fn
|
||||
(b)
|
||||
(str
|
||||
(substring
|
||||
git/sha1-hexd
|
||||
(quotient b 16)
|
||||
(+ (quotient b 16) 1))
|
||||
(substring
|
||||
git/sha1-hexd
|
||||
(remainder b 16)
|
||||
(+ (remainder b 16) 1)))))
|
||||
|
||||
(define
|
||||
git/sha1-hex
|
||||
(fn
|
||||
(s)
|
||||
(reduce
|
||||
(fn (acc b) (str acc (git/sha1-byte-hex b)))
|
||||
""
|
||||
(git/sha1-digest-bytes s))))
|
||||
|
||||
(define
|
||||
git/sha1-raw
|
||||
(fn
|
||||
(s)
|
||||
(list->string
|
||||
(map (fn (b) (integer->char b)) (git/sha1-digest-bytes s)))))
|
||||
|
||||
; hex string -> raw bytes (tree entries embed 20 raw sha bytes)
|
||||
(define
|
||||
git/hex-digit-val
|
||||
(fn
|
||||
(c)
|
||||
(let
|
||||
((v (char-code c)))
|
||||
(if (< v 58) (- v 48) (- v 87)))))
|
||||
|
||||
(define
|
||||
git/hex->raw-go
|
||||
(fn
|
||||
(h i n acc)
|
||||
(if
|
||||
(>= i n)
|
||||
(list->string (reverse acc))
|
||||
(git/hex->raw-go
|
||||
h
|
||||
(+ i 2)
|
||||
n
|
||||
(cons
|
||||
(integer->char
|
||||
(+
|
||||
(*
|
||||
16
|
||||
(git/hex-digit-val (substring h i (+ i 1))))
|
||||
(git/hex-digit-val
|
||||
(substring h (+ i 1) (+ i 2)))))
|
||||
acc)))))
|
||||
|
||||
(define
|
||||
git/hex->raw
|
||||
(fn (h) (git/hex->raw-go h 0 (string-length h) (list))))
|
||||
172
lib/git/tests/dag.sx
Normal file
172
lib/git/tests/dag.sx
Normal file
@@ -0,0 +1,172 @@
|
||||
; Phase 3 — dag: log (topo), ancestry, is-ancestor?, reachability, merge-base,
|
||||
; and the minimal Datalog ancestor-closure bridge.
|
||||
; Fixtures: a linear chain, a diamond merge, a criss-cross (two LCAs),
|
||||
; and two unrelated roots.
|
||||
|
||||
(define gdt-db (persist/mem-backend))
|
||||
(define gdt (git/repo gdt-db))
|
||||
|
||||
(define
|
||||
gdt-commit!
|
||||
(fn
|
||||
(msg parents)
|
||||
(git/write gdt (git/commit (git/write-blob gdt msg) parents {:message msg}))))
|
||||
|
||||
; linear chain c1 <- c2 <- c3
|
||||
(define gdt-c1 (gdt-commit! "c1" (list)))
|
||||
(define gdt-c2 (gdt-commit! "c2" (list gdt-c1)))
|
||||
(define gdt-c3 (gdt-commit! "c3" (list gdt-c2)))
|
||||
|
||||
; diamond: b1 <- p, b1 <- q, m = merge(p q)
|
||||
(define gdt-b1 (gdt-commit! "b1" (list)))
|
||||
(define gdt-p (gdt-commit! "p" (list gdt-b1)))
|
||||
(define gdt-q (gdt-commit! "q" (list gdt-b1)))
|
||||
(define gdt-m (gdt-commit! "m" (list gdt-p gdt-q)))
|
||||
|
||||
; criss-cross: base <- a, base <- b; x = merge(a b), y = merge(a b) via
|
||||
; distinct messages so x != y
|
||||
(define gdt-base (gdt-commit! "base" (list)))
|
||||
(define gdt-a (gdt-commit! "a" (list gdt-base)))
|
||||
(define gdt-b (gdt-commit! "b" (list gdt-base)))
|
||||
(define gdt-x (gdt-commit! "x" (list gdt-a gdt-b)))
|
||||
(define gdt-y (gdt-commit! "y" (list gdt-a gdt-b)))
|
||||
|
||||
; unrelated root
|
||||
(define gdt-lone (gdt-commit! "lone" (list)))
|
||||
|
||||
; ---- parents ----
|
||||
(git-test
|
||||
"parents of a root commit"
|
||||
(= (git/parents gdt gdt-c1) (list))
|
||||
true)
|
||||
(git-test
|
||||
"parents of a merge commit"
|
||||
(= (git/parents gdt gdt-m) (list gdt-p gdt-q))
|
||||
true)
|
||||
(git-test
|
||||
"parents of a non-commit cid"
|
||||
(= (git/parents gdt "sx1:junk") (list))
|
||||
true)
|
||||
|
||||
; ---- reachability ----
|
||||
(git-test
|
||||
"reachable includes self"
|
||||
(git/dag-member? gdt-c3 (git/reachable gdt gdt-c3))
|
||||
true)
|
||||
(git-test
|
||||
"reachable spans the chain"
|
||||
(= (git/reachable gdt gdt-c3) (list gdt-c1 gdt-c2 gdt-c3))
|
||||
true)
|
||||
(git-test
|
||||
"reachable covers both diamond legs"
|
||||
(len (git/reachable gdt gdt-m))
|
||||
4)
|
||||
(git-test
|
||||
"reachable-all merges from several heads without duplicates"
|
||||
(len (git/reachable-all gdt (list gdt-x gdt-y)))
|
||||
5)
|
||||
|
||||
; ---- log: children before parents ----
|
||||
(git-test
|
||||
"log on a chain is newest-first"
|
||||
(= (git/log gdt gdt-c3) (list gdt-c3 gdt-c2 gdt-c1))
|
||||
true)
|
||||
(git-test
|
||||
"log messages read newest-first"
|
||||
(= (git/log-messages gdt gdt-c3) (list "c3" "c2" "c1"))
|
||||
true)
|
||||
(git-test
|
||||
"log of a diamond is a valid topo order"
|
||||
(= (git/log gdt gdt-m) (list gdt-m gdt-q gdt-p gdt-b1))
|
||||
true)
|
||||
(git-test
|
||||
"log of a root is just the root"
|
||||
(= (git/log gdt gdt-c1) (list gdt-c1))
|
||||
true)
|
||||
|
||||
; ---- ancestors / is-ancestor? ----
|
||||
(git-test
|
||||
"ancestors excludes self"
|
||||
(git/dag-member? gdt-c3 (git/ancestors gdt gdt-c3))
|
||||
false)
|
||||
(git-test
|
||||
"ancestors of the chain tip"
|
||||
(= (git/ancestors gdt gdt-c3) (list gdt-c1 gdt-c2))
|
||||
true)
|
||||
(git-test
|
||||
"ancestors of a merge include both legs and the base"
|
||||
(len (git/ancestors gdt gdt-m))
|
||||
3)
|
||||
(git-test
|
||||
"is-ancestor? along the chain"
|
||||
(git/is-ancestor? gdt gdt-c1 gdt-c3)
|
||||
true)
|
||||
(git-test
|
||||
"is-ancestor? is directed"
|
||||
(git/is-ancestor? gdt gdt-c3 gdt-c1)
|
||||
false)
|
||||
(git-test
|
||||
"is-ancestor? is reflexive"
|
||||
(git/is-ancestor? gdt gdt-c2 gdt-c2)
|
||||
true)
|
||||
(git-test
|
||||
"is-ancestor? false across unrelated history"
|
||||
(git/is-ancestor? gdt gdt-c1 gdt-lone)
|
||||
false)
|
||||
|
||||
; ---- merge-base ----
|
||||
(git-test
|
||||
"merge-base of the diamond legs is the base"
|
||||
(git/merge-base gdt gdt-p gdt-q)
|
||||
gdt-b1)
|
||||
(git-test
|
||||
"merge-base when one side is an ancestor is that side"
|
||||
(git/merge-base gdt gdt-c1 gdt-c3)
|
||||
gdt-c1)
|
||||
(git-test "merge-base with self" (git/merge-base gdt gdt-c3 gdt-c3) gdt-c3)
|
||||
(git-test
|
||||
"merge-base of unrelated commits is nil"
|
||||
(git/merge-base gdt gdt-c3 gdt-lone)
|
||||
nil)
|
||||
(git-test
|
||||
"merge-base is symmetric"
|
||||
(equal? (git/merge-base gdt gdt-p gdt-q) (git/merge-base gdt gdt-q gdt-p))
|
||||
true)
|
||||
(git-test
|
||||
"criss-cross yields BOTH best common ancestors"
|
||||
(=
|
||||
(git/merge-bases gdt gdt-x gdt-y)
|
||||
(artdag/sort-strings (list gdt-a gdt-b)))
|
||||
true)
|
||||
(git-test
|
||||
"dominated common ancestor is not a merge-base"
|
||||
(git/dag-member? gdt-base (git/merge-bases gdt gdt-x gdt-y))
|
||||
false)
|
||||
|
||||
; ---- Datalog bridge ----
|
||||
(git-test
|
||||
"dag-facts exports one fact per parent edge"
|
||||
(len (git/dag-facts gdt (list gdt-m)))
|
||||
4)
|
||||
(git-test
|
||||
"datalog ancestors match the SX-side walk"
|
||||
(let
|
||||
((db (git/dag-db gdt (list gdt-m))))
|
||||
(=
|
||||
(git/ancestors-dl db gdt-m)
|
||||
(artdag/sort-strings (git/ancestors gdt gdt-m))))
|
||||
true)
|
||||
(git-test
|
||||
"datalog is-ancestor? positive"
|
||||
(git/is-ancestor-dl? (git/dag-db gdt (list gdt-m)) gdt-b1 gdt-m)
|
||||
true)
|
||||
(git-test
|
||||
"datalog is-ancestor? negative"
|
||||
(git/is-ancestor-dl? (git/dag-db gdt (list gdt-m)) gdt-m gdt-b1)
|
||||
false)
|
||||
(git-test
|
||||
"datalog closure spans the chain"
|
||||
(=
|
||||
(git/ancestors-dl (git/dag-db gdt (list gdt-c3)) gdt-c3)
|
||||
(artdag/sort-strings (list gdt-c1 gdt-c2)))
|
||||
true)
|
||||
164
lib/git/tests/diff.sx
Normal file
164
lib/git/tests/diff.sx
Normal file
@@ -0,0 +1,164 @@
|
||||
; Phase 5 — diff: Myers line diff (edit script + reconstruction invariants),
|
||||
; unified hunk rendering, structural tree/commit diff.
|
||||
|
||||
(define gdf-db (persist/mem-backend))
|
||||
(define gdf (git/repo gdf-db))
|
||||
|
||||
; ---- diff-lines ----
|
||||
(git-test
|
||||
"lines drop the trailing newline slot"
|
||||
(= (git/diff-lines "a\nb\n") (list "a" "b"))
|
||||
true)
|
||||
(git-test
|
||||
"lines without trailing newline"
|
||||
(= (git/diff-lines "a\nb") (list "a" "b"))
|
||||
true)
|
||||
(git-test "empty data has no lines" (= (git/diff-lines "") (list)) true)
|
||||
|
||||
; ---- Myers edit script ----
|
||||
(git-test
|
||||
"identical inputs are all-eq"
|
||||
(git/diff-changes (git/diff-script "a\nb\nc\n" "a\nb\nc\n"))
|
||||
0)
|
||||
(git-test
|
||||
"identical inputs keep every line"
|
||||
(len (git/diff-script "a\nb\nc\n" "a\nb\nc\n"))
|
||||
3)
|
||||
(git-test
|
||||
"empty vs empty is the empty script"
|
||||
(= (git/diff-script "" "") (list))
|
||||
true)
|
||||
(git-test
|
||||
"single line replacement"
|
||||
(= (git/diff-script "a" "b") (list {:op "del" :line "a"} {:op "add" :line "b"}))
|
||||
true)
|
||||
(git-test
|
||||
"pure insertion script"
|
||||
(= (git/diff-script "" "a\nb\n") (list {:op "add" :line "a"} {:op "add" :line "b"}))
|
||||
true)
|
||||
(git-test
|
||||
"pure deletion script"
|
||||
(= (git/diff-script "a\nb\n" "") (list {:op "del" :line "a"} {:op "del" :line "b"}))
|
||||
true)
|
||||
(git-test
|
||||
"middle change keeps flanks eq"
|
||||
(=
|
||||
(git/diff-script "a\nb\nc\n" "a\nx\nc\n")
|
||||
(list {:op "eq" :line "a"} {:op "del" :line "b"} {:op "add" :line "x"} {:op "eq" :line "c"}))
|
||||
true)
|
||||
|
||||
; Myers' paper example: ABCABBA -> CBABAC has a shortest edit script of 5
|
||||
(git-test
|
||||
"ABCABBA/CBABAC shortest edit distance is 5"
|
||||
(git/diff-changes (git/diff-script "A\nB\nC\nA\nB\nB\nA" "C\nB\nA\nB\nA\nC"))
|
||||
5)
|
||||
(git-test
|
||||
"script reconstructs the old side"
|
||||
(=
|
||||
(git/diff-old-lines (git/diff-script "A\nB\nC\nA\nB\nB\nA" "C\nB\nA\nB\nA\nC"))
|
||||
(list "A" "B" "C" "A" "B" "B" "A"))
|
||||
true)
|
||||
(git-test
|
||||
"script reconstructs the new side"
|
||||
(=
|
||||
(git/diff-new-lines (git/diff-script "A\nB\nC\nA\nB\nB\nA" "C\nB\nA\nB\nA\nC"))
|
||||
(list "C" "B" "A" "B" "A" "C"))
|
||||
true)
|
||||
(git-test
|
||||
"reconstruction holds for asymmetric edits"
|
||||
(let
|
||||
((a "one\ntwo\nthree\nfour\n") (b "zero\ntwo\nfour\nfive\nsix\n"))
|
||||
(and
|
||||
(= (git/diff-old-lines (git/diff-script a b)) (git/diff-lines a))
|
||||
(= (git/diff-new-lines (git/diff-script a b)) (git/diff-lines b))))
|
||||
true)
|
||||
|
||||
; ---- unified rendering ----
|
||||
(git-test
|
||||
"unified: middle replacement, full context"
|
||||
(git/diff-unified "a\nb\nc\n" "a\nx\nc\n")
|
||||
"@@ -1,3 +1,3 @@\n a\n-b\n+x\n c\n")
|
||||
(git-test
|
||||
"unified: append at end"
|
||||
(git/diff-unified "a\n" "a\nb\n")
|
||||
"@@ -1,1 +1,2 @@\n a\n+b\n")
|
||||
(git-test "unified: identical renders empty" (git/diff-unified "x\n" "x\n") "")
|
||||
(git-test
|
||||
"unified: creation from empty"
|
||||
(git/diff-unified "" "a\nb\n")
|
||||
"@@ -0,0 +1,2 @@\n+a\n+b\n")
|
||||
(git-test
|
||||
"unified: deletion to empty"
|
||||
(git/diff-unified "a\nb\n" "")
|
||||
"@@ -1,2 +0,0 @@\n-a\n-b\n")
|
||||
(git-test
|
||||
"unified: context trimmed to 3 lines"
|
||||
(git/diff-unified "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\n" "l1\nl2\nl3\nl4\nX\nl6\nl7\nl8\nl9\n")
|
||||
"@@ -2,7 +2,7 @@\n l2\n l3\n l4\n-l5\n+X\n l6\n l7\n l8\n")
|
||||
(git-test
|
||||
"unified: distant changes split into two hunks"
|
||||
(git/diff-unified
|
||||
"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\nl15\n"
|
||||
"l1\nX\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nY\nl15\n")
|
||||
(str
|
||||
"@@ -1,5 +1,5 @@\n l1\n-l2\n+X\n l3\n l4\n l5\n"
|
||||
"@@ -11,5 +11,5 @@\n l11\n l12\n l13\n-l14\n+Y\n l15\n"))
|
||||
|
||||
; ---- blob diff over the object store ----
|
||||
(git-test
|
||||
"blob-diff reads both blobs"
|
||||
(=
|
||||
(git/blob-diff gdf (git/write-blob gdf "a\n") (git/write-blob gdf "b\n"))
|
||||
(list {:op "del" :line "a"} {:op "add" :line "b"}))
|
||||
true)
|
||||
|
||||
; ---- structural tree/commit diff ----
|
||||
(define
|
||||
gdf-t1
|
||||
(git/tree-from-files
|
||||
gdf
|
||||
(assoc
|
||||
(assoc (assoc {} "a.txt" "1\n") "b.txt" "2\n")
|
||||
"sub/c.txt"
|
||||
"3\n")))
|
||||
(define
|
||||
gdf-t2
|
||||
(git/tree-from-files
|
||||
gdf
|
||||
(assoc
|
||||
(assoc (assoc {} "a.txt" "1\n") "b.txt" "2x\n")
|
||||
"d.txt"
|
||||
"new\n")))
|
||||
(define gdf-c1 (git/write gdf (git/commit gdf-t1 (list) {:message "c1"})))
|
||||
(define gdf-c2 (git/write gdf (git/commit gdf-t2 (list gdf-c1) {:message "c2"})))
|
||||
|
||||
(git-test
|
||||
"tree-diff classifies added/modified/deleted"
|
||||
(= (git/tree-diff gdf gdf-t1 gdf-t2) {:deleted (list "sub/c.txt") :modified (list "b.txt") :added (list "d.txt")})
|
||||
true)
|
||||
(git-test
|
||||
"tree-diff of a tree with itself is empty"
|
||||
(= (git/tree-diff gdf gdf-t1 gdf-t1) {:deleted (list) :modified (list) :added (list)})
|
||||
true)
|
||||
(git-test
|
||||
"commit-diff goes through the commit trees"
|
||||
(= (git/commit-diff gdf gdf-c1 gdf-c2) {:deleted (list "sub/c.txt") :modified (list "b.txt") :added (list "d.txt")})
|
||||
true)
|
||||
|
||||
; ---- whole-commit unified render ----
|
||||
(git-test
|
||||
"commit-diff-unified renders adds, deletes, then modifications"
|
||||
(let
|
||||
((r (git/repo (persist/mem-backend))))
|
||||
(let
|
||||
((c1 (git/write r (git/commit (git/tree-from-files r (assoc {} "f.txt" "old\n")) (list) {:message "c1"}))))
|
||||
(let
|
||||
((c2 (git/write r (git/commit (git/tree-from-files r (assoc (assoc {} "f.txt" "new\n") "g.txt" "hi\n")) (list c1) {:message "c2"}))))
|
||||
(git/commit-diff-unified r c1 c2))))
|
||||
(str
|
||||
"diff --sx a/g.txt b/g.txt\n--- /dev/null\n+++ b/g.txt\n@@ -0,0 +1,1 @@\n+hi\n"
|
||||
"diff --sx a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"))
|
||||
(git-test
|
||||
"commit-diff-unified of identical commits is empty"
|
||||
(git/commit-diff-unified gdf gdf-c1 gdf-c1)
|
||||
"")
|
||||
202
lib/git/tests/export.sx
Normal file
202
lib/git/tests/export.sx
Normal file
@@ -0,0 +1,202 @@
|
||||
; Extension — git-wire export adapter. Golden values generated with real git
|
||||
; (hash-object/mktree/commit-tree with pinned idents) — the adapter must
|
||||
; reproduce byte-exact payloads and SHA-1s.
|
||||
|
||||
; ---- SHA-1 vectors ----
|
||||
(git-test
|
||||
"sha1 of empty"
|
||||
(git/sha1-hex "")
|
||||
"da39a3ee5e6b4b0d3255bfef95601890afd80709")
|
||||
(git-test
|
||||
"sha1 of abc"
|
||||
(git/sha1-hex "abc")
|
||||
"a9993e364706816aba3e25717850c26c9cd0d89d")
|
||||
(git-test
|
||||
"sha1 crossing the padding boundary"
|
||||
(git/sha1-hex "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")
|
||||
"84983e441c3bd26ebaae4aa1f95129e5e54670f1")
|
||||
(define
|
||||
gex-rep
|
||||
(fn
|
||||
(s n)
|
||||
(if (= n 0) "" (str s (gex-rep s (- n 1))))))
|
||||
(git-test
|
||||
"sha1 over multiple blocks"
|
||||
(git/sha1-hex (gex-rep "a" 200))
|
||||
"e61cfffe0d9195a525fc6cf06ca2d77119c24a40")
|
||||
(git-test
|
||||
"raw digest is 20 bytes"
|
||||
(= (string-length (git/sha1-raw "abc")) 20)
|
||||
true)
|
||||
(git-test
|
||||
"hex->raw round-trips through byte codes"
|
||||
(char-code (substring (git/hex->raw "ff00a1") 1 2))
|
||||
0)
|
||||
|
||||
; ---- fixture (mirrors the golden git repo exactly) ----
|
||||
(define gex-db (persist/mem-backend))
|
||||
(define gex (git/repo gex-db))
|
||||
(define gex-hello (git/write-blob gex "hello\n"))
|
||||
(define gex-sub (git/write-blob gex "sub\n"))
|
||||
(define gex-hello2 (git/write-blob gex "hello2\n"))
|
||||
(define gex-subtxt (git/write-blob gex "not a dir\n"))
|
||||
(define
|
||||
gex-tsub
|
||||
(git/write
|
||||
gex
|
||||
(git/tree (assoc {} "c.txt" (git/tree-entry "blob" gex-sub)))))
|
||||
(define
|
||||
gex-t1
|
||||
(git/write
|
||||
gex
|
||||
(git/tree
|
||||
(assoc
|
||||
(assoc {} "a.txt" (git/tree-entry "blob" gex-hello))
|
||||
"sub"
|
||||
(git/tree-entry "tree" gex-tsub)))))
|
||||
(define
|
||||
gex-t2
|
||||
(git/write
|
||||
gex
|
||||
(git/tree
|
||||
(assoc
|
||||
(assoc {} "a.txt" (git/tree-entry "blob" gex-hello2))
|
||||
"sub"
|
||||
(git/tree-entry "tree" gex-tsub)))))
|
||||
(define
|
||||
gex-t3
|
||||
(git/write
|
||||
gex
|
||||
(git/tree
|
||||
(assoc
|
||||
(assoc
|
||||
(assoc {} "a.txt" (git/tree-entry "blob" gex-hello))
|
||||
"sub"
|
||||
(git/tree-entry "tree" gex-tsub))
|
||||
"sub.txt"
|
||||
(git/tree-entry "blob" gex-subtxt)))))
|
||||
(define
|
||||
gex-t4
|
||||
(git/write
|
||||
gex
|
||||
(git/tree
|
||||
(assoc
|
||||
{}
|
||||
"x.sh"
|
||||
(merge (git/tree-entry "blob" gex-hello) {:mode "100755"})))))
|
||||
(define gex-c1 (git/write gex (git/commit gex-t1 (list) {:message "c1" :tz "+0000" :email "ada@sx" :time 1700000000 :author "ada"})))
|
||||
(define gex-c2 (git/write gex (git/commit gex-t2 (list gex-c1) {:message "c2" :tz "+0000" :email "ada@sx" :time 1700000100 :author "ada"})))
|
||||
(define gex-tag (git/write gex (git/tag gex-c1 "v1" {:message "first" :tz "+0000" :email "ada@sx" :time 1700000200 :author "ada"})))
|
||||
|
||||
; ---- blob payloads ----
|
||||
(git-test
|
||||
"blob export matches git hash-object"
|
||||
(git/export-sha gex gex-hello)
|
||||
"ce013625030ba8dba906f756967f9e9ca394464a")
|
||||
(git-test
|
||||
"empty blob is git's famous e69de29"
|
||||
(git/export-sha gex (git/write-blob gex ""))
|
||||
"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391")
|
||||
(git-test
|
||||
"blob payload bytes are '<type> <len>NUL<data>'"
|
||||
(equal?
|
||||
(get (git/export gex gex-hello) :bytes)
|
||||
(str "blob 6" git/export-nul "hello\n"))
|
||||
true)
|
||||
|
||||
; ---- tree payloads (git sort rules, raw child shas) ----
|
||||
(git-test
|
||||
"leaf tree matches git mktree"
|
||||
(git/export-sha gex gex-tsub)
|
||||
"2282cb13a4b7999406280aac69e4fc45260fb909")
|
||||
(git-test
|
||||
"nested tree matches git mktree"
|
||||
(git/export-sha gex gex-t1)
|
||||
"77918032f1f02d785d3bc222ab29b4969cd83854")
|
||||
(git-test
|
||||
"modified tree matches git mktree"
|
||||
(git/export-sha gex gex-t2)
|
||||
"aaf1022f60da7b14837d52232a75fa3fc2d3e3a7")
|
||||
(git-test
|
||||
"directories sort as 'name/': sub.txt before dir sub"
|
||||
(git/export-sha gex gex-t3)
|
||||
"58c19e599aa988a4ee6fba065f8801c700777a3d")
|
||||
(git-test
|
||||
"entry :mode override (100755) is honored"
|
||||
(git/export-sha gex gex-t4)
|
||||
"54925a269ee97325d7da275bda4250d83b338e65")
|
||||
(git-test
|
||||
"the empty tree is git's 4b825dc"
|
||||
(git/export-sha gex (git/write gex (git/tree {})))
|
||||
"4b825dc642cb6eb9a060e54bf8d69288fbee4904")
|
||||
|
||||
; ---- commit payloads ----
|
||||
(git-test
|
||||
"root commit matches git commit-tree"
|
||||
(git/export-sha gex gex-c1)
|
||||
"baeeb137fc255e62c1e3b980e70b2d8dd2be6e83")
|
||||
(git-test
|
||||
"child commit matches git commit-tree -p"
|
||||
(git/export-sha gex gex-c2)
|
||||
"a8eac1e101bf4f6b7d614a4384592a981a67ce92")
|
||||
(git-test
|
||||
"commit body is byte-exact"
|
||||
(get (git/export gex gex-c1) :bytes)
|
||||
(str
|
||||
"commit 127"
|
||||
git/export-nul
|
||||
"tree 77918032f1f02d785d3bc222ab29b4969cd83854\n"
|
||||
"author ada <ada@sx> 1700000000 +0000\n"
|
||||
"committer ada <ada@sx> 1700000000 +0000\n"
|
||||
"\n"
|
||||
"c1\n"))
|
||||
|
||||
; ---- tag payload ----
|
||||
(git-test
|
||||
"annotated tag matches git hash-object -t tag"
|
||||
(git/export-sha gex gex-tag)
|
||||
"b28ebfd4213f10fd0c2127d1d14a26179fcf12e0")
|
||||
(git-test
|
||||
"tag body names the target type"
|
||||
(contains? (get (git/export gex gex-tag) :bytes) "type commit")
|
||||
true)
|
||||
|
||||
; ---- closure + set ----
|
||||
(git-test
|
||||
"closure of the child commit covers all 8 reachable objects"
|
||||
(len (keys (git/export-closure gex gex-c2)))
|
||||
8)
|
||||
(git-test
|
||||
"closure of the tag reaches through the commit"
|
||||
(len (keys (git/export-closure gex gex-tag)))
|
||||
6)
|
||||
(git-test
|
||||
"export-set is keyed by git sha with the head marked"
|
||||
(let
|
||||
((s (git/export-set gex gex-c2)))
|
||||
(list
|
||||
(get s :head)
|
||||
(get
|
||||
(get (get s :objects) "baeeb137fc255e62c1e3b980e70b2d8dd2be6e83")
|
||||
:type)))
|
||||
(list "a8eac1e101bf4f6b7d614a4384592a981a67ce92" "commit"))
|
||||
|
||||
; ---- defaults + trailing newline rule ----
|
||||
(git-test
|
||||
"idents default deterministically"
|
||||
(contains?
|
||||
(get
|
||||
(git/export gex (git/write gex (git/commit gex-t1 (list) {:message "m\n"})))
|
||||
:bytes)
|
||||
"author sx <sx@sx> 0 +0000")
|
||||
true)
|
||||
(git-test
|
||||
"message gains a trailing newline when missing"
|
||||
(let
|
||||
((e1 (git/export gex (git/write gex (git/commit gex-t1 (list) {:message "m"}))))
|
||||
(e2
|
||||
(git/export
|
||||
gex
|
||||
(git/write gex (git/commit gex-t1 (list) {:message "m\n" :x 1})))))
|
||||
(equal? (get e1 :sha1) (get e2 :sha1)))
|
||||
true)
|
||||
174
lib/git/tests/import.sx
Normal file
174
lib/git/tests/import.sx
Normal file
@@ -0,0 +1,174 @@
|
||||
; Extension — git-wire import (inverse adapter). The law under test:
|
||||
; export-set -> import-set -> export-set is BYTE-IDENTICAL (same head sha,
|
||||
; same object table), and imported blobs/trees with default modes get the
|
||||
; same native cids as the originals.
|
||||
|
||||
(define gim-db (persist/mem-backend))
|
||||
(define gim (git/repo gim-db))
|
||||
(define gim-hello (git/write-blob gim "hello\n"))
|
||||
(define gim-sub (git/write-blob gim "sub\n"))
|
||||
(define
|
||||
gim-tsub
|
||||
(git/write
|
||||
gim
|
||||
(git/tree (assoc {} "c.txt" (git/tree-entry "blob" gim-sub)))))
|
||||
(define
|
||||
gim-t1
|
||||
(git/write
|
||||
gim
|
||||
(git/tree
|
||||
(assoc
|
||||
(assoc {} "a.txt" (git/tree-entry "blob" gim-hello))
|
||||
"sub"
|
||||
(git/tree-entry "tree" gim-tsub)))))
|
||||
(define gim-c1 (git/write gim (git/commit gim-t1 (list) {:message "c1" :tz "+0000" :email "ada@sx" :time 1700000000 :author "ada"})))
|
||||
(define
|
||||
gim-c2
|
||||
(git/write
|
||||
gim
|
||||
(git/commit
|
||||
(git/write
|
||||
gim
|
||||
(git/tree
|
||||
(assoc
|
||||
(assoc
|
||||
{}
|
||||
"a.txt"
|
||||
(git/tree-entry "blob" (git/write-blob gim "hello2\n")))
|
||||
"sub"
|
||||
(git/tree-entry "tree" gim-tsub))))
|
||||
(list gim-c1)
|
||||
{:message "c2" :tz "+0000" :email "ada@sx" :time 1700000100 :author "ada"})))
|
||||
|
||||
; ---- parsing units ----
|
||||
(git-test
|
||||
"payload splits type and body"
|
||||
(git/import-payload (str "blob 6" git/export-nul "hello\n"))
|
||||
{:type "blob" :body "hello\n"})
|
||||
(git-test
|
||||
"ident parses name/email/time/tz"
|
||||
(= (git/import-ident "ada lovelace <ada@sx> 1700000000 +0100") {:name "ada lovelace" :tz "+0100" :email "ada@sx" :time 1700000000})
|
||||
true)
|
||||
(git-test
|
||||
"raw->hex inverts hex->raw"
|
||||
(git/raw->hex (git/hex->raw "ce013625030ba8dba906f756967f9e9ca394464a"))
|
||||
"ce013625030ba8dba906f756967f9e9ca394464a")
|
||||
(git-test
|
||||
"tree body parses mode/name/sha triples"
|
||||
(=
|
||||
(map
|
||||
(fn (e) (get e :name))
|
||||
(git/import-tree-entries
|
||||
(get
|
||||
(git/import-payload (get (git/export gim gim-t1) :bytes))
|
||||
:body)
|
||||
0
|
||||
(string-length
|
||||
(get
|
||||
(git/import-payload (get (git/export gim gim-t1) :bytes))
|
||||
:body))
|
||||
(list)))
|
||||
(list "a.txt" "sub"))
|
||||
true)
|
||||
|
||||
; ---- the round-trip law ----
|
||||
(define gim-set1 (git/export-set gim gim-c2))
|
||||
(define gim-fresh (git/repo-named (persist/mem-backend) "imported"))
|
||||
(define gim-head (git/import-set gim-fresh gim-set1))
|
||||
(define gim-set2 (git/export-set gim-fresh gim-head))
|
||||
|
||||
(git-test
|
||||
"wire round-trip: head sha is identical"
|
||||
(get gim-set2 :head)
|
||||
(get gim-set1 :head))
|
||||
(git-test
|
||||
"wire round-trip: same number of objects"
|
||||
(=
|
||||
(len (keys (get gim-set2 :objects)))
|
||||
(len (keys (get gim-set1 :objects))))
|
||||
true)
|
||||
(git-test
|
||||
"wire round-trip: every sha re-exports byte-identical"
|
||||
(reduce
|
||||
(fn
|
||||
(acc sha)
|
||||
(and
|
||||
acc
|
||||
(equal?
|
||||
(get (get (get gim-set2 :objects) sha) :bytes)
|
||||
(get (get (get gim-set1 :objects) sha) :bytes))))
|
||||
true
|
||||
(keys (get gim-set1 :objects)))
|
||||
true)
|
||||
|
||||
; ---- native identity on the wire-expressible subset ----
|
||||
(git-test
|
||||
"imported blob has the original native cid"
|
||||
(git/has? gim-fresh gim-hello)
|
||||
true)
|
||||
(git-test
|
||||
"imported default-mode tree has the original native cid"
|
||||
(git/has? gim-fresh gim-t1)
|
||||
true)
|
||||
(git-test
|
||||
"imported commit graph walks natively"
|
||||
(= (git/log-messages gim-fresh gim-head) (list "c2\n" "c1\n"))
|
||||
true)
|
||||
(git-test
|
||||
"imported parents map to native cids"
|
||||
(=
|
||||
(git/parents gim-fresh gim-head)
|
||||
(list
|
||||
(git/cid
|
||||
(git/read gim-fresh (first (git/parents gim-fresh gim-head))))))
|
||||
true)
|
||||
|
||||
; ---- mode + tag + committer round-trips ----
|
||||
(git-test
|
||||
"non-default mode survives the round-trip"
|
||||
(let
|
||||
((r (git/repo (persist/mem-backend))))
|
||||
(let
|
||||
((t (git/write r (git/tree (assoc {} "x.sh" (merge (git/tree-entry "blob" (git/write-blob r "hello\n")) {:mode "100755"}))))))
|
||||
(let
|
||||
((s1 (git/export-set r t)))
|
||||
(let
|
||||
((r2 (git/repo-named (persist/mem-backend) "i")))
|
||||
(get (git/export-set r2 (git/import-set r2 s1)) :head)))))
|
||||
"54925a269ee97325d7da275bda4250d83b338e65")
|
||||
(git-test
|
||||
"annotated tag round-trips through the wire"
|
||||
(let
|
||||
((tag (git/write gim (git/tag gim-c1 "v1" {:message "first" :tz "+0000" :email "ada@sx" :time 1700000200 :author "ada"}))))
|
||||
(let
|
||||
((s1 (git/export-set gim tag)))
|
||||
(let
|
||||
((r2 (git/repo-named (persist/mem-backend) "i")))
|
||||
(equal?
|
||||
(get (git/export-set r2 (git/import-set r2 s1)) :head)
|
||||
(get s1 :head)))))
|
||||
true)
|
||||
(git-test
|
||||
"distinct committer survives the round-trip"
|
||||
(let
|
||||
((c (git/write gim (git/commit gim-t1 (list) {:message "handoff" :committer "bob" :committer-tz "+0200" :tz "+0000" :committer-email "bob@sx" :email "ada@sx" :committer-time 1700000300 :time 1700000000 :author "ada"}))))
|
||||
(let
|
||||
((s1 (git/export-set gim c)))
|
||||
(let
|
||||
((r2 (git/repo-named (persist/mem-backend) "i")))
|
||||
(equal?
|
||||
(get (git/export-set r2 (git/import-set r2 s1)) :head)
|
||||
(get s1 :head)))))
|
||||
true)
|
||||
(git-test
|
||||
"multi-line message round-trips byte-exact"
|
||||
(let
|
||||
((c (git/write gim (git/commit gim-t1 (list) {:message "subject\n\nbody line one\nbody line two\n" :tz "+0000" :email "ada@sx" :time 1 :author "ada"}))))
|
||||
(let
|
||||
((s1 (git/export-set gim c)))
|
||||
(let
|
||||
((r2 (git/repo-named (persist/mem-backend) "i")))
|
||||
(equal?
|
||||
(get (git/export-set r2 (git/import-set r2 s1)) :head)
|
||||
(get s1 :head)))))
|
||||
true)
|
||||
235
lib/git/tests/merge.sx
Normal file
235
lib/git/tests/merge.sx
Normal file
@@ -0,0 +1,235 @@
|
||||
; Phase 6 — merge: textual diff3 with conflict markers, per-path 3-way tree
|
||||
; merge with blob auto-merge, fast-forward/up-to-date detection.
|
||||
|
||||
(define gmg-db (persist/mem-backend))
|
||||
(define gmg (git/repo gmg-db))
|
||||
|
||||
(define
|
||||
gmg-commit!
|
||||
(fn
|
||||
(files parents msg)
|
||||
(git/write
|
||||
gmg
|
||||
(git/commit (git/tree-from-files gmg files) parents {:message msg}))))
|
||||
|
||||
; ---- textual 3-way ----
|
||||
(git-test "merge3: untouched" (git/merge3-text "x\n" "x\n" "x\n") {:clean true :text "x\n" :conflicts 0})
|
||||
(git-test
|
||||
"merge3: ours-only change wins"
|
||||
(get (git/merge3-text "a\nb\n" "a\nB\n" "a\nb\n") :text)
|
||||
"a\nB\n")
|
||||
(git-test
|
||||
"merge3: theirs-only change wins"
|
||||
(get (git/merge3-text "a\nb\n" "a\nb\n" "a\nB\n") :text)
|
||||
"a\nB\n")
|
||||
(git-test
|
||||
"merge3: identical change on both sides is clean"
|
||||
(git/merge3-text "a\nb\n" "a\nZ\n" "a\nZ\n")
|
||||
{:clean true :text "a\nZ\n" :conflicts 0})
|
||||
(git-test
|
||||
"merge3: non-overlapping changes both apply"
|
||||
(git/merge3-text "a\nb\nc\n" "A\nb\nc\n" "a\nb\nC\n")
|
||||
{:clean true :text "A\nb\nC\n" :conflicts 0})
|
||||
(git-test
|
||||
"merge3: adjacent line changes merge cleanly"
|
||||
(get (git/merge3-text "l1\nl2\nl3\nl4\n" "l1\nX\nl3\nl4\n" "l1\nl2\nY\nl4\n") :text)
|
||||
"l1\nX\nY\nl4\n")
|
||||
(git-test
|
||||
"merge3: append and prepend both apply"
|
||||
(get (git/merge3-text "m\n" "m\ne\n" "s\nm\n") :text)
|
||||
"s\nm\ne\n")
|
||||
(git-test
|
||||
"merge3: clean deletion"
|
||||
(get (git/merge3-text "a\nb\nc\n" "a\nc\n" "a\nb\nc\n") :text)
|
||||
"a\nc\n")
|
||||
|
||||
(git-test
|
||||
"merge3: overlapping edits conflict with diff3 markers"
|
||||
(git/merge3-text "a\nb\nc\n" "a\nX\nc\n" "a\nY\nc\n")
|
||||
{:clean false :text "a\n<<<<<<< ours\nX\n||||||| base\nb\n=======\nY\n>>>>>>> theirs\nc\n" :conflicts 1})
|
||||
(git-test
|
||||
"merge3: delete vs modify conflicts"
|
||||
(git/merge3-text "a\nb\nc\n" "a\nc\n" "a\nY\nc\n")
|
||||
{:clean false :text "a\n<<<<<<< ours\n||||||| base\nb\n=======\nY\n>>>>>>> theirs\nc\n" :conflicts 1})
|
||||
(git-test
|
||||
"merge3: same-point insertions with different content conflict"
|
||||
(git/merge3-text "" "A\n" "B\n")
|
||||
{:clean false :text "<<<<<<< ours\nA\n||||||| base\n=======\nB\n>>>>>>> theirs\n" :conflicts 1})
|
||||
(git-test
|
||||
"merge3: same-point insertions with the same content are clean"
|
||||
(git/merge3-text "a\n" "a\nx\n" "a\nx\n")
|
||||
{:clean true :text "a\nx\n" :conflicts 0})
|
||||
(git-test
|
||||
"merge3: two separated conflicts are counted"
|
||||
(get
|
||||
(git/merge3-text
|
||||
"a\nb\nc\nd\ne\nf\ng\nh\ni\n"
|
||||
"a\nX1\nc\nd\ne\nf\ng\nX2\ni\n"
|
||||
"a\nY1\nc\nd\ne\nf\ng\nY2\ni\n")
|
||||
:conflicts)
|
||||
2)
|
||||
|
||||
; ---- tree-from-cids ----
|
||||
(git-test
|
||||
"tree-from-cids equals tree-from-files"
|
||||
(equal?
|
||||
(git/tree-from-cids
|
||||
gmg
|
||||
(assoc
|
||||
(assoc {} "a.txt" (git/write-blob gmg "1\n"))
|
||||
"s/b.txt"
|
||||
(git/write-blob gmg "2\n")))
|
||||
(git/tree-from-files
|
||||
gmg
|
||||
(assoc (assoc {} "a.txt" "1\n") "s/b.txt" "2\n")))
|
||||
true)
|
||||
|
||||
; ---- tree merge ----
|
||||
(define
|
||||
gmg-base-files
|
||||
(assoc (assoc {} "f.txt" "a\nb\nc\n") "g.txt" "same\n"))
|
||||
(define gmg-base-tree (git/tree-from-files gmg gmg-base-files))
|
||||
|
||||
(git-test
|
||||
"merge-trees: disjoint paths merge clean"
|
||||
(let
|
||||
((ta (git/tree-from-files gmg (assoc gmg-base-files "ours.txt" "o\n")))
|
||||
(tb (git/tree-from-files gmg (assoc gmg-base-files "theirs.txt" "t\n"))))
|
||||
(let
|
||||
((m (git/merge-trees gmg gmg-base-tree ta tb)))
|
||||
(list
|
||||
(get m :conflicts)
|
||||
(=
|
||||
(git/tree-files gmg (git/tree-from-cids gmg (get m :files)))
|
||||
(assoc (assoc gmg-base-files "ours.txt" "o\n") "theirs.txt" "t\n")))))
|
||||
(list (list) true))
|
||||
(git-test
|
||||
"merge-trees: same file, non-overlapping lines auto-merge"
|
||||
(let
|
||||
((ta (git/tree-from-files gmg (assoc gmg-base-files "f.txt" "A\nb\nc\n")))
|
||||
(tb (git/tree-from-files gmg (assoc gmg-base-files "f.txt" "a\nb\nC\n"))))
|
||||
(let
|
||||
((m (git/merge-trees gmg gmg-base-tree ta tb)))
|
||||
(list
|
||||
(get m :conflicts)
|
||||
(git/blob-data (git/read gmg (get (get m :files) "f.txt"))))))
|
||||
(list (list) "A\nb\nC\n"))
|
||||
(git-test
|
||||
"merge-trees: overlapping edits flag the path"
|
||||
(let
|
||||
((ta (git/tree-from-files gmg (assoc gmg-base-files "f.txt" "a\nX\nc\n")))
|
||||
(tb (git/tree-from-files gmg (assoc gmg-base-files "f.txt" "a\nY\nc\n"))))
|
||||
(let
|
||||
((m (git/merge-trees gmg gmg-base-tree ta tb)))
|
||||
(=
|
||||
(list
|
||||
(get m :conflicts)
|
||||
(contains?
|
||||
(git/blob-data (git/read gmg (get (get m :files) "f.txt")))
|
||||
"<<<<<<< ours"))
|
||||
(list (list "f.txt") true))))
|
||||
true)
|
||||
(git-test
|
||||
"merge-trees: both delete is clean"
|
||||
(let
|
||||
((ta (git/tree-from-files gmg (dissoc gmg-base-files "f.txt")))
|
||||
(tb (git/tree-from-files gmg (dissoc gmg-base-files "f.txt"))))
|
||||
(let
|
||||
((m (git/merge-trees gmg gmg-base-tree ta tb)))
|
||||
(list (get m :conflicts) (has-key? (get m :files) "f.txt"))))
|
||||
(list (list) false))
|
||||
(git-test
|
||||
"merge-trees: delete vs modify keeps the modified side and flags it"
|
||||
(let
|
||||
((ta (git/tree-from-files gmg (dissoc gmg-base-files "f.txt")))
|
||||
(tb (git/tree-from-files gmg (assoc gmg-base-files "f.txt" "a\nY\nc\n"))))
|
||||
(let
|
||||
((m (git/merge-trees gmg gmg-base-tree ta tb)))
|
||||
(=
|
||||
(list
|
||||
(get m :conflicts)
|
||||
(git/blob-data (git/read gmg (get (get m :files) "f.txt"))))
|
||||
(list (list "f.txt") "a\nY\nc\n"))))
|
||||
true)
|
||||
(git-test
|
||||
"merge-trees: both add the same file identically"
|
||||
(let
|
||||
((ta (git/tree-from-files gmg (assoc gmg-base-files "n.txt" "n\n")))
|
||||
(tb (git/tree-from-files gmg (assoc gmg-base-files "n.txt" "n\n"))))
|
||||
(get (git/merge-trees gmg gmg-base-tree ta tb) :conflicts))
|
||||
(list))
|
||||
(git-test
|
||||
"merge-trees: both add the same file differently"
|
||||
(let
|
||||
((ta (git/tree-from-files gmg (assoc gmg-base-files "n.txt" "N1\n")))
|
||||
(tb (git/tree-from-files gmg (assoc gmg-base-files "n.txt" "N2\n"))))
|
||||
(=
|
||||
(get (git/merge-trees gmg gmg-base-tree ta tb) :conflicts)
|
||||
(list "n.txt")))
|
||||
true)
|
||||
|
||||
; ---- commit-level ----
|
||||
(define gmg-c0 (gmg-commit! gmg-base-files (list) "base"))
|
||||
(define
|
||||
gmg-ca
|
||||
(gmg-commit! (assoc gmg-base-files "f.txt" "A\nb\nc\n") (list gmg-c0) "ours"))
|
||||
(define
|
||||
gmg-cb
|
||||
(gmg-commit!
|
||||
(assoc gmg-base-files "f.txt" "a\nb\nC\n")
|
||||
(list gmg-c0)
|
||||
"theirs"))
|
||||
(define
|
||||
gmg-cx
|
||||
(gmg-commit! (assoc gmg-base-files "f.txt" "a\nX\nc\n") (list gmg-c0) "ox"))
|
||||
(define
|
||||
gmg-cy
|
||||
(gmg-commit! (assoc gmg-base-files "f.txt" "a\nY\nc\n") (list gmg-c0) "oy"))
|
||||
|
||||
(git-test "ff? when ours is behind" (git/ff? gmg gmg-c0 gmg-ca) true)
|
||||
(git-test "ff? false for diverged heads" (git/ff? gmg gmg-ca gmg-cb) false)
|
||||
(git-test
|
||||
"merge-commits: up-to-date when theirs is an ancestor"
|
||||
(git/merge-commits gmg gmg-ca gmg-c0)
|
||||
{:cid gmg-ca :result "up-to-date"})
|
||||
(git-test
|
||||
"merge-commits: fast-forward when ours is an ancestor"
|
||||
(git/merge-commits gmg gmg-c0 gmg-ca)
|
||||
{:cid gmg-ca :result "fast-forward"})
|
||||
(git-test
|
||||
"merge-commits: clean 3-way produces the merged tree"
|
||||
(let
|
||||
((m (git/merge-commits gmg gmg-ca gmg-cb)))
|
||||
(list
|
||||
(get m :result)
|
||||
(get m :conflicts)
|
||||
(=
|
||||
(git/tree-files gmg (get m :tree))
|
||||
(assoc gmg-base-files "f.txt" "A\nb\nC\n"))))
|
||||
(list "merged" (list) true))
|
||||
(git-test
|
||||
"merge-commits: conflicting 3-way reports paths and marker tree"
|
||||
(let
|
||||
((m (git/merge-commits gmg gmg-cx gmg-cy)))
|
||||
(=
|
||||
(list
|
||||
(get m :result)
|
||||
(get m :conflicts)
|
||||
(contains?
|
||||
(get (git/tree-files gmg (get m :tree)) "f.txt")
|
||||
">>>>>>> theirs"))
|
||||
(list "conflicts" (list "f.txt") true)))
|
||||
true)
|
||||
(git-test
|
||||
"merge-commits: unrelated histories merge over an empty base"
|
||||
(let
|
||||
((r1 (gmg-commit! (assoc {} "x.txt" "x\n") (list) "rx"))
|
||||
(r2 (gmg-commit! (assoc {} "y.txt" "y\n") (list) "ry")))
|
||||
(let
|
||||
((m (git/merge-commits gmg r1 r2)))
|
||||
(list
|
||||
(get m :result)
|
||||
(=
|
||||
(git/tree-files gmg (get m :tree))
|
||||
(assoc (assoc {} "x.txt" "x\n") "y.txt" "y\n")))))
|
||||
(list "merged" true))
|
||||
241
lib/git/tests/object.sx
Normal file
241
lib/git/tests/object.sx
Normal file
@@ -0,0 +1,241 @@
|
||||
; Phase 1 — object: blob/tree/commit/tag as content-addressed typed objects.
|
||||
; Fixture repo: blobs a/b/c, nested tree (a.txt b.txt sub/c.txt), two commits
|
||||
; (c2 modifies a.txt, parent c1), tag v1 -> c1. Reused as the assertion target.
|
||||
|
||||
(define git-fix-db (persist/mem-backend))
|
||||
(define git-fix (git/repo git-fix-db))
|
||||
|
||||
(define git-fix-blob-a (git/write-blob git-fix "hello\n"))
|
||||
(define git-fix-blob-b (git/write-blob git-fix "world\n"))
|
||||
(define git-fix-blob-c (git/write-blob git-fix "sub\n"))
|
||||
(define git-fix-blob-a2 (git/write-blob git-fix "hello2\n"))
|
||||
|
||||
(define
|
||||
git-fixt-entries3
|
||||
(fn
|
||||
(acid bcid subcid)
|
||||
(assoc
|
||||
(assoc
|
||||
(assoc {} "a.txt" (git/tree-entry "blob" acid))
|
||||
"b.txt"
|
||||
(git/tree-entry "blob" bcid))
|
||||
"sub"
|
||||
(git/tree-entry "tree" subcid))))
|
||||
|
||||
(define
|
||||
git-fix-subtree-cid
|
||||
(git/write
|
||||
git-fix
|
||||
(git/tree
|
||||
(assoc {} "c.txt" (git/tree-entry "blob" git-fix-blob-c)))))
|
||||
|
||||
(define
|
||||
git-fix-tree1-cid
|
||||
(git/write
|
||||
git-fix
|
||||
(git/tree
|
||||
(git-fixt-entries3 git-fix-blob-a git-fix-blob-b git-fix-subtree-cid))))
|
||||
|
||||
(define
|
||||
git-fix-tree2-cid
|
||||
(git/write
|
||||
git-fix
|
||||
(git/tree
|
||||
(git-fixt-entries3 git-fix-blob-a2 git-fix-blob-b git-fix-subtree-cid))))
|
||||
|
||||
(define
|
||||
git-fix-commit1-cid
|
||||
(git/write git-fix (git/commit git-fix-tree1-cid (list) {:message "c1" :time 1 :author "ada"})))
|
||||
|
||||
(define
|
||||
git-fix-commit2-cid
|
||||
(git/write
|
||||
git-fix
|
||||
(git/commit git-fix-tree2-cid (list git-fix-commit1-cid) {:message "c2" :time 2 :author "ada"})))
|
||||
|
||||
(define
|
||||
git-fix-tag-cid
|
||||
(git/write git-fix (git/tag git-fix-commit1-cid "v1" {:message "first" :tagger "ada"})))
|
||||
|
||||
; ---- constructors + types ----
|
||||
(git-test "blob is typed" (git/object-type (git/blob "x")) "blob")
|
||||
(git-test "blob? true on blob" (git/blob? (git/blob "x")) true)
|
||||
(git-test
|
||||
"blob? false on commit"
|
||||
(git/blob? (git/commit "t" (list) {}))
|
||||
false)
|
||||
(git-test "tree? true on tree" (git/tree? (git/tree {})) true)
|
||||
(git-test
|
||||
"commit? true on commit"
|
||||
(git/commit? (git/commit "t" (list) {}))
|
||||
true)
|
||||
(git-test "tag? true on tag" (git/tag? (git/tag "c" "v" {})) true)
|
||||
(git-test "blob-data reads back" (git/blob-data (git/blob "hi")) "hi")
|
||||
|
||||
; ---- cid: deterministic structural identity ----
|
||||
(git-test
|
||||
"cid deterministic"
|
||||
(equal? (git/cid (git/blob "same")) (git/cid (git/blob "same")))
|
||||
true)
|
||||
(git-test
|
||||
"cid differs by content"
|
||||
(equal? (git/cid (git/blob "a")) (git/cid (git/blob "b")))
|
||||
false)
|
||||
(git-test
|
||||
"cid ignores dict insertion order"
|
||||
(equal?
|
||||
(git/cid (assoc (assoc {} :type "blob") :data "x"))
|
||||
(git/cid (assoc (assoc {} :data "x") :type "blob")))
|
||||
true)
|
||||
(git-test
|
||||
"cid carries the native scheme prefix"
|
||||
(starts-with? (git/cid (git/blob "x")) "sx1:")
|
||||
true)
|
||||
|
||||
; ---- write / read / has ----
|
||||
(git-test
|
||||
"write returns the object cid"
|
||||
(equal? git-fix-blob-a (git/cid (git/blob "hello\n")))
|
||||
true)
|
||||
(git-test
|
||||
"read round-trips blob data"
|
||||
(git/blob-data (git/read git-fix git-fix-blob-a))
|
||||
"hello\n")
|
||||
(git-test
|
||||
"read round-trips structurally"
|
||||
(equal? (git/read git-fix git-fix-blob-a) (git/blob "hello\n"))
|
||||
true)
|
||||
(git-test "has? true after write" (git/has? git-fix git-fix-blob-a) true)
|
||||
(git-test "has? false for unknown cid" (git/has? git-fix "sx1:nope") false)
|
||||
(git-test "read unknown cid gives nil" (git/read git-fix "sx1:nope") nil)
|
||||
(git-test
|
||||
"rewrite is idempotent, same cid"
|
||||
(equal? (git/write git-fix (git/blob "hello\n")) git-fix-blob-a)
|
||||
true)
|
||||
|
||||
; ---- structural identity across separately built objects ----
|
||||
(git-test
|
||||
"separately built identical tree shares the cid"
|
||||
(equal?
|
||||
(git/write
|
||||
git-fix
|
||||
(git/tree
|
||||
(git-fixt-entries3 git-fix-blob-a git-fix-blob-b git-fix-subtree-cid)))
|
||||
git-fix-tree1-cid)
|
||||
true)
|
||||
(git-test
|
||||
"changed entry changes the tree cid"
|
||||
(equal? git-fix-tree1-cid git-fix-tree2-cid)
|
||||
false)
|
||||
|
||||
; ---- tree accessors ----
|
||||
(git-test
|
||||
"tree entry lookup by name"
|
||||
(git/entry-cid
|
||||
(git/tree-entry-for (git/read git-fix git-fix-tree1-cid) "a.txt"))
|
||||
git-fix-blob-a)
|
||||
(git-test
|
||||
"tree entry kind"
|
||||
(git/entry-kind
|
||||
(git/tree-entry-for (git/read git-fix git-fix-tree1-cid) "sub"))
|
||||
"tree")
|
||||
(git-test
|
||||
"tree-names sorted"
|
||||
(=
|
||||
(git/tree-names (git/read git-fix git-fix-tree1-cid))
|
||||
(list "a.txt" "b.txt" "sub"))
|
||||
true)
|
||||
|
||||
; ---- commit accessors ----
|
||||
(git-test
|
||||
"commit tree cid"
|
||||
(git/commit-tree (git/read git-fix git-fix-commit1-cid))
|
||||
git-fix-tree1-cid)
|
||||
(git-test
|
||||
"root commit has no parents"
|
||||
(git/commit-parents (git/read git-fix git-fix-commit1-cid))
|
||||
(list))
|
||||
(git-test
|
||||
"child commit records its parent"
|
||||
(git/commit-parents (git/read git-fix git-fix-commit2-cid))
|
||||
(list git-fix-commit1-cid))
|
||||
(git-test
|
||||
"commit author round-trips"
|
||||
(git/commit-author (git/read git-fix git-fix-commit1-cid))
|
||||
"ada")
|
||||
(git-test
|
||||
"commit message round-trips"
|
||||
(git/commit-message (git/read git-fix git-fix-commit2-cid))
|
||||
"c2")
|
||||
(git-test
|
||||
"commit cids differ across history"
|
||||
(equal? git-fix-commit1-cid git-fix-commit2-cid)
|
||||
false)
|
||||
|
||||
; ---- typed extensibility (the reason for native CID) ----
|
||||
(git-test
|
||||
"extra commit field round-trips"
|
||||
(get
|
||||
(git/read
|
||||
git-fix
|
||||
(git/write git-fix (git/commit "t" (list) {:message "m" :co-authored-by "claude"})))
|
||||
:co-authored-by)
|
||||
"claude")
|
||||
(git-test
|
||||
"extra field changes the cid"
|
||||
(equal?
|
||||
(git/cid (git/commit "t" (list) {:m 1}))
|
||||
(git/cid (git/commit "t" (list) {})))
|
||||
false)
|
||||
(git-test
|
||||
"protected keys win over meta"
|
||||
(git/commit-tree (git/commit "t" (list) {:tree "evil"}))
|
||||
"t")
|
||||
|
||||
; ---- tag ----
|
||||
(git-test
|
||||
"tag target"
|
||||
(git/tag-target (git/read git-fix git-fix-tag-cid))
|
||||
git-fix-commit1-cid)
|
||||
(git-test "tag name" (git/tag-name (git/read git-fix git-fix-tag-cid)) "v1")
|
||||
(git-test
|
||||
"tag? on read-back"
|
||||
(git/tag? (git/read git-fix git-fix-tag-cid))
|
||||
true)
|
||||
|
||||
; ---- full walk: commit -> tree -> subtree -> blob ----
|
||||
(git-test
|
||||
"walk commit to nested blob"
|
||||
(git/blob-data
|
||||
(git/read
|
||||
git-fix
|
||||
(git/entry-cid
|
||||
(git/tree-entry-for
|
||||
(git/read
|
||||
git-fix
|
||||
(git/entry-cid
|
||||
(git/tree-entry-for
|
||||
(git/read
|
||||
git-fix
|
||||
(git/commit-tree (git/read git-fix git-fix-commit1-cid)))
|
||||
"sub")))
|
||||
"c.txt"))))
|
||||
"sub\n")
|
||||
|
||||
; ---- repos are namespaced within one backend ----
|
||||
(git-test
|
||||
"objects are invisible across repo namespaces"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(let
|
||||
((ra (git/repo-named db "a")) (rb (git/repo-named db "b")))
|
||||
(let ((cid (git/write-blob ra "x"))) (git/has? rb cid))))
|
||||
false)
|
||||
(git-test
|
||||
"same content, same cid in any repo"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(equal?
|
||||
(git/write-blob (git/repo-named db "a") "x")
|
||||
(git/write-blob (git/repo-named db "b") "x")))
|
||||
true)
|
||||
261
lib/git/tests/porcelain.sx
Normal file
261
lib/git/tests/porcelain.sx
Normal file
@@ -0,0 +1,261 @@
|
||||
; Phase 7 — porcelain: init/add/commit/branch/checkout/tag/reset/merge/log/diff
|
||||
; as one end-to-end topology story, plus the conflicted-merge flow.
|
||||
|
||||
(define gpc-db (persist/mem-backend))
|
||||
(define gpc (git/init! gpc-db "proj"))
|
||||
|
||||
; ---- init ----
|
||||
(git-test
|
||||
"init points HEAD at an unborn main"
|
||||
(git/head-target gpc)
|
||||
"heads/main")
|
||||
(git-test "init has no commits yet" (git/head gpc) nil)
|
||||
(git-test
|
||||
"init status is clean and empty"
|
||||
(= (git/status gpc {}) {:untracked (list) :staged {:deleted (list) :modified (list) :added (list)} :unstaged {:deleted (list) :modified (list)}})
|
||||
true)
|
||||
|
||||
; ---- first commit on main ----
|
||||
(git-test
|
||||
"staged files show before the first commit"
|
||||
(begin
|
||||
(git/add! gpc "README.md" "hello\n")
|
||||
(git/add! gpc "src/main.sx" "(main)\n")
|
||||
(=
|
||||
(get (get (git/status gpc {}) :staged) :added)
|
||||
(list "README.md" "src/main.sx")))
|
||||
true)
|
||||
|
||||
(define gpc-c1 (git/commit! gpc {:message "c1" :author "ada"}))
|
||||
|
||||
(git-test "commit! advances HEAD through main" (git/head gpc) gpc-c1)
|
||||
(git-test "commit! moved the branch ref" (git/branch-get gpc "main") gpc-c1)
|
||||
(git-test
|
||||
"the commit carries the author"
|
||||
(git/commit-author (git/read gpc gpc-c1))
|
||||
"ada")
|
||||
(git-test
|
||||
"first commit has no parents"
|
||||
(= (git/commit-parents (git/read gpc gpc-c1)) (list))
|
||||
true)
|
||||
(git-test
|
||||
"commit materializes the staged files"
|
||||
(=
|
||||
(git/commit-files gpc gpc-c1)
|
||||
(assoc (assoc {} "README.md" "hello\n") "src/main.sx" "(main)\n"))
|
||||
true)
|
||||
(git-test "after commit! the index is clean" (git/diff-staged gpc) "")
|
||||
(git-test
|
||||
"extensible meta flows through porcelain"
|
||||
(let
|
||||
((r (git/init! (persist/mem-backend) "x")))
|
||||
(begin
|
||||
(git/add! r "f" "1\n")
|
||||
(get (git/read r (git/commit! r {:message "m" :agent "claude-fable"})) :agent)))
|
||||
"claude-fable")
|
||||
|
||||
; ---- branch + checkout ----
|
||||
(git-test "branch! forks at HEAD" (git/branch! gpc "feature") gpc-c1)
|
||||
(git-test
|
||||
"checkout! returns the materialized worktree"
|
||||
(= (git/checkout! gpc "feature") (git/commit-files gpc gpc-c1))
|
||||
true)
|
||||
(git-test "checkout! retargets HEAD" (git/head-target gpc) "heads/feature")
|
||||
|
||||
(define
|
||||
gpc-c2
|
||||
(begin
|
||||
(git/add! gpc "src/feature.sx" "(feature)\n")
|
||||
(git/commit! gpc {:message "c2" :author "ada"})))
|
||||
|
||||
(git-test
|
||||
"feature moved, main did not"
|
||||
(=
|
||||
(list (git/branch-get gpc "feature") (git/branch-get gpc "main"))
|
||||
(list gpc-c2 gpc-c1))
|
||||
true)
|
||||
(git-test
|
||||
"checkout back to main drops the feature file"
|
||||
(has-key? (git/checkout! gpc "main") "src/feature.sx")
|
||||
false)
|
||||
|
||||
(define
|
||||
gpc-c3
|
||||
(begin
|
||||
(git/add! gpc "README.md" "hello\nworld\n")
|
||||
(git/commit! gpc {:message "c3" :author "ada"})))
|
||||
|
||||
; ---- real merge ----
|
||||
(define gpc-m (git/merge! gpc "feature" {:message "merge feature" :author "ada"}))
|
||||
|
||||
(git-test
|
||||
"diverged branches produce a merge commit"
|
||||
(get gpc-m :result)
|
||||
"merged")
|
||||
(git-test
|
||||
"merge commit has both parents in order"
|
||||
(=
|
||||
(git/commit-parents (git/read gpc (get gpc-m :cid)))
|
||||
(list gpc-c3 gpc-c2))
|
||||
true)
|
||||
(git-test
|
||||
"merge result is where HEAD is now"
|
||||
(git/head gpc)
|
||||
(get gpc-m :cid))
|
||||
(git-test
|
||||
"merged tree unions both changes"
|
||||
(=
|
||||
(git/commit-files gpc (git/head gpc))
|
||||
(assoc
|
||||
(assoc
|
||||
(assoc {} "README.md" "hello\nworld\n")
|
||||
"src/main.sx"
|
||||
"(main)\n")
|
||||
"src/feature.sx"
|
||||
"(feature)\n"))
|
||||
true)
|
||||
(git-test "no merge left in progress" (git/merge-in-progress? gpc) false)
|
||||
(git-test
|
||||
"log walks the merged topology newest-first"
|
||||
(=
|
||||
(git/log-messages gpc (git/head gpc))
|
||||
(list "merge feature" "c2" "c3" "c1"))
|
||||
true)
|
||||
(git-test
|
||||
"merge-base of the two legs is the fork point"
|
||||
(git/merge-base gpc gpc-c2 gpc-c3)
|
||||
gpc-c1)
|
||||
|
||||
; ---- fast-forward + up-to-date ----
|
||||
(define gpc-c4 (get gpc-m :cid))
|
||||
(define
|
||||
gpc-c5
|
||||
(begin
|
||||
(git/branch! gpc "hotfix")
|
||||
(git/checkout! gpc "hotfix")
|
||||
(git/add! gpc "fix.txt" "patched\n")
|
||||
(git/commit! gpc {:message "c5" :author "ada"})))
|
||||
|
||||
(git-test
|
||||
"merging a descendant fast-forwards"
|
||||
(begin
|
||||
(git/checkout! gpc "main")
|
||||
(get (git/merge! gpc "hotfix" {:message "unused"}) :result))
|
||||
"fast-forward")
|
||||
(git-test
|
||||
"fast-forward moved main to the hotfix tip"
|
||||
(git/branch-get gpc "main")
|
||||
gpc-c5)
|
||||
(git-test
|
||||
"merging it again is up-to-date"
|
||||
(get (git/merge! gpc "hotfix" {:message "unused"}) :result)
|
||||
"up-to-date")
|
||||
|
||||
; ---- tags ----
|
||||
(git-test
|
||||
"tag! writes an annotated tag object at HEAD"
|
||||
(begin
|
||||
(git/tag! gpc "v1" {:tagger "ada"})
|
||||
(git/tag-target (git/read gpc (git/tag-get gpc "v1"))))
|
||||
gpc-c5)
|
||||
(git-test
|
||||
"lightweight tag points straight at the commit"
|
||||
(begin (git/tag-lightweight! gpc "tip") (git/tag-get gpc "tip"))
|
||||
gpc-c5)
|
||||
|
||||
; ---- reset ----
|
||||
(git-test
|
||||
"soft reset moves the branch but keeps the index"
|
||||
(begin
|
||||
(git/reset! gpc gpc-c4 "soft")
|
||||
(list (git/head gpc) (contains? (git/diff-staged gpc) "+patched")))
|
||||
(list gpc-c4 true))
|
||||
(git-test
|
||||
"mixed reset also resets the index"
|
||||
(begin
|
||||
(git/reset! gpc gpc-c5 "mixed")
|
||||
(list (git/head gpc) (git/diff-staged gpc)))
|
||||
(list gpc-c5 ""))
|
||||
|
||||
; ---- detached HEAD ----
|
||||
(git-test
|
||||
"detached checkout pins the cid"
|
||||
(begin
|
||||
(git/checkout-detached! gpc gpc-c1)
|
||||
(list (git/detached? gpc) (git/head gpc)))
|
||||
(list true gpc-c1))
|
||||
(git-test
|
||||
"committing while detached moves only the pin"
|
||||
(let
|
||||
((c6 (begin (git/add! gpc "scratch.txt" "s\n") (git/commit! gpc {:message "c6"}))))
|
||||
(list (git/head gpc) (git/branch-get gpc "main")))
|
||||
(list (git/head gpc) gpc-c5))
|
||||
(git-test
|
||||
"checkout! reattaches to a branch"
|
||||
(begin (git/checkout! gpc "main") (git/head-target gpc))
|
||||
"heads/main")
|
||||
|
||||
; ---- unstaged diff ----
|
||||
(git-test
|
||||
"diff-unstaged renders worktree edits"
|
||||
(contains?
|
||||
(git/diff-unstaged
|
||||
gpc
|
||||
(assoc (git/commit-files gpc (git/head gpc)) "fix.txt" "patched\nedit\n"))
|
||||
"+edit")
|
||||
true)
|
||||
|
||||
; ---- conflicted merge flow ----
|
||||
(define gcf (git/init! (persist/mem-backend) "cf"))
|
||||
(define
|
||||
gcf-c1
|
||||
(begin (git/add! gcf "f.txt" "a\nb\nc\n") (git/commit! gcf {:message "base"})))
|
||||
(define
|
||||
gcf-c2
|
||||
(begin
|
||||
(git/branch! gcf "left")
|
||||
(git/add! gcf "f.txt" "a\nX\nc\n")
|
||||
(git/commit! gcf {:message "ours"})))
|
||||
(define
|
||||
gcf-c3
|
||||
(begin
|
||||
(git/checkout! gcf "left")
|
||||
(git/add! gcf "f.txt" "a\nY\nc\n")
|
||||
(git/commit! gcf {:message "theirs"})))
|
||||
(define
|
||||
gcf-m
|
||||
(begin (git/checkout! gcf "main") (git/merge! gcf "left" {:message "m"})))
|
||||
|
||||
(git-test
|
||||
"conflicting merge reports the paths"
|
||||
(=
|
||||
(list (get gcf-m :result) (get gcf-m :conflicts))
|
||||
(list "conflicts" (list "f.txt")))
|
||||
true)
|
||||
(git-test
|
||||
"a conflicted merge is in progress"
|
||||
(git/merge-in-progress? gcf)
|
||||
true)
|
||||
(git-test
|
||||
"the conflicted file is staged with markers"
|
||||
(contains? (get (git/index-files gcf) "f.txt") "<<<<<<< ours")
|
||||
true)
|
||||
(git-test "HEAD did not move on conflict" (git/head gcf) gcf-c2)
|
||||
(git-test
|
||||
"merge-commit! concludes with two parents"
|
||||
(begin
|
||||
(git/add! gcf "f.txt" "a\nXY\nc\n")
|
||||
(let
|
||||
((mc (git/merge-commit! gcf {:message "resolved"})))
|
||||
(=
|
||||
(list
|
||||
(git/head gcf)
|
||||
(git/commit-parents (git/read gcf mc))
|
||||
(get (git/commit-files gcf mc) "f.txt")
|
||||
(git/merge-in-progress? gcf))
|
||||
(list mc (list gcf-c2 gcf-c3) "a\nXY\nc\n" false))))
|
||||
true)
|
||||
(git-test
|
||||
"merge-commit! without a merge in progress errors"
|
||||
(get (git/merge-commit! gcf {:message "x"}) :error)
|
||||
"no merge in progress")
|
||||
208
lib/git/tests/ref.sx
Normal file
208
lib/git/tests/ref.sx
Normal file
@@ -0,0 +1,208 @@
|
||||
; Phase 2 — ref: branches/tags/HEAD as name -> cid over persist kv, symbolic
|
||||
; refs, CAS updates with old-value expect, reflog.
|
||||
|
||||
(define grt-db (persist/mem-backend))
|
||||
(define grt (git/repo grt-db))
|
||||
(define grt-c1 (git/write-blob grt "one"))
|
||||
(define grt-c2 (git/write-blob grt "two"))
|
||||
(define grt-c3 (git/write-blob grt "three"))
|
||||
|
||||
; ---- direct refs ----
|
||||
(git-test "ref-get on unset ref is nil" (git/ref-get grt "heads/none") nil)
|
||||
(git-test
|
||||
"ref-set! returns the cid"
|
||||
(git/ref-set! grt "heads/main" grt-c1)
|
||||
grt-c1)
|
||||
(git-test "ref-get reads it back" (git/ref-get grt "heads/main") grt-c1)
|
||||
(git-test
|
||||
"ref-resolve on a direct ref"
|
||||
(git/ref-resolve grt "heads/main")
|
||||
grt-c1)
|
||||
(git-test
|
||||
"ref-resolve on missing ref is nil"
|
||||
(git/ref-resolve grt "nope")
|
||||
nil)
|
||||
|
||||
; ---- symbolic refs ----
|
||||
(git-test
|
||||
"symref resolves through its target"
|
||||
(begin
|
||||
(git/symref-set! grt "HEAD" "heads/main")
|
||||
(git/ref-resolve grt "HEAD"))
|
||||
grt-c1)
|
||||
(git-test
|
||||
"symref chain resolves"
|
||||
(begin
|
||||
(git/symref-set! grt "INDIRECT" "HEAD")
|
||||
(git/ref-resolve grt "INDIRECT"))
|
||||
grt-c1)
|
||||
(git-test
|
||||
"symref cycle is bounded, resolves nil"
|
||||
(begin
|
||||
(git/symref-set! grt "LOOPA" "LOOPB")
|
||||
(git/symref-set! grt "LOOPB" "LOOPA")
|
||||
(git/ref-resolve grt "LOOPA"))
|
||||
nil)
|
||||
(git-test "ref-get does not follow a symref" (git/ref-get grt "HEAD") nil)
|
||||
(git-test
|
||||
"symref? on a raw symref value"
|
||||
(git/symref? (git/ref-read grt "HEAD"))
|
||||
true)
|
||||
|
||||
; ---- CAS ----
|
||||
(git-test
|
||||
"cas create (expected nil) succeeds"
|
||||
(git/ref-cas! grt "heads/dev" nil grt-c1)
|
||||
grt-c1)
|
||||
(git-test
|
||||
"cas create conflicts when the ref exists"
|
||||
(get (git/ref-cas! grt "heads/dev" nil grt-c2) :conflict)
|
||||
true)
|
||||
(git-test
|
||||
"cas conflict reports the actual cid"
|
||||
(get (git/ref-cas! grt "heads/dev" grt-c3 grt-c2) :actual)
|
||||
grt-c1)
|
||||
(git-test
|
||||
"cas conflict leaves the ref unchanged"
|
||||
(git/ref-get grt "heads/dev")
|
||||
grt-c1)
|
||||
(git-test
|
||||
"cas with the right expected value moves the ref"
|
||||
(git/ref-cas! grt "heads/dev" grt-c1 grt-c2)
|
||||
grt-c2)
|
||||
(git-test "cas move is visible" (git/ref-get grt "heads/dev") grt-c2)
|
||||
|
||||
; ---- delete ----
|
||||
(git-test
|
||||
"ref-delete! removes the ref"
|
||||
(begin
|
||||
(git/ref-set! grt "heads/tmp" grt-c1)
|
||||
(git/ref-delete! grt "heads/tmp")
|
||||
(git/ref-get grt "heads/tmp"))
|
||||
nil)
|
||||
|
||||
; ---- branch conveniences ----
|
||||
(git-test
|
||||
"branch-create! returns the cid"
|
||||
(git/branch-create! grt "feature" grt-c1)
|
||||
grt-c1)
|
||||
(git-test
|
||||
"branch-create! conflicts on an existing branch"
|
||||
(get (git/branch-create! grt "feature" grt-c2) :conflict)
|
||||
true)
|
||||
(git-test "branch-get" (git/branch-get grt "feature") grt-c1)
|
||||
(git-test
|
||||
"branch-cas! moves the branch"
|
||||
(git/branch-cas! grt "feature" grt-c1 grt-c2)
|
||||
grt-c2)
|
||||
(git-test
|
||||
"branch-set! moves unconditionally"
|
||||
(git/branch-set! grt "feature" grt-c3)
|
||||
grt-c3)
|
||||
(git-test
|
||||
"branch-delete! removes it"
|
||||
(begin (git/branch-delete! grt "feature") (git/branch-get grt "feature"))
|
||||
nil)
|
||||
|
||||
; ---- listing ----
|
||||
(git-test
|
||||
"branches are listed sorted, tags and HEAD excluded"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(let
|
||||
((r (git/repo db)))
|
||||
(begin
|
||||
(git/branch-set! r "zeta" "sx1:z")
|
||||
(git/branch-set! r "alpha" "sx1:a")
|
||||
(git/tag-set! r "v1" "sx1:t")
|
||||
(git/head-set! r "alpha")
|
||||
(= (git/branches r) (list "alpha" "zeta")))))
|
||||
true)
|
||||
(git-test
|
||||
"tag-names lists tag refs"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(let
|
||||
((r (git/repo db)))
|
||||
(begin
|
||||
(git/tag-set! r "v2" "sx1:t2")
|
||||
(git/tag-set! r "v1" "sx1:t1")
|
||||
(= (git/tag-names r) (list "v1" "v2")))))
|
||||
true)
|
||||
(git-test
|
||||
"tag-get reads a tag ref"
|
||||
(begin (git/tag-set! grt "v1" grt-c1) (git/tag-get grt "v1"))
|
||||
grt-c1)
|
||||
|
||||
; ---- HEAD ----
|
||||
(git-test
|
||||
"head resolves through the current branch"
|
||||
(begin
|
||||
(git/branch-set! grt "main" grt-c2)
|
||||
(git/head-set! grt "main")
|
||||
(git/head grt))
|
||||
grt-c2)
|
||||
(git-test
|
||||
"moving the branch moves head"
|
||||
(begin (git/branch-set! grt "main" grt-c3) (git/head grt))
|
||||
grt-c3)
|
||||
(git-test
|
||||
"head-target names the branch ref"
|
||||
(git/head-target grt)
|
||||
"heads/main")
|
||||
(git-test "detached? false on a symref HEAD" (git/detached? grt) false)
|
||||
(git-test
|
||||
"head-detach! pins a cid"
|
||||
(begin (git/head-detach! grt grt-c1) (git/head grt))
|
||||
grt-c1)
|
||||
(git-test "detached? true after detach" (git/detached? grt) true)
|
||||
(git-test "head-target nil when detached" (git/head-target grt) nil)
|
||||
|
||||
; ---- reflog ----
|
||||
(git-test
|
||||
"reflog of an untouched ref is empty"
|
||||
(= (git/reflog grt "heads/quiet") (list))
|
||||
true)
|
||||
(git-test
|
||||
"reflog records create and moves oldest-first"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(let
|
||||
((r (git/repo db)))
|
||||
(begin
|
||||
(git/ref-set! r "heads/x" "sx1:a")
|
||||
(git/ref-set! r "heads/x" "sx1:b")
|
||||
(git/ref-cas! r "heads/x" "sx1:b" "sx1:c")
|
||||
(= (git/reflog r "heads/x") (list {:new "sx1:a" :old nil} {:new "sx1:b" :old "sx1:a"} {:new "sx1:c" :old "sx1:b"})))))
|
||||
true)
|
||||
(git-test
|
||||
"branch-create! writes the first reflog entry"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(let
|
||||
((r (git/repo db)))
|
||||
(begin
|
||||
(git/branch-create! r "b" "sx1:a")
|
||||
(= (git/reflog r "heads/b") (list {:new "sx1:a" :old nil})))))
|
||||
true)
|
||||
(git-test
|
||||
"failed cas leaves no reflog entry"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(let
|
||||
((r (git/repo db)))
|
||||
(begin
|
||||
(git/ref-set! r "heads/x" "sx1:a")
|
||||
(git/ref-cas! r "heads/x" "sx1:wrong" "sx1:b")
|
||||
(len (git/reflog r "heads/x")))))
|
||||
1)
|
||||
|
||||
; ---- namespacing ----
|
||||
(git-test
|
||||
"refs are invisible across repo namespaces"
|
||||
(let
|
||||
((db (persist/mem-backend)))
|
||||
(begin
|
||||
(git/branch-set! (git/repo-named db "a") "main" "sx1:a")
|
||||
(git/branch-get (git/repo-named db "b") "main")))
|
||||
nil)
|
||||
253
lib/git/tests/worktree.sx
Normal file
253
lib/git/tests/worktree.sx
Normal file
@@ -0,0 +1,253 @@
|
||||
; Phase 4 — worktree: tree materialization (files-dict <-> tree objects),
|
||||
; index as staged-tree overlay, three-way status.
|
||||
|
||||
(define gwt-db (persist/mem-backend))
|
||||
(define gwt (git/repo gwt-db))
|
||||
|
||||
(define
|
||||
gwt-files
|
||||
(assoc
|
||||
(assoc (assoc {} "a.txt" "hello\n") "b.txt" "world\n")
|
||||
"sub/c.txt"
|
||||
"sub\n"))
|
||||
|
||||
(define gwt-tree (git/tree-from-files gwt gwt-files))
|
||||
(define gwt-c1 (git/write gwt (git/commit gwt-tree (list) {:message "c1"})))
|
||||
|
||||
; fresh repo with the fixture committed, HEAD on main, index clean
|
||||
(define
|
||||
gwt-fresh!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((r (git/repo (persist/mem-backend))))
|
||||
(let
|
||||
((tc (git/tree-from-files r gwt-files)))
|
||||
(let
|
||||
((c (git/write r (git/commit tc (list) {:message "c1"}))))
|
||||
(begin
|
||||
(git/branch-set! r "main" c)
|
||||
(git/head-set! r "main")
|
||||
(git/index-init! r c)
|
||||
r))))))
|
||||
|
||||
(define gwt-clean {:untracked (list) :staged {:deleted (list) :modified (list) :added (list)} :unstaged {:deleted (list) :modified (list)}})
|
||||
|
||||
; ---- tree building + materialization ----
|
||||
(git-test
|
||||
"tree-from-files writes a tree object"
|
||||
(git/tree? (git/read gwt gwt-tree))
|
||||
true)
|
||||
(git-test
|
||||
"tree-files round-trips the files dict"
|
||||
(= (git/tree-files gwt gwt-tree) gwt-files)
|
||||
true)
|
||||
(git-test
|
||||
"tree-from-files is deterministic"
|
||||
(equal? (git/tree-from-files gwt gwt-files) gwt-tree)
|
||||
true)
|
||||
(git-test
|
||||
"matches a manually built tree"
|
||||
(equal?
|
||||
(git/write
|
||||
gwt
|
||||
(git/tree
|
||||
(assoc
|
||||
(assoc
|
||||
(assoc
|
||||
{}
|
||||
"a.txt"
|
||||
(git/tree-entry "blob" (git/write-blob gwt "hello\n")))
|
||||
"b.txt"
|
||||
(git/tree-entry "blob" (git/write-blob gwt "world\n")))
|
||||
"sub"
|
||||
(git/tree-entry
|
||||
"tree"
|
||||
(git/write
|
||||
gwt
|
||||
(git/tree
|
||||
(assoc
|
||||
{}
|
||||
"c.txt"
|
||||
(git/tree-entry "blob" (git/write-blob gwt "sub\n")))))))))
|
||||
gwt-tree)
|
||||
true)
|
||||
(git-test
|
||||
"flatten lists nested paths"
|
||||
(=
|
||||
(artdag/sort-strings (keys (git/tree-flatten gwt gwt-tree)))
|
||||
(list "a.txt" "b.txt" "sub/c.txt"))
|
||||
true)
|
||||
(git-test
|
||||
"flatten maps a path to its blob cid"
|
||||
(get (git/tree-flatten gwt gwt-tree) "a.txt")
|
||||
(git/cid (git/blob "hello\n")))
|
||||
(git-test
|
||||
"commit-files materializes through the commit"
|
||||
(= (git/commit-files gwt gwt-c1) gwt-files)
|
||||
true)
|
||||
(git-test
|
||||
"deep nesting round-trips"
|
||||
(let
|
||||
((files (assoc {} "x/y/z.txt" "deep")))
|
||||
(= (git/tree-files gwt (git/tree-from-files gwt files)) files))
|
||||
true)
|
||||
(git-test
|
||||
"empty files dict is an empty tree"
|
||||
(= (git/tree-files gwt (git/tree-from-files gwt {})) {})
|
||||
true)
|
||||
|
||||
; ---- index ----
|
||||
(git-test
|
||||
"default index is empty over no base"
|
||||
(= (git/index-read (git/repo (persist/mem-backend))) {:base nil :staged {}})
|
||||
true)
|
||||
(git-test
|
||||
"index-init! bases the index on the commit tree"
|
||||
(let ((r (gwt-fresh!))) (= (git/index-files r) gwt-files))
|
||||
true)
|
||||
(git-test
|
||||
"add! stages new content"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin
|
||||
(git/add! r "new.txt" "fresh")
|
||||
(get (git/index-files r) "new.txt")))
|
||||
"fresh")
|
||||
(git-test
|
||||
"add! overlays an existing path"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin (git/add! r "a.txt" "changed") (get (git/index-files r) "a.txt")))
|
||||
"changed")
|
||||
(git-test
|
||||
"rm! stages a removal"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin (git/rm! r "b.txt") (has-key? (git/index-files r) "b.txt")))
|
||||
false)
|
||||
(git-test
|
||||
"unstage! reverts to the base"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin
|
||||
(git/add! r "a.txt" "changed")
|
||||
(git/unstage! r "a.txt")
|
||||
(get (git/index-files r) "a.txt")))
|
||||
"hello\n")
|
||||
(git-test
|
||||
"index-tree! of a clean index reproduces the commit tree cid"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(equal?
|
||||
(git/index-tree! r)
|
||||
(git/commit-tree (git/read r (git/head r)))))
|
||||
true)
|
||||
(git-test
|
||||
"index-tree! materializes staged changes"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin
|
||||
(git/add! r "a.txt" "changed")
|
||||
(git/rm! r "b.txt")
|
||||
(=
|
||||
(git/tree-files r (git/index-tree! r))
|
||||
(assoc (assoc {} "a.txt" "changed") "sub/c.txt" "sub\n"))))
|
||||
true)
|
||||
|
||||
; ---- status ----
|
||||
(git-test
|
||||
"clean repo, clean worktree"
|
||||
(= (git/status (gwt-fresh!) gwt-files) gwt-clean)
|
||||
true)
|
||||
(git-test
|
||||
"staged addition"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin
|
||||
(git/add! r "new.txt" "fresh")
|
||||
(=
|
||||
(get (get (git/status r gwt-files) :staged) :added)
|
||||
(list "new.txt"))))
|
||||
true)
|
||||
(git-test
|
||||
"staged modification"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin
|
||||
(git/add! r "a.txt" "changed")
|
||||
(=
|
||||
(get
|
||||
(get (git/status r (assoc gwt-files "a.txt" "changed")) :staged)
|
||||
:modified)
|
||||
(list "a.txt"))))
|
||||
true)
|
||||
(git-test
|
||||
"staged deletion"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin
|
||||
(git/rm! r "b.txt")
|
||||
(=
|
||||
(get
|
||||
(get (git/status r (dissoc gwt-files "b.txt")) :staged)
|
||||
:deleted)
|
||||
(list "b.txt"))))
|
||||
true)
|
||||
(git-test
|
||||
"unstaged modification"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(=
|
||||
(get
|
||||
(get (git/status r (assoc gwt-files "a.txt" "edited")) :unstaged)
|
||||
:modified)
|
||||
(list "a.txt")))
|
||||
true)
|
||||
(git-test
|
||||
"unstaged deletion"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(=
|
||||
(get
|
||||
(get (git/status r (dissoc gwt-files "sub/c.txt")) :unstaged)
|
||||
:deleted)
|
||||
(list "sub/c.txt")))
|
||||
true)
|
||||
(git-test
|
||||
"untracked file"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(=
|
||||
(get (git/status r (assoc gwt-files "notes.md" "hi")) :untracked)
|
||||
(list "notes.md")))
|
||||
true)
|
||||
(git-test
|
||||
"combined status"
|
||||
(let
|
||||
((r (gwt-fresh!)))
|
||||
(begin
|
||||
(git/add! r "staged.txt" "s")
|
||||
(git/add! r "a.txt" "changed")
|
||||
(git/rm! r "b.txt")
|
||||
(=
|
||||
(git/status
|
||||
r
|
||||
(assoc
|
||||
(assoc
|
||||
(assoc
|
||||
(dissoc (dissoc gwt-files "b.txt") "sub/c.txt")
|
||||
"a.txt"
|
||||
"changed-again")
|
||||
"staged.txt"
|
||||
"s")
|
||||
"wild.txt"
|
||||
"w"))
|
||||
{:untracked (list "wild.txt") :staged {:deleted (list "b.txt") :modified (list "a.txt") :added (list "staged.txt")} :unstaged {:deleted (list "sub/c.txt") :modified (list "a.txt")}})))
|
||||
true)
|
||||
(git-test
|
||||
"no HEAD, no index: everything untracked"
|
||||
(let
|
||||
((r (git/repo (persist/mem-backend))))
|
||||
(= (git/status r (assoc {} "f.txt" "x")) {:untracked (list "f.txt") :staged {:deleted (list) :modified (list) :added (list)} :unstaged {:deleted (list) :modified (list)}}))
|
||||
true)
|
||||
234
lib/git/worktree.sx
Normal file
234
lib/git/worktree.sx
Normal file
@@ -0,0 +1,234 @@
|
||||
; lib/git/worktree.sx — sx-git Phase 4: tree materialization, index, status.
|
||||
; The "worktree" is a VALUE: a dict of path -> file data (no filesystem).
|
||||
; The index is a staged-tree overlay {:base <tree-cid|nil> :staged
|
||||
; {path -> {:data d} | {:removed true}}} stored in kv at <prefix>/index.
|
||||
; status = three-way dict diff: HEAD tree vs index vs worktree.
|
||||
; Requires: lib/git/object.sx, lib/git/ref.sx.
|
||||
|
||||
(define
|
||||
git/wt-join
|
||||
(fn
|
||||
(segs)
|
||||
(if
|
||||
(empty? segs)
|
||||
""
|
||||
(reduce (fn (acc s) (str acc "/" s)) (first segs) (rest segs)))))
|
||||
|
||||
; ---- tree flattening: tree-cid -> dict path -> blob cid ----
|
||||
(define
|
||||
git/wt-flatten-into
|
||||
(fn
|
||||
(repo tree-cid prefix acc)
|
||||
(let
|
||||
((tree (git/read repo tree-cid)))
|
||||
(reduce
|
||||
(fn
|
||||
(a name)
|
||||
(let
|
||||
((e (git/tree-entry-for tree name)))
|
||||
(let
|
||||
((path (if (equal? prefix "") name (str prefix "/" name))))
|
||||
(if
|
||||
(equal? (git/entry-kind e) "tree")
|
||||
(git/wt-flatten-into repo (git/entry-cid e) path a)
|
||||
(assoc a path (git/entry-cid e))))))
|
||||
acc
|
||||
(git/tree-names tree)))))
|
||||
|
||||
(define
|
||||
git/tree-flatten
|
||||
(fn (repo tree-cid) (git/wt-flatten-into repo tree-cid "" {})))
|
||||
|
||||
; ---- materialization: tree/commit -> dict path -> data ----
|
||||
(define
|
||||
git/tree-files
|
||||
(fn
|
||||
(repo tree-cid)
|
||||
(let
|
||||
((flat (git/tree-flatten repo tree-cid)))
|
||||
(reduce
|
||||
(fn
|
||||
(a p)
|
||||
(assoc a p (git/blob-data (git/read repo (get flat p)))))
|
||||
{}
|
||||
(keys flat)))))
|
||||
|
||||
(define
|
||||
git/commit-files
|
||||
(fn
|
||||
(repo commit-cid)
|
||||
(git/tree-files repo (git/commit-tree (git/read repo commit-cid)))))
|
||||
|
||||
; ---- inverse: dict path -> data => written tree, returns root tree cid ----
|
||||
(define
|
||||
git/wt-group
|
||||
(fn
|
||||
(files)
|
||||
(reduce
|
||||
(fn
|
||||
(acc path)
|
||||
(let
|
||||
((segs (split path "/")))
|
||||
(if
|
||||
(= (len segs) 1)
|
||||
(assoc
|
||||
acc
|
||||
:files (assoc (get acc :files) path (get files path)))
|
||||
(let
|
||||
((dir (first segs)))
|
||||
(let
|
||||
((cur (get (get acc :dirs) dir)))
|
||||
(assoc
|
||||
acc
|
||||
:dirs (assoc
|
||||
(get acc :dirs)
|
||||
dir
|
||||
(assoc
|
||||
(if (dict? cur) cur {})
|
||||
(git/wt-join (rest segs))
|
||||
(get files path)))))))))
|
||||
{:files {} :dirs {}}
|
||||
(keys files))))
|
||||
|
||||
(define
|
||||
git/tree-from-files
|
||||
(fn
|
||||
(repo files)
|
||||
(let
|
||||
((g (git/wt-group files)))
|
||||
(let
|
||||
((with-blobs (reduce (fn (acc name) (assoc acc name (git/tree-entry "blob" (git/write-blob repo (get (get g :files) name))))) {} (keys (get g :files)))))
|
||||
(let
|
||||
((entries (reduce (fn (acc dir) (assoc acc dir (git/tree-entry "tree" (git/tree-from-files repo (get (get g :dirs) dir))))) with-blobs (keys (get g :dirs)))))
|
||||
(git/write repo (git/tree entries)))))))
|
||||
|
||||
; ---- index: staged overlay over a base tree ----
|
||||
(define git/index-key (fn (repo) (str (get repo :prefix) "/index")))
|
||||
|
||||
(define
|
||||
git/index-read
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((v (persist/kv-get (get repo :db) (git/index-key repo))))
|
||||
(if (dict? v) v {:base nil :staged {}}))))
|
||||
|
||||
(define
|
||||
git/index-write!
|
||||
(fn
|
||||
(repo idx)
|
||||
(begin (persist/kv-put (get repo :db) (git/index-key repo) idx) idx)))
|
||||
|
||||
; reset the index to a commit's tree (nil commit = empty index)
|
||||
(define
|
||||
git/index-init!
|
||||
(fn (repo commit-cid) (git/index-write! repo {:base (if (equal? commit-cid nil) nil (git/commit-tree (git/read repo commit-cid))) :staged {}})))
|
||||
|
||||
(define
|
||||
git/wt-stage!
|
||||
(fn
|
||||
(repo path entry)
|
||||
(let
|
||||
((idx (git/index-read repo)))
|
||||
(git/index-write!
|
||||
repo
|
||||
(assoc idx :staged (assoc (get idx :staged) path entry))))))
|
||||
|
||||
(define git/add! (fn (repo path data) (git/wt-stage! repo path {:data data})))
|
||||
(define git/rm! (fn (repo path) (git/wt-stage! repo path {:removed true})))
|
||||
|
||||
(define
|
||||
git/unstage!
|
||||
(fn
|
||||
(repo path)
|
||||
(let
|
||||
((idx (git/index-read repo)))
|
||||
(git/index-write!
|
||||
repo
|
||||
(assoc idx :staged (dissoc (get idx :staged) path))))))
|
||||
|
||||
; apply the overlay: base entries -> overridden/removed by staged
|
||||
(define
|
||||
git/wt-overlay
|
||||
(fn
|
||||
(base staged entry-fn)
|
||||
(reduce
|
||||
(fn
|
||||
(acc path)
|
||||
(let
|
||||
((s (get staged path)))
|
||||
(if
|
||||
(has-key? s :removed)
|
||||
(dissoc acc path)
|
||||
(assoc acc path (entry-fn s)))))
|
||||
base
|
||||
(keys staged))))
|
||||
|
||||
; effective index as path -> blob cid
|
||||
(define
|
||||
git/index-cids
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((idx (git/index-read repo)))
|
||||
(git/wt-overlay
|
||||
(if
|
||||
(equal? (get idx :base) nil)
|
||||
{}
|
||||
(git/tree-flatten repo (get idx :base)))
|
||||
(get idx :staged)
|
||||
(fn (s) (git/cid (git/blob (get s :data))))))))
|
||||
|
||||
; effective index as path -> data
|
||||
(define
|
||||
git/index-files
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((idx (git/index-read repo)))
|
||||
(git/wt-overlay
|
||||
(if
|
||||
(equal? (get idx :base) nil)
|
||||
{}
|
||||
(git/tree-files repo (get idx :base)))
|
||||
(get idx :staged)
|
||||
(fn (s) (get s :data))))))
|
||||
|
||||
; write the staged state as a real tree; returns the root tree cid
|
||||
(define
|
||||
git/index-tree!
|
||||
(fn (repo) (git/tree-from-files repo (git/index-files repo))))
|
||||
|
||||
; ---- status ----
|
||||
(define
|
||||
git/files-cids
|
||||
(fn
|
||||
(files)
|
||||
(reduce
|
||||
(fn (acc p) (assoc acc p (git/cid (git/blob (get files p)))))
|
||||
{}
|
||||
(keys files))))
|
||||
|
||||
(define git/files-diff (fn (old new) {:deleted (artdag/sort-strings (filter (fn (p) (not (has-key? new p))) (keys old))) :modified (artdag/sort-strings (filter (fn (p) (and (has-key? old p) (not (equal? (get old p) (get new p))))) (keys new))) :added (artdag/sort-strings (filter (fn (p) (not (has-key? old p))) (keys new)))}))
|
||||
|
||||
(define
|
||||
git/head-tree-cids
|
||||
(fn
|
||||
(repo)
|
||||
(let
|
||||
((h (git/head repo)))
|
||||
(if
|
||||
(equal? h nil)
|
||||
{}
|
||||
(git/tree-flatten repo (git/commit-tree (git/read repo h)))))))
|
||||
|
||||
; worktree-files: dict path -> data (the caller's working copy value)
|
||||
(define
|
||||
git/status
|
||||
(fn
|
||||
(repo worktree-files)
|
||||
(let
|
||||
((headc (git/head-tree-cids repo))
|
||||
(idxc (git/index-cids repo))
|
||||
(wtc (git/files-cids worktree-files)))
|
||||
(let ((unstaged (git/files-diff idxc wtc))) {:untracked (get unstaged :added) :staged (git/files-diff headc idxc) :unstaged {:deleted (get unstaged :deleted) :modified (get unstaged :modified)}}))))
|
||||
61
lib/gitea/README.md
Normal file
61
lib/gitea/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# sx-gitea — a federated git forge in plain SX
|
||||
|
||||
A git forge built by **composing the x-on-sx subsystems**: every phase
|
||||
wires one more substrate onto the forge. No third-party dependencies —
|
||||
the whole stack is SX on the OCaml kernel.
|
||||
|
||||
Run the suite: `bash lib/gitea/conformance.sh` (per-suite scores in
|
||||
`scoreboard.md`). Suites are independent `sx_server` sessions; heavyweight
|
||||
substrates (Smalltalk/content, Scheme/flow, APL/feed, Haskell/search) load
|
||||
only for the suites that need them.
|
||||
|
||||
## Composition map
|
||||
|
||||
| Phase | Module | Built on |
|
||||
|-------|--------|----------|
|
||||
| 1 repo | `repo.sx` | **sx-git** (`lib/git`, native-CID object store), persist kv |
|
||||
| 2 access | `access.sx` | **acl** (datalog): repo role groups, collaborators, org teams; bearer tokens |
|
||||
| 3 wire | `wire.sx` | git-style smart HTTP: pkt-line framing, upload/receive-pack, CID-verified packs; client (`clone!`/`fetch!`/`push!`) drives any dream app fn |
|
||||
| 4 issues | `issues.sx` | **content** (Smalltalk): Markdown bodies as block documents; **relations** (datalog): derived issue graph |
|
||||
| 5 pr | `pr.sx` | **sx-git** merge-base diffs + 3-way merge; **flow** (Scheme): durable open→approval→merge lifecycle; merge queue |
|
||||
| 6 activity | `activity.sx` | **feed** (APL): timelines/dashboard; **events** (flow): durable at-least-once notifications |
|
||||
| 7 search | `search.sx` | **search** (Haskell): tf-idf ranked code/issue/PR search, batched evaluations |
|
||||
| 8 fed | `fed.sx` | ForgeFed: AP actors, trust-gated inbox with provenance + materialized federated issues/PRs, mirrors over the wire client, cursor-based delivery |
|
||||
| web | `web.sx` | **dream**: routes, auth gating (401/403/404-hides-private), route-pack registry |
|
||||
|
||||
## Architectural rules of thumb
|
||||
|
||||
- **The kv store is the source of truth.** Owners, repo records, issues,
|
||||
PRs, collaborators, teams, tokens, follows, trust, mirrors — all plain
|
||||
dicts under `gitea/...` keys on one persist backend per forge.
|
||||
Deleting a repo is a prefix purge (no ghost state on recreate).
|
||||
- **Derived, not maintained.** The acl database and the relations graph
|
||||
are *derived* from kv state and rebuilt when the derived facts change
|
||||
(cached in the forge handle) — deletions can never dangle.
|
||||
- **Instrument in the runtime.** Activity logging wraps the mutation
|
||||
verbs by redefinition (`gitea/base-*!` + wrapper), so every caller
|
||||
emits activity with zero call-site edits.
|
||||
- **Everything is testable without sockets.** A forge is a value over a
|
||||
`persist/mem-backend`; `gitea/app` is a pure request→response fn; the
|
||||
wire client federates two in-memory forges directly.
|
||||
- **Trust is re-checked, never cached.** Federation operations
|
||||
(inbox, mirror sync, delivery) consult the trust set at use time.
|
||||
|
||||
## Per-repo git stores
|
||||
|
||||
Each repo's objects/refs live in their own `git/repo-named` namespace
|
||||
`forge/<owner>/<name>` — identical content still shares CIDs, but repos
|
||||
cannot see each other's objects. All ref moves go through `ref-cas!`;
|
||||
concurrent pushes surface as `stale`/`non-fast-forward` per-ref statuses.
|
||||
|
||||
## Known limits (deliberate, documented)
|
||||
|
||||
- Wire packs carry one object per pkt line (~64KB); side-band chunking
|
||||
is a future extension (`gitea/pkt-fits?` reports it). SHA-1/packfile
|
||||
byte compat for stock git clients lives in `lib/git/{export,import}.sx`
|
||||
and is not yet wired into the HTTP endpoints.
|
||||
- Inbox activities are trust-gated but not signature-verified.
|
||||
- Reopening a PR restarts its lifecycle flow (a cancelled flow cannot
|
||||
resume); reviews survive.
|
||||
- Issue web close/reopen does not emit activity (no actor at the core
|
||||
call sites for `issue-close!`).
|
||||
403
lib/gitea/access.sx
Normal file
403
lib/gitea/access.sx
Normal file
@@ -0,0 +1,403 @@
|
||||
; lib/gitea/access.sx — sx-gitea Phase 2: permissions over lib/acl.
|
||||
;
|
||||
; Model: each repo exposes three role groups ("read:"/"write:"/"admin:" +
|
||||
; "repo:<owner>/<name>") with hierarchical action grants (admin > write >
|
||||
; read). A user-owner is a member of the admin group. Collaborators join
|
||||
; the group for their role. Org repos add team groups: a team has a role
|
||||
; and a repo scope ("all" or a name list); team members join the team
|
||||
; group, the team group joins the covered repos' role groups. Facts are
|
||||
; derived from forge state and saturated by the acl datalog engine —
|
||||
; deny-wins and group nesting come for free.
|
||||
;
|
||||
; Public visibility is an engine short-circuit: "read" on a public repo is
|
||||
; always permitted, even anonymously (subject nil never reaches acl).
|
||||
;
|
||||
; Auth is bearer tokens in the kv store — Phase 8 federates identity.
|
||||
;
|
||||
; Requires: lib/gitea/repo.sx, lib/datalog/* stack,
|
||||
; lib/acl/{schema,facts,engine}.sx
|
||||
|
||||
(define gitea/roles (list "read" "write" "admin"))
|
||||
(define gitea/role-valid? (fn (r) (contains? gitea/roles r)))
|
||||
|
||||
; actions implied by holding a role on a repo
|
||||
(define
|
||||
gitea/role-actions
|
||||
(fn
|
||||
(role)
|
||||
(cond
|
||||
((equal? role "read") (list "read"))
|
||||
((equal? role "write") (list "read" "write"))
|
||||
((equal? role "admin") (list "read" "write" "admin"))
|
||||
(else (list)))))
|
||||
|
||||
(define gitea/repo-res (fn (owner name) (str "repo:" owner "/" name)))
|
||||
(define gitea/role-group (fn (res role) (str role ":" res)))
|
||||
(define gitea/team-id (fn (org team) (str "team:" org "/" team)))
|
||||
|
||||
(define
|
||||
gitea/split-full
|
||||
(fn (full) (let ((i (index-of full "/"))) {:name (substr full (+ i 1)) :owner (substr full 0 i)})))
|
||||
|
||||
; ── collaborators ────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/collab-key
|
||||
(fn (owner name user) (str "gitea/collab/" owner "/" name "/" user)))
|
||||
|
||||
; upsert: adding an existing collaborator changes their role
|
||||
(define
|
||||
gitea/collab-add!
|
||||
(fn
|
||||
(forge owner name user role)
|
||||
(cond
|
||||
((not (gitea/repo-exists? forge owner name)) {:error "no-such-repo"})
|
||||
((not (gitea/owner-exists? forge user)) {:error "no-such-user"})
|
||||
((not (gitea/role-valid? role)) {:error "invalid-role"})
|
||||
(else
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/collab-key owner name user)
|
||||
{:role role :user user})))))
|
||||
|
||||
(define
|
||||
gitea/collab-get
|
||||
(fn
|
||||
(forge owner name user)
|
||||
(persist/kv-get
|
||||
(gitea/forge-db forge)
|
||||
(gitea/collab-key owner name user))))
|
||||
|
||||
(define
|
||||
gitea/collab-role
|
||||
(fn
|
||||
(forge owner name user)
|
||||
(get (or (gitea/collab-get forge owner name user) {}) :role)))
|
||||
|
||||
(define
|
||||
gitea/collab-remove!
|
||||
(fn
|
||||
(forge owner name user)
|
||||
(if
|
||||
(nil? (gitea/collab-get forge owner name user))
|
||||
false
|
||||
(begin
|
||||
(persist/kv-delete
|
||||
(gitea/forge-db forge)
|
||||
(gitea/collab-key owner name user))
|
||||
true))))
|
||||
|
||||
(define
|
||||
gitea/collabs
|
||||
(fn
|
||||
(forge owner name)
|
||||
(gitea/names-under forge (str "gitea/collab/" owner "/" name "/"))))
|
||||
|
||||
; ── teams ────────────────────────────────────────────────────────────
|
||||
; A team belongs to an org, carries ONE role, and covers either every org
|
||||
; repo (:repos "all") or an explicit list of repo names.
|
||||
|
||||
(define gitea/team-key (fn (org team) (str "gitea/team/" org "/" team)))
|
||||
(define
|
||||
gitea/teammem-key
|
||||
(fn (org team user) (str "gitea/teammem/" org "/" team "/" user)))
|
||||
|
||||
(define
|
||||
gitea/team-create!
|
||||
(fn
|
||||
(forge org team role)
|
||||
(let
|
||||
((orec (gitea/owner-get forge org)))
|
||||
(cond
|
||||
((or (nil? orec) (not (gitea/org? orec))) {:error "no-such-org"})
|
||||
((not (gitea/valid-name? team)) {:error "invalid-name"})
|
||||
((not (gitea/role-valid? role)) {:error "invalid-role"})
|
||||
(else
|
||||
(persist/kv-put-new
|
||||
(gitea/forge-db forge)
|
||||
(gitea/team-key org team)
|
||||
{:name team :org org :repos "all" :role role}))))))
|
||||
|
||||
(define
|
||||
gitea/team-get
|
||||
(fn
|
||||
(forge org team)
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/team-key org team))))
|
||||
|
||||
(define
|
||||
gitea/teams
|
||||
(fn (forge org) (gitea/names-under forge (str "gitea/team/" org "/"))))
|
||||
|
||||
(define
|
||||
gitea/team-set-repos!
|
||||
(fn
|
||||
(forge org team repos)
|
||||
(let
|
||||
((rec (gitea/team-get forge org team)))
|
||||
(if
|
||||
(nil? rec)
|
||||
nil
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/team-key org team)
|
||||
(assoc rec :repos repos))))))
|
||||
|
||||
(define
|
||||
gitea/team-add-member!
|
||||
(fn
|
||||
(forge org team user)
|
||||
(cond
|
||||
((nil? (gitea/team-get forge org team)) {:error "no-such-team"})
|
||||
((not (gitea/owner-exists? forge user)) {:error "no-such-user"})
|
||||
(else
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/teammem-key org team user)
|
||||
{:user user})))))
|
||||
|
||||
(define
|
||||
gitea/team-remove-member!
|
||||
(fn
|
||||
(forge org team user)
|
||||
(let
|
||||
((k (gitea/teammem-key org team user)))
|
||||
(if
|
||||
(persist/kv-has? (gitea/forge-db forge) k)
|
||||
(begin (persist/kv-delete (gitea/forge-db forge) k) true)
|
||||
false))))
|
||||
|
||||
(define
|
||||
gitea/team-members
|
||||
(fn
|
||||
(forge org team)
|
||||
(gitea/names-under forge (str "gitea/teammem/" org "/" team "/"))))
|
||||
|
||||
(define
|
||||
gitea/team-delete!
|
||||
(fn
|
||||
(forge org team)
|
||||
(if
|
||||
(nil? (gitea/team-get forge org team))
|
||||
false
|
||||
(let
|
||||
((db (gitea/forge-db forge)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn (u) (persist/kv-delete db (gitea/teammem-key org team u)))
|
||||
(gitea/team-members forge org team))
|
||||
(persist/kv-delete db (gitea/team-key org team))
|
||||
true)))))
|
||||
|
||||
(define
|
||||
gitea/team-covers?
|
||||
(fn
|
||||
(trec name)
|
||||
(let
|
||||
((repos (get trec :repos)))
|
||||
(or (equal? repos "all") (and (list? repos) (contains? repos name))))))
|
||||
|
||||
; org admin = member of an admin-role team that covers every repo
|
||||
(define
|
||||
gitea/org-admin?
|
||||
(fn
|
||||
(forge org user)
|
||||
(reduce
|
||||
(fn
|
||||
(acc tname)
|
||||
(or
|
||||
acc
|
||||
(let
|
||||
((trec (gitea/team-get forge org tname)))
|
||||
(and
|
||||
(equal? (get trec :role) "admin")
|
||||
(equal? (get trec :repos) "all")
|
||||
(contains? (gitea/team-members forge org tname) user)))))
|
||||
false
|
||||
(gitea/teams forge org))))
|
||||
|
||||
; ── acl fact derivation ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/access-facts
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((facts (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(full)
|
||||
(let
|
||||
((p (gitea/split-full full)))
|
||||
(let
|
||||
((owner (get p :owner)) (name (get p :name)))
|
||||
(let
|
||||
((res (gitea/repo-res owner name))
|
||||
(orec (gitea/owner-get forge owner)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(role)
|
||||
(for-each
|
||||
(fn
|
||||
(act)
|
||||
(append!
|
||||
facts
|
||||
(acl-grant (gitea/role-group res role) act res)))
|
||||
(gitea/role-actions role)))
|
||||
gitea/roles)
|
||||
(if
|
||||
(and orec (gitea/user? orec))
|
||||
(append!
|
||||
facts
|
||||
(acl-member-of owner (gitea/role-group res "admin")))
|
||||
nil)
|
||||
(for-each
|
||||
(fn
|
||||
(user)
|
||||
(append!
|
||||
facts
|
||||
(acl-member-of
|
||||
user
|
||||
(gitea/role-group
|
||||
res
|
||||
(gitea/collab-role forge owner name user)))))
|
||||
(gitea/collabs forge owner name))
|
||||
(if
|
||||
(and orec (gitea/org? orec))
|
||||
(for-each
|
||||
(fn
|
||||
(tname)
|
||||
(let
|
||||
((trec (gitea/team-get forge owner tname)))
|
||||
(if
|
||||
(gitea/team-covers? trec name)
|
||||
(append!
|
||||
facts
|
||||
(acl-member-of
|
||||
(gitea/team-id owner tname)
|
||||
(gitea/role-group res (get trec :role))))
|
||||
nil)))
|
||||
(gitea/teams forge owner))
|
||||
nil))))))
|
||||
(gitea/repos forge))
|
||||
(for-each
|
||||
(fn
|
||||
(owner)
|
||||
(let
|
||||
((orec (gitea/owner-get forge owner)))
|
||||
(if
|
||||
(and orec (gitea/org? orec))
|
||||
(for-each
|
||||
(fn
|
||||
(tname)
|
||||
(for-each
|
||||
(fn
|
||||
(user)
|
||||
(append!
|
||||
facts
|
||||
(acl-member-of user (gitea/team-id owner tname))))
|
||||
(gitea/team-members forge owner tname)))
|
||||
(gitea/teams forge owner))
|
||||
nil)))
|
||||
(gitea/owners forge))
|
||||
facts))))
|
||||
|
||||
; rebuild the acl db only when derived facts changed (cache in the forge
|
||||
; handle; forges created before the :cache field just rebuild every call)
|
||||
(define
|
||||
gitea/access-db
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((facts (gitea/access-facts forge)) (cache (get forge :cache)))
|
||||
(if
|
||||
(and cache (= (get cache "facts") facts) (get cache "db"))
|
||||
(get cache "db")
|
||||
(let
|
||||
((db (acl-build-db facts)))
|
||||
(begin
|
||||
(if
|
||||
cache
|
||||
(begin
|
||||
(dict-set! cache "facts" facts)
|
||||
(dict-set! cache "db" db))
|
||||
nil)
|
||||
db))))))
|
||||
|
||||
; ── the permission question ──────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/can?
|
||||
(fn
|
||||
(forge subj action owner name)
|
||||
(let
|
||||
((rec (gitea/repo-get forge owner name)))
|
||||
(cond
|
||||
((nil? rec) false)
|
||||
((and (equal? action "read") (equal? (get rec :visibility) "public"))
|
||||
true)
|
||||
((nil? subj) false)
|
||||
(else
|
||||
(acl-permit?
|
||||
(gitea/access-db forge)
|
||||
subj
|
||||
action
|
||||
(gitea/repo-res owner name)))))))
|
||||
|
||||
(define
|
||||
gitea/visible-repos
|
||||
(fn
|
||||
(forge subj)
|
||||
(filter
|
||||
(fn
|
||||
(full)
|
||||
(let
|
||||
((p (gitea/split-full full)))
|
||||
(gitea/can? forge subj "read" (get p :owner) (get p :name))))
|
||||
(gitea/repos forge))))
|
||||
|
||||
; who may create a repo under this owner?
|
||||
(define
|
||||
gitea/create-allowed?
|
||||
(fn
|
||||
(forge user owner)
|
||||
(let
|
||||
((orec (gitea/owner-get forge owner)))
|
||||
(cond
|
||||
((nil? user) false)
|
||||
((nil? orec) false)
|
||||
((gitea/user? orec) (equal? user owner))
|
||||
(else (gitea/org-admin? forge owner user))))))
|
||||
|
||||
; ── bearer tokens ────────────────────────────────────────────────────
|
||||
|
||||
(define gitea/token-key (fn (token) (str "gitea/token/" token)))
|
||||
|
||||
(define
|
||||
gitea/token-create!
|
||||
(fn
|
||||
(forge user token)
|
||||
(if
|
||||
(not (gitea/owner-exists? forge user))
|
||||
{:error "no-such-user"}
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/token-key token)
|
||||
{:user user}))))
|
||||
|
||||
(define
|
||||
gitea/token-user
|
||||
(fn
|
||||
(forge token)
|
||||
(get
|
||||
(or
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/token-key token))
|
||||
{})
|
||||
:user)))
|
||||
|
||||
(define
|
||||
gitea/token-revoke!
|
||||
(fn
|
||||
(forge token)
|
||||
(persist/kv-delete (gitea/forge-db forge) (gitea/token-key token))))
|
||||
579
lib/gitea/activity.sx
Normal file
579
lib/gitea/activity.sx
Normal file
@@ -0,0 +1,579 @@
|
||||
; lib/gitea/activity.sx — sx-gitea Phase 6: activity, dashboard, notify.
|
||||
;
|
||||
; Every noteworthy forge action lands as a feed activity ({:actor :verb
|
||||
; :object :at :tags}) in an append-only persist log stream on the forge
|
||||
; backend. Instrumentation wraps the existing mutation verbs IN the
|
||||
; runtime (repo-create!/issue-create!/issue-comment!/pr-create!/
|
||||
; pr-review!/pr-merge! are redefined around their originals), so SX
|
||||
; callers and web handlers alike emit activity with zero call-site edits.
|
||||
;
|
||||
; Timelines are lib/feed queries over that stream (APL-backed filter/
|
||||
; rank/take); visibility follows repo access — an activity tagged with a
|
||||
; private repo is invisible to non-readers. The dashboard shows what a
|
||||
; user follows (users or repos), minus their own actions.
|
||||
;
|
||||
; Notifications ride lib/events' durable delivery flows: pending
|
||||
; activities after a cursor expand to (id recipient body) messages,
|
||||
; ev/deliver-messages runs them through the at-least-once digest flow
|
||||
; (idempotent by message id), and delivered messages land in per-user kv
|
||||
; inboxes; the cursor then advances, so a re-run delivers nothing twice.
|
||||
;
|
||||
; Requires: lib/gitea/{repo,access,web,issues,pr}.sx and their stacks,
|
||||
; lib/apl/runtime.sx + lib/feed/{normalize,stream,api,fanout,dedupe,
|
||||
; aggregate,rank,acl,mute,page,notify,home,fed}.sx, and
|
||||
; lib/events/notify.sx over the flow stack.
|
||||
|
||||
; ── the activity log ─────────────────────────────────────────────────
|
||||
|
||||
(define gitea/activity-stream-name "gitea/activity")
|
||||
|
||||
(define
|
||||
gitea/act!
|
||||
(fn
|
||||
(forge actor verb object tags at)
|
||||
(persist/append
|
||||
(gitea/forge-db forge)
|
||||
gitea/activity-stream-name
|
||||
verb
|
||||
at
|
||||
(feed/activity actor verb object at tags))))
|
||||
|
||||
(define
|
||||
gitea/activity-events
|
||||
(fn
|
||||
(forge)
|
||||
(persist/read (gitea/forge-db forge) gitea/activity-stream-name)))
|
||||
|
||||
(define
|
||||
gitea/activity-stream
|
||||
(fn
|
||||
(forge)
|
||||
(feed/stream (map persist/event-data (gitea/activity-events forge)))))
|
||||
|
||||
(define
|
||||
gitea/activity-count
|
||||
(fn
|
||||
(forge)
|
||||
(persist/last-seq (gitea/forge-db forge) gitea/activity-stream-name)))
|
||||
|
||||
; ── node helpers ─────────────────────────────────────────────────────
|
||||
|
||||
(define gitea/pr-node (fn (owner name n) (str "pr:" owner "/" name "#" n)))
|
||||
|
||||
; "issue:alice/proj#3" / "pr:alice/proj#3" => {:owner :name :n} | nil
|
||||
(define
|
||||
gitea/parse-numbered-node
|
||||
(fn
|
||||
(node)
|
||||
(let
|
||||
((colon (index-of node ":")) (hash (index-of node "#")))
|
||||
(if
|
||||
(or (< colon 0) (< hash 0))
|
||||
nil
|
||||
(let
|
||||
((p (gitea/split-full (substr node (+ colon 1) (- hash colon 1)))))
|
||||
{:name (get p :name) :n (parse-int (substr node (+ hash 1))) :owner (get p :owner)})))))
|
||||
|
||||
; the repo an activity belongs to, via its tags
|
||||
(define
|
||||
gitea/act-repo
|
||||
(fn
|
||||
(a)
|
||||
(first (filter (fn (t) (starts-with? t "repo:")) (get a :tags)))))
|
||||
|
||||
(define
|
||||
gitea/act-visible?
|
||||
(fn
|
||||
(forge user a)
|
||||
(let
|
||||
((r (gitea/act-repo a)))
|
||||
(if
|
||||
(nil? r)
|
||||
true
|
||||
(let
|
||||
((p (gitea/split-full (substr r 5))))
|
||||
(gitea/can? forge user "read" (get p :owner) (get p :name)))))))
|
||||
|
||||
; ── instrumentation: wrap the mutation verbs ─────────────────────────
|
||||
|
||||
(define gitea/base-repo-create! gitea/repo-create!)
|
||||
(define
|
||||
gitea/repo-create!
|
||||
(fn
|
||||
(forge owner name opts)
|
||||
(let
|
||||
((res (gitea/base-repo-create! forge owner name opts)))
|
||||
(begin
|
||||
(if
|
||||
(or (get res :error) (get res :conflict))
|
||||
nil
|
||||
(gitea/act!
|
||||
forge
|
||||
owner
|
||||
"create-repo"
|
||||
(gitea/repo-node owner name)
|
||||
(list (gitea/repo-node owner name))
|
||||
(get res :created-at)))
|
||||
res))))
|
||||
|
||||
(define gitea/base-issue-create! gitea/issue-create!)
|
||||
(define
|
||||
gitea/issue-create!
|
||||
(fn
|
||||
(forge owner name author title body opts)
|
||||
(let
|
||||
((res (gitea/base-issue-create! forge owner name author title body opts)))
|
||||
(begin
|
||||
(if
|
||||
(get res :error)
|
||||
nil
|
||||
(gitea/act!
|
||||
forge
|
||||
author
|
||||
"open-issue"
|
||||
(gitea/issue-node owner name (get res :number))
|
||||
(list (gitea/repo-node owner name))
|
||||
(get res :created-at)))
|
||||
res))))
|
||||
|
||||
(define gitea/base-issue-comment! gitea/issue-comment!)
|
||||
(define
|
||||
gitea/issue-comment!
|
||||
(fn
|
||||
(forge owner name n author body opts)
|
||||
(let
|
||||
((res (gitea/base-issue-comment! forge owner name n author body opts)))
|
||||
(begin
|
||||
(if
|
||||
(get res :error)
|
||||
nil
|
||||
(gitea/act!
|
||||
forge
|
||||
author
|
||||
"comment"
|
||||
(gitea/issue-node owner name n)
|
||||
(list (gitea/repo-node owner name))
|
||||
(get res :at)))
|
||||
res))))
|
||||
|
||||
(define gitea/base-pr-create! gitea/pr-create!)
|
||||
(define
|
||||
gitea/pr-create!
|
||||
(fn
|
||||
(forge owner name author title source target body opts)
|
||||
(let
|
||||
((res (gitea/base-pr-create! forge owner name author title source target body opts)))
|
||||
(begin
|
||||
(if
|
||||
(get res :error)
|
||||
nil
|
||||
(gitea/act!
|
||||
forge
|
||||
author
|
||||
"open-pr"
|
||||
(gitea/pr-node owner name (get res :number))
|
||||
(list (gitea/repo-node owner name))
|
||||
(get res :created-at)))
|
||||
res))))
|
||||
|
||||
(define gitea/base-pr-review! gitea/pr-review!)
|
||||
(define
|
||||
gitea/pr-review!
|
||||
(fn
|
||||
(forge owner name n reviewer verdict body opts)
|
||||
(let
|
||||
((res (gitea/base-pr-review! forge owner name n reviewer verdict body opts)))
|
||||
(begin
|
||||
(if
|
||||
(get res :error)
|
||||
nil
|
||||
(gitea/act!
|
||||
forge
|
||||
reviewer
|
||||
"review"
|
||||
(gitea/pr-node owner name n)
|
||||
(list (gitea/repo-node owner name))
|
||||
(get res :at)))
|
||||
res))))
|
||||
|
||||
(define gitea/base-pr-merge! gitea/pr-merge!)
|
||||
(define
|
||||
gitea/pr-merge!
|
||||
(fn
|
||||
(forge owner name n merger opts)
|
||||
(let
|
||||
((res (gitea/base-pr-merge! forge owner name n merger opts)))
|
||||
(begin
|
||||
(if
|
||||
(get res :error)
|
||||
nil
|
||||
(gitea/act!
|
||||
forge
|
||||
merger
|
||||
"merge-pr"
|
||||
(gitea/pr-node owner name n)
|
||||
(list (gitea/repo-node owner name))
|
||||
(or (get (or opts {}) :time) 0)))
|
||||
res))))
|
||||
|
||||
; ── timelines ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/timeline
|
||||
(fn
|
||||
(forge user n)
|
||||
(feed/items
|
||||
(feed/take
|
||||
(feed/recent
|
||||
(feed/filter
|
||||
(gitea/activity-stream forge)
|
||||
(fn (a) (gitea/act-visible? forge user a))))
|
||||
n))))
|
||||
|
||||
(define
|
||||
gitea/repo-timeline
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((node (gitea/repo-node owner name)))
|
||||
(feed/items
|
||||
(feed/take
|
||||
(feed/recent
|
||||
(feed/filter
|
||||
(gitea/activity-stream forge)
|
||||
(fn (a) (contains? (get a :tags) node))))
|
||||
n)))))
|
||||
|
||||
(define
|
||||
gitea/user-timeline
|
||||
(fn
|
||||
(forge viewer user n)
|
||||
(feed/items
|
||||
(feed/take
|
||||
(feed/recent
|
||||
(feed/filter
|
||||
(feed/by-actor (gitea/activity-stream forge) user)
|
||||
(fn (a) (gitea/act-visible? forge viewer a))))
|
||||
n))))
|
||||
|
||||
; ── follows + dashboard ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/follow-key
|
||||
(fn (user target) (str "gitea/follow/" user "/" target)))
|
||||
|
||||
(define
|
||||
gitea/follow-target-valid?
|
||||
(fn
|
||||
(forge target)
|
||||
(cond
|
||||
((not (string? target)) false)
|
||||
((starts-with? target "user:")
|
||||
(gitea/owner-exists? forge (substr target 5)))
|
||||
((starts-with? target "repo:")
|
||||
(let
|
||||
((p (gitea/split-full (substr target 5))))
|
||||
(gitea/repo-exists? forge (get p :owner) (get p :name))))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
gitea/follow!
|
||||
(fn
|
||||
(forge user target)
|
||||
(cond
|
||||
((not (gitea/owner-exists? forge user)) {:error "no-such-user"})
|
||||
((not (gitea/follow-target-valid? forge target)) {:error "no-such-target"})
|
||||
(else
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/follow-key user target)
|
||||
{:target target})))))
|
||||
|
||||
(define
|
||||
gitea/unfollow!
|
||||
(fn
|
||||
(forge user target)
|
||||
(let
|
||||
((k (gitea/follow-key user target)))
|
||||
(if
|
||||
(persist/kv-has? (gitea/forge-db forge) k)
|
||||
(begin (persist/kv-delete (gitea/forge-db forge) k) true)
|
||||
false))))
|
||||
|
||||
(define
|
||||
gitea/follows
|
||||
(fn (forge user) (gitea/names-under forge (str "gitea/follow/" user "/"))))
|
||||
|
||||
; what the people/repos a user follows have been doing (own actions
|
||||
; excluded, private repos only where the VIEWER can read)
|
||||
(define
|
||||
gitea/dashboard
|
||||
(fn
|
||||
(forge user n)
|
||||
(let
|
||||
((follows (gitea/follows forge user)))
|
||||
(feed/items
|
||||
(feed/take
|
||||
(feed/recent
|
||||
(feed/filter
|
||||
(gitea/activity-stream forge)
|
||||
(fn
|
||||
(a)
|
||||
(and
|
||||
(not (= (get a :actor) user))
|
||||
(gitea/act-visible? forge user a)
|
||||
(or
|
||||
(contains? follows (str "user:" (get a :actor)))
|
||||
(let
|
||||
((r (gitea/act-repo a)))
|
||||
(and (not (nil? r)) (contains? follows r))))))))
|
||||
n)))))
|
||||
|
||||
; ── notifications over lib/events durable delivery ───────────────────
|
||||
|
||||
; who should hear about an activity (never its own actor)
|
||||
(define
|
||||
gitea/notify-recipients
|
||||
(fn
|
||||
(forge a)
|
||||
(let
|
||||
((verb (get a :verb))
|
||||
(node (gitea/parse-numbered-node (or (get a :object) ""))))
|
||||
(let
|
||||
((raw (cond ((nil? node) (list)) ((= verb "comment") (let ((rec (gitea/issue-get forge (get node :owner) (get node :name) (get node :n)))) (if (nil? rec) (list) (concat (list (get rec :author)) (concat (get rec :assignees) (map (fn (c) (get c :author)) (get rec :comments))))))) ((= verb "open-issue") (let ((rec (gitea/issue-get forge (get node :owner) (get node :name) (get node :n)))) (if (nil? rec) (list) (get rec :assignees)))) ((or (= verb "review") (= verb "merge-pr")) (let ((rec (gitea/pr-get forge (get node :owner) (get node :name) (get node :n)))) (if (nil? rec) (list) (list (get rec :author))))) (else (list)))))
|
||||
(artdag/sort-strings
|
||||
(relations-dedup
|
||||
(filter
|
||||
(fn
|
||||
(u)
|
||||
(and
|
||||
(not (= u (get a :actor)))
|
||||
(gitea/owner-exists? forge u)))
|
||||
raw)))))))
|
||||
|
||||
(define gitea/notify-cursor-key "gitea/notify-cursor")
|
||||
|
||||
(define
|
||||
gitea/notify-body
|
||||
(fn (a) (str (get a :actor) " " (get a :verb) " " (get a :object))))
|
||||
|
||||
; messages for every activity after the cursor: (id recipient body),
|
||||
; id = <pad8 seq>:<recipient> so inbox keys sort chronologically
|
||||
(define
|
||||
gitea/pending-notifications
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((db (gitea/forge-db forge)))
|
||||
(let
|
||||
((cursor (persist/kv-get-or db gitea/notify-cursor-key 0)))
|
||||
(let
|
||||
((events (persist/read-from db gitea/activity-stream-name (+ cursor 1))))
|
||||
{:messages (reduce (fn (acc e) (let ((a (persist/event-data e))) (concat acc (map (fn (u) (list (str (gitea/pad8 (persist/event-seq e)) ":" u) u (gitea/notify-body a))) (gitea/notify-recipients forge a))))) (list) events) :last-seq (reduce (fn (acc e) (persist/event-seq e)) cursor events)})))))
|
||||
|
||||
(define gitea/inbox-key (fn (user id) (str "gitea/inbox/" user "/" id)))
|
||||
|
||||
; deliver pending notifications through the durable digest flow and file
|
||||
; the delivered ones into per-user inboxes; the cursor advance makes a
|
||||
; re-run a no-op. => (("delivered"|"failed" id n-or-reason) ...)
|
||||
(define
|
||||
gitea/notify!
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((p (gitea/pending-notifications forge)))
|
||||
(let
|
||||
((msgs (get p :messages)))
|
||||
(let
|
||||
((by-id (reduce (fn (acc m) (assoc acc (first m) m)) {} msgs)))
|
||||
(let
|
||||
((outcomes (if (empty? msgs) (list) (ev/deliver-messages msgs ev-notify-ok-transport 3 500))))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(o)
|
||||
(if
|
||||
(= (first o) "delivered")
|
||||
(let
|
||||
((m (get by-id (nth o 1))))
|
||||
(if
|
||||
(nil? m)
|
||||
nil
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/inbox-key (nth m 1) (first m))
|
||||
{:id (first m) :body (nth m 2)})))
|
||||
nil))
|
||||
outcomes)
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
gitea/notify-cursor-key
|
||||
(get p :last-seq))
|
||||
outcomes)))))))
|
||||
|
||||
(define
|
||||
gitea/inbox
|
||||
(fn
|
||||
(forge user)
|
||||
(map
|
||||
(fn
|
||||
(id)
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/inbox-key user id)))
|
||||
(gitea/names-under forge (str "gitea/inbox/" user "/")))))
|
||||
|
||||
(define gitea/inbox-count (fn (forge user) (len (gitea/inbox forge user))))
|
||||
|
||||
; ── web ──────────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-act-item
|
||||
(fn
|
||||
(a)
|
||||
(str
|
||||
"<li class=\""
|
||||
(get a :verb)
|
||||
"\">"
|
||||
(dream-escape (get a :actor))
|
||||
" "
|
||||
(get a :verb)
|
||||
" "
|
||||
(dream-escape (or (get a :object) ""))
|
||||
"</li>")))
|
||||
|
||||
(define
|
||||
gitea/w-activity-page
|
||||
(fn
|
||||
(forge req)
|
||||
(gitea/w-page
|
||||
"activity"
|
||||
(str
|
||||
"<h1>Activity</h1><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
gitea/w-act-item
|
||||
(gitea/timeline forge (gitea/w-user forge req) 50)))
|
||||
"</ul>"))))
|
||||
|
||||
(define
|
||||
gitea/w-repo-activity-page
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(gitea/w-page
|
||||
(str owner "/" name " activity")
|
||||
(str
|
||||
"<h1>Activity</h1><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
gitea/w-act-item
|
||||
(gitea/repo-timeline forge owner name 50)))
|
||||
"</ul>"))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-user-activity
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (dream-param req "user")))
|
||||
(if
|
||||
(not (gitea/owner-exists? forge user))
|
||||
(dream-not-found)
|
||||
(dream-json-value
|
||||
(gitea/user-timeline forge (gitea/w-user forge req) user 50))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-dashboard
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(if
|
||||
(nil? user)
|
||||
(gitea/w-unauthorized)
|
||||
(dream-json-value (gitea/dashboard forge user 50))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-follow
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(if
|
||||
(nil? user)
|
||||
(gitea/w-unauthorized)
|
||||
(let
|
||||
((res (gitea/follow! forge user (get (dream-json-body req) :target))))
|
||||
(if
|
||||
(get res :error)
|
||||
(gitea/w-json-status 400 {:error (get res :error)})
|
||||
(dream-json-value (gitea/follows forge user))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-unfollow
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(if
|
||||
(nil? user)
|
||||
(gitea/w-unauthorized)
|
||||
(if
|
||||
(gitea/unfollow! forge user (or (dream-param req "**") ""))
|
||||
(dream-json-value (gitea/follows forge user))
|
||||
(dream-not-found))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-notifications
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(if
|
||||
(nil? user)
|
||||
(gitea/w-unauthorized)
|
||||
(dream-json-value
|
||||
(map (fn (r) (get r :body)) (gitea/inbox forge user)))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-notify-run
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(if (nil? user) (gitea/w-unauthorized) (dream-json-value {:outcomes (len (gitea/notify! forge))})))))
|
||||
|
||||
(define
|
||||
gitea/activity-routes
|
||||
(fn
|
||||
(forge)
|
||||
(list
|
||||
(dream-get "/activity" (fn (req) (gitea/w-activity-page forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/activity"
|
||||
(fn (req) (gitea/w-repo-activity-page forge req)))
|
||||
(dream-get
|
||||
"/api/users/:user/activity"
|
||||
(fn (req) (gitea/w-api-user-activity forge req)))
|
||||
(dream-get
|
||||
"/api/dashboard"
|
||||
(fn (req) (gitea/w-api-dashboard forge req)))
|
||||
(dream-post "/api/follow" (fn (req) (gitea/w-api-follow forge req)))
|
||||
(dream-delete
|
||||
"/api/follow/**"
|
||||
(fn (req) (gitea/w-api-unfollow forge req)))
|
||||
(dream-get
|
||||
"/api/notifications"
|
||||
(fn (req) (gitea/w-api-notifications forge req)))
|
||||
(dream-post
|
||||
"/api/notify/run"
|
||||
(fn (req) (gitea/w-api-notify-run forge req))))))
|
||||
|
||||
(set!
|
||||
gitea/route-packs
|
||||
(append gitea/route-packs (list gitea/activity-routes)))
|
||||
182
lib/gitea/conformance.sh
Normal file
182
lib/gitea/conformance.sh
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/gitea/conformance.sh — run sx-gitea test suites, emit scoreboard.json + scoreboard.md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/gitea/conformance.sh # run all suites
|
||||
# bash lib/gitea/conformance.sh -v # also print failure details
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
|
||||
# suite name | pass counter | fail counter | failures list | extra modules (;-sep)
|
||||
ISSUES_EXTRAS="lib/relations/schema.sx;lib/relations/engine.sx;lib/relations/api.sx;lib/smalltalk/tokenizer.sx;lib/smalltalk/parser.sx;lib/guest/reflective/class-chain.sx;lib/smalltalk/runtime.sx;lib/guest/reflective/env.sx;lib/smalltalk/eval.sx;lib/content/block.sx;lib/content/doc.sx;lib/content/render.sx;lib/content/api.sx;lib/content/meta.sx;lib/content/text.sx;lib/content/section.sx;lib/content/table.sx;lib/content/markdown.sx;lib/content/md-import.sx;lib/gitea/issues.sx"
|
||||
|
||||
PR_EXTRAS="$ISSUES_EXTRAS;lib/guest/lex.sx;lib/guest/reflective/quoting.sx;lib/scheme/parser.sx;lib/scheme/eval.sx;lib/scheme/runtime.sx;lib/flow/spec.sx;lib/flow/store.sx;lib/flow/remote.sx;lib/flow/host.sx;lib/flow/api.sx;lib/gitea/pr.sx"
|
||||
SEARCH_EXTRAS_TAIL="lib/haskell/tokenizer.sx;lib/haskell/layout.sx;lib/haskell/parser.sx;lib/haskell/desugar.sx;lib/haskell/runtime.sx;lib/haskell/match.sx;lib/haskell/eval.sx;lib/haskell/map.sx;lib/haskell/set.sx;lib/haskell/testlib.sx;lib/search/tokenize.sx;lib/search/index.sx;lib/search/query.sx;lib/search/parse.sx;lib/search/rank.sx;lib/search/rankq.sx;lib/search/testlib.sx;lib/gitea/search.sx"
|
||||
ACT_EXTRAS="$PR_EXTRAS;lib/apl/runtime.sx;lib/feed/normalize.sx;lib/feed/stream.sx;lib/feed/api.sx;lib/feed/fanout.sx;lib/feed/dedupe.sx;lib/feed/aggregate.sx;lib/feed/rank.sx;lib/feed/acl.sx;lib/feed/mute.sx;lib/feed/page.sx;lib/feed/notify.sx;lib/feed/home.sx;lib/feed/fed.sx;lib/events/notify.sx;lib/gitea/activity.sx"
|
||||
|
||||
SUITES=(
|
||||
"repo|gitea-repo-pass|gitea-repo-fail|gitea-repo-fails|"
|
||||
"access|gitea-access-pass|gitea-access-fail|gitea-access-fails|"
|
||||
"wire|gitea-wire-pass|gitea-wire-fail|gitea-wire-fails|"
|
||||
"issues|gitea-issues-pass|gitea-issues-fail|gitea-issues-fails|$ISSUES_EXTRAS"
|
||||
"pr|gitea-pr-pass|gitea-pr-fail|gitea-pr-fails|$PR_EXTRAS"
|
||||
"activity|gitea-act-pass|gitea-act-fail|gitea-act-fails|$ACT_EXTRAS"
|
||||
"search|gitea-search-pass|gitea-search-fail|gitea-search-fails|$PR_EXTRAS;$SEARCH_EXTRAS_TAIL"
|
||||
"fed|gitea-fed-pass|gitea-fed-fail|gitea-fed-fails|$ACT_EXTRAS;lib/gitea/fed.sx"
|
||||
)
|
||||
|
||||
OUT_JSON="lib/gitea/scoreboard.json"
|
||||
OUT_MD="lib/gitea/scoreboard.md"
|
||||
|
||||
# Library load order: kernel stdlib, persist, artdag canon, sx-git, dream
|
||||
# (types/router/middleware/error/html/json/api), then the gitea modules.
|
||||
MODULES=(
|
||||
"spec/stdlib.sx"
|
||||
"spec/parser.sx"
|
||||
"lib/r7rs.sx"
|
||||
"lib/persist/event.sx"
|
||||
"lib/persist/backend.sx"
|
||||
"lib/persist/log.sx"
|
||||
"lib/persist/kv.sx"
|
||||
"lib/artdag/dag.sx"
|
||||
"lib/git/object.sx"
|
||||
"lib/git/ref.sx"
|
||||
"lib/git/dag.sx"
|
||||
"lib/git/worktree.sx"
|
||||
"lib/git/diff.sx"
|
||||
"lib/git/merge.sx"
|
||||
"lib/git/porcelain.sx"
|
||||
"lib/datalog/tokenizer.sx"
|
||||
"lib/datalog/parser.sx"
|
||||
"lib/datalog/unify.sx"
|
||||
"lib/datalog/db.sx"
|
||||
"lib/datalog/builtins.sx"
|
||||
"lib/datalog/aggregates.sx"
|
||||
"lib/datalog/strata.sx"
|
||||
"lib/datalog/eval.sx"
|
||||
"lib/datalog/api.sx"
|
||||
"lib/datalog/magic.sx"
|
||||
"lib/acl/schema.sx"
|
||||
"lib/acl/facts.sx"
|
||||
"lib/acl/engine.sx"
|
||||
"lib/dream/types.sx"
|
||||
"lib/dream/router.sx"
|
||||
"lib/dream/middleware.sx"
|
||||
"lib/dream/error.sx"
|
||||
"lib/dream/html.sx"
|
||||
"lib/dream/json.sx"
|
||||
"lib/dream/auth.sx"
|
||||
"lib/dream/api.sx"
|
||||
"lib/gitea/repo.sx"
|
||||
"lib/gitea/access.sx"
|
||||
"lib/gitea/web.sx"
|
||||
"lib/gitea/wire.sx"
|
||||
)
|
||||
|
||||
run_suite() {
|
||||
local suite=$1 passvar=$2 failvar=$3 failsvar=$4 extras=$5
|
||||
local file="lib/gitea/tests/${suite}.sx"
|
||||
local TMP
|
||||
TMP=$(mktemp)
|
||||
{
|
||||
echo "(epoch 1)"
|
||||
for M in "${MODULES[@]}"; do echo "(load \"$M\")"; done
|
||||
if [ -n "$extras" ]; then
|
||||
for M in ${extras//;/ }; do echo "(load \"$M\")"; done
|
||||
fi
|
||||
echo "(epoch 2)"
|
||||
echo "(load \"${file}\")"
|
||||
echo "(epoch 3)"
|
||||
echo "(eval \"(list ${passvar} ${failvar})\")"
|
||||
echo "(epoch 4)"
|
||||
echo "(eval \"(inspect ${failsvar})\")"
|
||||
} > "$TMP"
|
||||
|
||||
local OUTPUT
|
||||
OUTPUT=$(timeout 900 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||
rm -f "$TMP"
|
||||
|
||||
local LINE
|
||||
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 3 / {getline; print; exit}')
|
||||
if [ -z "$LINE" ]; then
|
||||
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 3 \([0-9]+ [0-9]+\)\)' | tail -1 \
|
||||
| sed -E 's/^\(ok 3 //; s/\)$//')
|
||||
fi
|
||||
|
||||
local P F
|
||||
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
|
||||
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
|
||||
P=${P:-0}
|
||||
F=${F:-0}
|
||||
|
||||
if [ -n "$VERBOSE" ] && [ "$F" != "0" ]; then
|
||||
echo " --- ${suite} failures ---" >&2
|
||||
echo "$OUTPUT" | awk '/^\(ok(-len)? 4 /,0' | head -40 >&2
|
||||
fi
|
||||
|
||||
echo "${P} ${F}"
|
||||
}
|
||||
|
||||
declare -A SUITE_PASS
|
||||
declare -A SUITE_FAIL
|
||||
TOTAL_PASS=0
|
||||
TOTAL_FAIL=0
|
||||
|
||||
echo "Running sx-gitea conformance suite..." >&2
|
||||
for entry in "${SUITES[@]}"; do
|
||||
IFS='|' read -r s passvar failvar failsvar extras <<< "$entry"
|
||||
read -r p f < <(run_suite "$s" "$passvar" "$failvar" "$failsvar" "$extras")
|
||||
SUITE_PASS[$s]=$p
|
||||
SUITE_FAIL[$s]=$f
|
||||
TOTAL_PASS=$((TOTAL_PASS + p))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + f))
|
||||
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
|
||||
done
|
||||
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
first=1
|
||||
for entry in "${SUITES[@]}"; do
|
||||
IFS='|' read -r s _ _ _ _ <<< "$entry"
|
||||
if [ $first -eq 0 ]; then printf ',\n'; fi
|
||||
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
|
||||
first=0
|
||||
done
|
||||
printf '\n },\n'
|
||||
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
printf '}\n'
|
||||
} > "$OUT_JSON"
|
||||
|
||||
{
|
||||
printf '# sx-gitea Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/gitea/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for entry in "${SUITES[@]}"; do
|
||||
IFS='|' read -r s _ _ _ _ <<< "$entry"
|
||||
p=${SUITE_PASS[$s]}
|
||||
f=${SUITE_FAIL[$s]}
|
||||
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
} > "$OUT_MD"
|
||||
|
||||
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||
|
||||
[ "$TOTAL_FAIL" -eq 0 ]
|
||||
449
lib/gitea/fed.sx
Normal file
449
lib/gitea/fed.sx
Normal file
@@ -0,0 +1,449 @@
|
||||
; lib/gitea/fed.sx — sx-gitea Phase 8: ForgeFed federation.
|
||||
;
|
||||
; Forges federate as fed-sx-style peers. Each forge carries an instance
|
||||
; id; users and repos project as AP actor documents (Person/Repository
|
||||
; with inbox/outbox); the outbox is the forge's activity log in an
|
||||
; AP-shaped envelope ({:actor "<instance>/user:<u>" :verb :object
|
||||
; :published}).
|
||||
;
|
||||
; Trust is the events-federation pattern: a kv set of peer ids,
|
||||
; RE-CHECKED on every operation, so revoking a peer takes effect
|
||||
; immediately. Peer transports (dream app fns) live in the forge handle's
|
||||
; runtime cache — only the trust set persists.
|
||||
;
|
||||
; The inbox (POST /api/ap/inbox, {:peer :activity}) accepts activities
|
||||
; from trusted peers only. Every accepted activity lands in a federated
|
||||
; activity log with :origin provenance; open-issue/comment/open-pr
|
||||
; activities also MATERIALIZE: the foreign author becomes a proxy user
|
||||
; "<name>@<peer>" (auto-created), and the issue/comment/PR is created
|
||||
; locally under that identity — federated issues and PRs with honest
|
||||
; provenance. fed-deliver! pushes public-repo activities (cursor-based,
|
||||
; never private ones) to every trusted peer's inbox.
|
||||
;
|
||||
; Cross-instance repo follow = mirror!: clone a trusted peer's repo over
|
||||
; the Phase 3 wire client, remember the source, and mirror-sync! to
|
||||
; fast-forward — trust re-checked on every sync.
|
||||
;
|
||||
; Requires: lib/gitea/{repo,access,web,wire,issues,pr,activity}.sx and
|
||||
; their stacks.
|
||||
|
||||
; ── instance identity ────────────────────────────────────────────────
|
||||
|
||||
(define gitea/instance-key "gitea/instance")
|
||||
|
||||
(define
|
||||
gitea/instance!
|
||||
(fn
|
||||
(forge id)
|
||||
(persist/kv-put (gitea/forge-db forge) gitea/instance-key {:id id})))
|
||||
|
||||
(define
|
||||
gitea/instance-id
|
||||
(fn
|
||||
(forge)
|
||||
(get
|
||||
(or
|
||||
(persist/kv-get (gitea/forge-db forge) gitea/instance-key)
|
||||
{})
|
||||
:id)))
|
||||
|
||||
(define
|
||||
gitea/actor-id
|
||||
(fn (forge node) (str (or (gitea/instance-id forge) "local") "/" node)))
|
||||
|
||||
; ── peers + trust ────────────────────────────────────────────────────
|
||||
|
||||
; transports are live functions — runtime registry in the forge cache
|
||||
(define
|
||||
gitea/peers-registry
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((cache (get forge :cache)))
|
||||
(begin
|
||||
(if
|
||||
(and cache (nil? (get cache "peers")))
|
||||
(dict-set! cache "peers" {})
|
||||
nil)
|
||||
(or (get cache "peers") {})))))
|
||||
|
||||
(define
|
||||
gitea/peer-register!
|
||||
(fn
|
||||
(forge id app token)
|
||||
(let
|
||||
((cache (get forge :cache)))
|
||||
(begin
|
||||
(dict-set!
|
||||
cache
|
||||
"peers"
|
||||
(assoc (gitea/peers-registry forge) id {:id id :token token :app app}))
|
||||
id))))
|
||||
|
||||
(define
|
||||
gitea/peer-get
|
||||
(fn (forge id) (get (gitea/peers-registry forge) id)))
|
||||
|
||||
(define gitea/trust-key (fn (id) (str "gitea/trust/" id)))
|
||||
|
||||
(define
|
||||
gitea/trust!
|
||||
(fn
|
||||
(forge id)
|
||||
(persist/kv-put (gitea/forge-db forge) (gitea/trust-key id) {:id id})))
|
||||
|
||||
(define
|
||||
gitea/untrust!
|
||||
(fn
|
||||
(forge id)
|
||||
(persist/kv-delete (gitea/forge-db forge) (gitea/trust-key id))))
|
||||
|
||||
(define
|
||||
gitea/trusted?
|
||||
(fn
|
||||
(forge id)
|
||||
(persist/kv-has? (gitea/forge-db forge) (gitea/trust-key id))))
|
||||
|
||||
(define
|
||||
gitea/trusted-peers
|
||||
(fn
|
||||
(forge)
|
||||
(filter
|
||||
(fn (id) (gitea/trusted? forge id))
|
||||
(artdag/sort-strings (keys (gitea/peers-registry forge))))))
|
||||
|
||||
; ── AP actor documents ───────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/ap-user
|
||||
(fn
|
||||
(forge user)
|
||||
(let
|
||||
((rec (gitea/owner-get forge user)))
|
||||
(if (nil? rec) nil {:id (gitea/actor-id forge (str "user:" user)) :type (if (gitea/org? rec) "Group" "Person") :outbox (str "/api/ap/users/" user "/outbox") :preferredUsername user :inbox "/api/ap/inbox"}))))
|
||||
|
||||
(define
|
||||
gitea/ap-repo
|
||||
(fn
|
||||
(forge owner name)
|
||||
(let
|
||||
((rec (gitea/repo-get forge owner name)))
|
||||
(if (nil? rec) nil {:clone (str "/" owner "/" name "/info/refs") :name (str owner "/" name) :id (gitea/actor-id forge (str "repo:" owner "/" name)) :attributedTo (gitea/actor-id forge (str "user:" owner)) :type "Repository" :summary (get rec :description) :inbox "/api/ap/inbox"}))))
|
||||
|
||||
; AP-shaped envelope for a feed activity
|
||||
(define gitea/ap-activity (fn (forge a) {:published (get a :at) :actor (gitea/actor-id forge (str "user:" (get a :actor))) :object (get a :object) :verb (get a :verb)}))
|
||||
|
||||
(define
|
||||
gitea/ap-outbox
|
||||
(fn
|
||||
(forge user n)
|
||||
(map
|
||||
(fn (a) (gitea/ap-activity forge a))
|
||||
(gitea/user-timeline forge nil user n))))
|
||||
|
||||
; ── federated activity log (inbound, with provenance) ────────────────
|
||||
|
||||
(define gitea/fed-stream-name "gitea/fed-activity")
|
||||
|
||||
(define
|
||||
gitea/fed-log
|
||||
(fn
|
||||
(forge)
|
||||
(map
|
||||
persist/event-data
|
||||
(persist/read (gitea/forge-db forge) gitea/fed-stream-name))))
|
||||
|
||||
(define
|
||||
gitea/fed-log-append!
|
||||
(fn
|
||||
(forge origin a)
|
||||
(persist/append
|
||||
(gitea/forge-db forge)
|
||||
gitea/fed-stream-name
|
||||
(or (get a :verb) "activity")
|
||||
(or (get a :at) 0)
|
||||
{:activity a :origin origin})))
|
||||
|
||||
; local + foreign activities, newest first, foreign tagged :origin
|
||||
(define
|
||||
gitea/fed-timeline
|
||||
(fn
|
||||
(forge user n)
|
||||
(feed/items
|
||||
(feed/take
|
||||
(feed/recent
|
||||
(feed/stream
|
||||
(concat
|
||||
(feed/items
|
||||
(feed/filter
|
||||
(gitea/activity-stream forge)
|
||||
(fn (a) (gitea/act-visible? forge user a))))
|
||||
(map
|
||||
(fn (e) (assoc (get e :activity) :origin (get e :origin)))
|
||||
(gitea/fed-log forge)))))
|
||||
n))))
|
||||
|
||||
; ── inbound materialization ──────────────────────────────────────────
|
||||
|
||||
; foreign authors become local proxy users "<name>@<peer>"
|
||||
(define
|
||||
gitea/fed-user!
|
||||
(fn
|
||||
(forge name peer)
|
||||
(let
|
||||
((proxy (str name "@" peer)))
|
||||
(begin
|
||||
(if
|
||||
(gitea/owner-exists? forge proxy)
|
||||
nil
|
||||
(gitea/user-create! forge proxy))
|
||||
proxy))))
|
||||
|
||||
; a foreign activity's :actor may be "<instance>/user:<name>" or a bare
|
||||
; name — reduce it to the name
|
||||
(define
|
||||
gitea/fed-actor-name
|
||||
(fn
|
||||
(actor)
|
||||
(let
|
||||
((i (index-of (or actor "") "user:")))
|
||||
(if (< i 0) (or actor "") (substr actor (+ i 5))))))
|
||||
|
||||
; apply one trusted activity: log it, and materialize the verbs a forge
|
||||
; can host locally. => {:accepted true ...} | {:error ...}
|
||||
(define
|
||||
gitea/fed-receive!
|
||||
(fn
|
||||
(forge peer a)
|
||||
(if
|
||||
(not (gitea/trusted? forge peer))
|
||||
{:error "untrusted-peer"}
|
||||
(let
|
||||
((verb (get a :verb))
|
||||
(actor (gitea/fed-actor-name (get a :actor)))
|
||||
(node (gitea/parse-numbered-node (or (get a :object) "")))
|
||||
(detail (or (get a :detail) {})))
|
||||
(begin
|
||||
(gitea/fed-log-append! forge peer a)
|
||||
(cond
|
||||
((= verb "open-issue")
|
||||
(let
|
||||
((rp (gitea/split-full (substr (or (gitea/act-repo a) "repo:/") 5))))
|
||||
(let
|
||||
((res (gitea/issue-create! forge (get rp :owner) (get rp :name) (gitea/fed-user! forge actor peer) (or (get detail :title) "(federated issue)") (or (get detail :body) "") {:created-at (or (get a :at) 0)})))
|
||||
(if (get res :error) res {:materialized "issue" :accepted true :number (get res :number)}))))
|
||||
((= verb "comment")
|
||||
(if
|
||||
(nil? node)
|
||||
{:error "bad-object"}
|
||||
(let
|
||||
((res (gitea/issue-comment! forge (get node :owner) (get node :name) (get node :n) (gitea/fed-user! forge actor peer) (or (get detail :body) "") {:at (or (get a :at) 0)})))
|
||||
(if (get res :error) res {:materialized "comment" :accepted true}))))
|
||||
((= verb "open-pr")
|
||||
(let
|
||||
((rp (gitea/split-full (substr (or (gitea/act-repo a) "repo:/") 5))))
|
||||
(let
|
||||
((res (gitea/pr-create! forge (get rp :owner) (get rp :name) (gitea/fed-user! forge actor peer) (or (get detail :title) "(federated pr)") (get detail :source) (get detail :target) (or (get detail :body) "") {:created-at (or (get a :at) 0)})))
|
||||
(if (get res :error) res {:materialized "pr" :accepted true :number (get res :number)}))))
|
||||
(else {:materialized "none" :accepted true})))))))
|
||||
|
||||
; ── outbound delivery ────────────────────────────────────────────────
|
||||
|
||||
(define gitea/fed-cursor-key "gitea/fed-cursor")
|
||||
|
||||
; push public-repo activities after the cursor to every trusted peer's
|
||||
; inbox. => {:delivered n :peers (ids)}
|
||||
(define
|
||||
gitea/fed-deliver!
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((db (gitea/forge-db forge)))
|
||||
(let
|
||||
((cursor (persist/kv-get-or db gitea/fed-cursor-key 0))
|
||||
(peers (gitea/trusted-peers forge))
|
||||
(me (or (gitea/instance-id forge) "local")))
|
||||
(let
|
||||
((events (persist/read-from db gitea/activity-stream-name (+ cursor 1))))
|
||||
(let
|
||||
((public (filter (fn (e) (gitea/act-visible? forge nil (persist/event-data e))) events)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(e)
|
||||
(for-each
|
||||
(fn
|
||||
(pid)
|
||||
(let
|
||||
((peer (gitea/peer-get forge pid)))
|
||||
(if
|
||||
(nil? peer)
|
||||
nil
|
||||
((get peer :app)
|
||||
(dream-request
|
||||
"POST"
|
||||
"/api/ap/inbox"
|
||||
(if
|
||||
(nil? (get peer :token))
|
||||
{}
|
||||
{:authorization (str "Bearer " (get peer :token))})
|
||||
(dream-json-encode {:activity (persist/event-data e) :peer me}))))))
|
||||
peers))
|
||||
public)
|
||||
(if
|
||||
(empty? events)
|
||||
nil
|
||||
(persist/kv-put
|
||||
db
|
||||
gitea/fed-cursor-key
|
||||
(reduce (fn (acc e) (persist/event-seq e)) cursor events)))
|
||||
{:delivered (len public) :peers peers})))))))
|
||||
|
||||
; ── cross-instance repo follow (mirrors) ─────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/mirror-key
|
||||
(fn (owner name) (str "gitea/mirror/" owner "/" name)))
|
||||
|
||||
(define
|
||||
gitea/peer-remote
|
||||
(fn
|
||||
(forge peer-id owner name)
|
||||
(let
|
||||
((peer (gitea/peer-get forge peer-id)))
|
||||
(if
|
||||
(nil? peer)
|
||||
nil
|
||||
(gitea/remote (get peer :app) owner name (get peer :token))))))
|
||||
|
||||
; clone a trusted peer's repo as owner/name and remember the source
|
||||
(define
|
||||
gitea/mirror!
|
||||
(fn
|
||||
(forge owner name peer-id remote-owner remote-name)
|
||||
(cond
|
||||
((not (gitea/trusted? forge peer-id)) {:error "untrusted-peer"})
|
||||
((nil? (gitea/peer-get forge peer-id)) {:error "no-such-peer"})
|
||||
(else
|
||||
(let
|
||||
((remote (gitea/peer-remote forge peer-id remote-owner remote-name)))
|
||||
(let
|
||||
((res (gitea/clone! forge owner name remote {})))
|
||||
(if
|
||||
(or (get res :error) (get res :conflict))
|
||||
res
|
||||
(begin
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/mirror-key owner name)
|
||||
{:remote-owner remote-owner :peer peer-id :remote-name remote-name})
|
||||
res))))))))
|
||||
|
||||
(define
|
||||
gitea/mirror-of
|
||||
(fn
|
||||
(forge owner name)
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/mirror-key owner name))))
|
||||
|
||||
(define
|
||||
gitea/mirrors
|
||||
(fn (forge) (gitea/names-under forge "gitea/mirror/")))
|
||||
|
||||
; re-fetch from the mirror source; trust is re-checked every sync
|
||||
(define
|
||||
gitea/mirror-sync!
|
||||
(fn
|
||||
(forge owner name)
|
||||
(let
|
||||
((m (gitea/mirror-of forge owner name)))
|
||||
(cond
|
||||
((nil? m) {:error "not-a-mirror"})
|
||||
((not (gitea/trusted? forge (get m :peer))) {:error "untrusted-peer"})
|
||||
((nil? (gitea/peer-get forge (get m :peer))) {:error "no-such-peer"})
|
||||
(else
|
||||
(gitea/fetch!
|
||||
(gitea/peer-remote
|
||||
forge
|
||||
(get m :peer)
|
||||
(get m :remote-owner)
|
||||
(get m :remote-name))
|
||||
(gitea/repo-git forge owner name)))))))
|
||||
|
||||
; ── web ──────────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-ap-user
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((doc (gitea/ap-user forge (dream-param req "user"))))
|
||||
(if (nil? doc) (dream-not-found) (dream-json-value doc)))))
|
||||
|
||||
(define
|
||||
gitea/w-ap-repo
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(dream-json-value (gitea/ap-repo forge owner name))))))
|
||||
|
||||
(define
|
||||
gitea/w-ap-outbox
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (dream-param req "user")))
|
||||
(if
|
||||
(not (gitea/owner-exists? forge user))
|
||||
(dream-not-found)
|
||||
(dream-json-value (gitea/ap-outbox forge user 50))))))
|
||||
|
||||
(define
|
||||
gitea/w-ap-inbox
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((body (dream-json-body req)))
|
||||
(let
|
||||
((peer (get body :peer)))
|
||||
(cond
|
||||
((nil? peer) (gitea/w-json-status 400 {:error "missing-peer"}))
|
||||
((not (gitea/trusted? forge peer)) (gitea/w-forbidden))
|
||||
(else
|
||||
(let
|
||||
((res (gitea/fed-receive! forge peer (or (get body :activity) {}))))
|
||||
(if
|
||||
(get res :error)
|
||||
(gitea/w-json-status 400 {:error (get res :error)})
|
||||
(dream-json-value res)))))))))
|
||||
|
||||
(define
|
||||
gitea/w-fed-timeline
|
||||
(fn
|
||||
(forge req)
|
||||
(dream-json-value
|
||||
(gitea/fed-timeline forge (gitea/w-user forge req) 50))))
|
||||
|
||||
(define
|
||||
gitea/fed-routes
|
||||
(fn
|
||||
(forge)
|
||||
(list
|
||||
(dream-get
|
||||
"/api/ap/users/:user"
|
||||
(fn (req) (gitea/w-ap-user forge req)))
|
||||
(dream-get
|
||||
"/api/ap/users/:user/outbox"
|
||||
(fn (req) (gitea/w-ap-outbox forge req)))
|
||||
(dream-get
|
||||
"/api/ap/repos/:owner/:name"
|
||||
(fn (req) (gitea/w-ap-repo forge req)))
|
||||
(dream-post "/api/ap/inbox" (fn (req) (gitea/w-ap-inbox forge req)))
|
||||
(dream-get
|
||||
"/api/fed/timeline"
|
||||
(fn (req) (gitea/w-fed-timeline forge req))))))
|
||||
|
||||
(set! gitea/route-packs (append gitea/route-packs (list gitea/fed-routes)))
|
||||
770
lib/gitea/issues.sx
Normal file
770
lib/gitea/issues.sx
Normal file
@@ -0,0 +1,770 @@
|
||||
; lib/gitea/issues.sx — sx-gitea Phase 4: issues.
|
||||
;
|
||||
; An issue is a kv record: number, title, author, state, label/assignee
|
||||
; sets, a Markdown body, and a comment thread (each comment is Markdown
|
||||
; too). The Markdown is the stored source of truth; lib/content turns it
|
||||
; into a block document (content/from-markdown) and renders HTML
|
||||
; (content/html) — content-on-sx's round-trip law keeps the two views of
|
||||
; the same body honest.
|
||||
;
|
||||
; The issue graph (issue->repo, author, assignees, labels, commenters) is
|
||||
; DERIVED from the records into lib/relations facts — like the acl db in
|
||||
; access.sx, the relations db is rebuilt (cached on fact equality) rather
|
||||
; than maintained incrementally, so deleting a repo can never leave
|
||||
; dangling edges.
|
||||
;
|
||||
; Requires: lib/gitea/{repo,access,web}.sx and their stacks, plus
|
||||
; lib/relations/{schema,engine,api}.sx and the content stack
|
||||
; (lib/smalltalk/* + lib/content/{block,doc,render,api,meta,text,section,
|
||||
; table,markdown,md-import}.sx) with content bootstrapped:
|
||||
; (st-bootstrap-classes!) (content/bootstrap!)
|
||||
; (content-bootstrap-markdown!) (content-bootstrap-table!)
|
||||
|
||||
; ── numbering / keys ─────────────────────────────────────────────────
|
||||
; keys carry zero-padded numbers so lexicographic kv order = issue order
|
||||
|
||||
(define
|
||||
gitea/pad8
|
||||
(fn
|
||||
(n)
|
||||
(let
|
||||
((s (str n)))
|
||||
(str
|
||||
(substr "00000000" 0 (- 8 (string-length s)))
|
||||
s))))
|
||||
|
||||
(define
|
||||
gitea/digits-loop
|
||||
(fn
|
||||
(s i)
|
||||
(if
|
||||
(>= i (string-length s))
|
||||
true
|
||||
(let
|
||||
((c (char-code (char-at s i))))
|
||||
(if
|
||||
(and (>= c 48) (<= c 57))
|
||||
(gitea/digits-loop s (+ i 1))
|
||||
false)))))
|
||||
|
||||
(define
|
||||
gitea/digits?
|
||||
(fn
|
||||
(s)
|
||||
(and
|
||||
(string? s)
|
||||
(> (string-length s) 0)
|
||||
(gitea/digits-loop s 0))))
|
||||
|
||||
(define
|
||||
gitea/issue-key
|
||||
(fn (owner name n) (str "gitea/issue/" owner "/" name "/" (gitea/pad8 n))))
|
||||
|
||||
(define
|
||||
gitea/issue-seq-key
|
||||
(fn (owner name) (str "gitea/issue-seq/" owner "/" name)))
|
||||
|
||||
(define
|
||||
gitea/issue-next!
|
||||
(fn
|
||||
(forge owner name)
|
||||
(let
|
||||
((k (gitea/issue-seq-key owner name)))
|
||||
(let
|
||||
((n (+ 1 (or (persist/kv-get (gitea/forge-db forge) k) 0))))
|
||||
(begin (persist/kv-put (gitea/forge-db forge) k n) n)))))
|
||||
|
||||
; ── sorted-set helpers ───────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/set-add
|
||||
(fn
|
||||
(xs x)
|
||||
(artdag/sort-strings (cons x (filter (fn (e) (not (= e x))) xs)))))
|
||||
|
||||
(define gitea/set-remove (fn (xs x) (filter (fn (e) (not (= e x))) xs)))
|
||||
|
||||
; ── issue CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/issue-create!
|
||||
(fn
|
||||
(forge owner name author title body opts)
|
||||
(cond
|
||||
((not (gitea/repo-exists? forge owner name)) {:error "no-such-repo"})
|
||||
((not (gitea/owner-exists? forge author)) {:error "no-such-user"})
|
||||
((or (not (string? title)) (= title "")) {:error "empty-title"})
|
||||
(else
|
||||
(let
|
||||
((o (or opts {})))
|
||||
(let
|
||||
((n (gitea/issue-next! forge owner name)))
|
||||
(let
|
||||
((rec {:state "open" :comments (list) :title title :body (or body "") :number n :author author :created-at (or (get o :created-at) 0) :assignees (artdag/sort-strings (or (get o :assignees) (list))) :labels (artdag/sort-strings (or (get o :labels) (list)))}))
|
||||
(begin
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/issue-key owner name n)
|
||||
rec)
|
||||
rec))))))))
|
||||
|
||||
(define
|
||||
gitea/issue-get
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/issue-key owner name n))))
|
||||
|
||||
(define
|
||||
gitea/issues
|
||||
(fn
|
||||
(forge owner name)
|
||||
(map
|
||||
(fn (s) (parse-int s))
|
||||
(gitea/names-under forge (str "gitea/issue/" owner "/" name "/")))))
|
||||
|
||||
(define
|
||||
gitea/issue-records
|
||||
(fn
|
||||
(forge owner name)
|
||||
(map
|
||||
(fn (n) (gitea/issue-get forge owner name n))
|
||||
(gitea/issues forge owner name))))
|
||||
|
||||
(define
|
||||
gitea/issue-update!
|
||||
(fn
|
||||
(forge owner name n f)
|
||||
(let
|
||||
((rec (gitea/issue-get forge owner name n)))
|
||||
(if
|
||||
(nil? rec)
|
||||
nil
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/issue-key owner name n)
|
||||
(f rec))))))
|
||||
|
||||
(define
|
||||
gitea/issue-close!
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn (r) (assoc r :state "closed")))))
|
||||
|
||||
(define
|
||||
gitea/issue-reopen!
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn (r) (assoc r :state "open")))))
|
||||
|
||||
(define
|
||||
gitea/issue-comment!
|
||||
(fn
|
||||
(forge owner name n author body opts)
|
||||
(cond
|
||||
((not (gitea/owner-exists? forge author)) {:error "no-such-user"})
|
||||
((nil? (gitea/issue-get forge owner name n)) {:error "no-such-issue"})
|
||||
(else
|
||||
(let
|
||||
((comment {:body (or body "") :at (or (get (or opts {}) :at) 0) :author author}))
|
||||
(begin
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn
|
||||
(r)
|
||||
(assoc
|
||||
r
|
||||
:comments (append (get r :comments) (list comment)))))
|
||||
comment))))))
|
||||
|
||||
; ── labels / assignees ───────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/issue-label!
|
||||
(fn
|
||||
(forge owner name n label)
|
||||
(if
|
||||
(or (not (string? label)) (= label ""))
|
||||
{:error "invalid-label"}
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn (r) (assoc r :labels (gitea/set-add (get r :labels) label)))))))
|
||||
|
||||
(define
|
||||
gitea/issue-unlabel!
|
||||
(fn
|
||||
(forge owner name n label)
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn (r) (assoc r :labels (gitea/set-remove (get r :labels) label))))))
|
||||
|
||||
(define
|
||||
gitea/issue-assign!
|
||||
(fn
|
||||
(forge owner name n user)
|
||||
(if
|
||||
(not (gitea/owner-exists? forge user))
|
||||
{:error "no-such-user"}
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn
|
||||
(r)
|
||||
(assoc r :assignees (gitea/set-add (get r :assignees) user)))))))
|
||||
|
||||
(define
|
||||
gitea/issue-unassign!
|
||||
(fn
|
||||
(forge owner name n user)
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn
|
||||
(r)
|
||||
(assoc r :assignees (gitea/set-remove (get r :assignees) user))))))
|
||||
|
||||
; ── views over the records ───────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/issues-open
|
||||
(fn
|
||||
(forge owner name)
|
||||
(filter
|
||||
(fn (r) (= (get r :state) "open"))
|
||||
(gitea/issue-records forge owner name))))
|
||||
|
||||
(define
|
||||
gitea/issues-closed
|
||||
(fn
|
||||
(forge owner name)
|
||||
(filter
|
||||
(fn (r) (= (get r :state) "closed"))
|
||||
(gitea/issue-records forge owner name))))
|
||||
|
||||
(define
|
||||
gitea/issues-with-label
|
||||
(fn
|
||||
(forge owner name label)
|
||||
(filter
|
||||
(fn (r) (contains? (get r :labels) label))
|
||||
(gitea/issue-records forge owner name))))
|
||||
|
||||
(define
|
||||
gitea/issues-assigned
|
||||
(fn
|
||||
(forge owner name user)
|
||||
(filter
|
||||
(fn (r) (contains? (get r :assignees) user))
|
||||
(gitea/issue-records forge owner name))))
|
||||
|
||||
; ── content documents ────────────────────────────────────────────────
|
||||
|
||||
(define gitea/md-doc (fn (md id) (content/from-markdown (or md "") id)))
|
||||
|
||||
(define gitea/md-html (fn (md id) (content/html (gitea/md-doc md id))))
|
||||
|
||||
(define
|
||||
gitea/issue-doc
|
||||
(fn
|
||||
(owner name issue)
|
||||
(gitea/md-doc
|
||||
(get issue :body)
|
||||
(str "issue-" owner "-" name "-" (get issue :number)))))
|
||||
|
||||
(define
|
||||
gitea/issue-html
|
||||
(fn (owner name issue) (content/html (gitea/issue-doc owner name issue))))
|
||||
|
||||
; ── relations graph (derived) ────────────────────────────────────────
|
||||
|
||||
(define gitea/user-node (fn (user) (str "user:" user)))
|
||||
(define gitea/repo-node (fn (owner name) (str "repo:" owner "/" name)))
|
||||
(define
|
||||
gitea/issue-node
|
||||
(fn (owner name n) (str "issue:" owner "/" name "#" n)))
|
||||
(define
|
||||
gitea/label-node
|
||||
(fn (owner name label) (str "label:" owner "/" name "/" label)))
|
||||
|
||||
(define
|
||||
gitea/rel-facts
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((facts (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(full)
|
||||
(let
|
||||
((p (gitea/split-full full)))
|
||||
(let
|
||||
((owner (get p :owner)) (name (get p :name)))
|
||||
(for-each
|
||||
(fn
|
||||
(rec)
|
||||
(let
|
||||
((inode (gitea/issue-node owner name (get rec :number))))
|
||||
(begin
|
||||
(append!
|
||||
facts
|
||||
(relations-rel
|
||||
inode
|
||||
(gitea/repo-node owner name)
|
||||
(quote parent)))
|
||||
(append!
|
||||
facts
|
||||
(relations-rel
|
||||
inode
|
||||
(gitea/user-node (get rec :author))
|
||||
(quote origin)))
|
||||
(for-each
|
||||
(fn
|
||||
(a)
|
||||
(append!
|
||||
facts
|
||||
(relations-rel
|
||||
(gitea/user-node a)
|
||||
inode
|
||||
(quote member))))
|
||||
(get rec :assignees))
|
||||
(for-each
|
||||
(fn
|
||||
(l)
|
||||
(append!
|
||||
facts
|
||||
(relations-rel
|
||||
inode
|
||||
(gitea/label-node owner name l)
|
||||
(quote link))))
|
||||
(get rec :labels))
|
||||
(for-each
|
||||
(fn
|
||||
(c)
|
||||
(append!
|
||||
facts
|
||||
(relations-rel
|
||||
(gitea/user-node (get c :author))
|
||||
inode
|
||||
(quote reply))))
|
||||
(get rec :comments)))))
|
||||
(gitea/issue-records forge owner name)))))
|
||||
(gitea/repos forge))
|
||||
facts))))
|
||||
|
||||
; rebuild only when the derived facts changed (cache in the forge handle)
|
||||
(define
|
||||
gitea/rels-db
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((facts (gitea/rel-facts forge)) (cache (get forge :cache)))
|
||||
(if
|
||||
(and cache (= (get cache "rel-facts") facts) (get cache "rels-db"))
|
||||
(get cache "rels-db")
|
||||
(let
|
||||
((db (relations-build-db facts)))
|
||||
(begin
|
||||
(if
|
||||
cache
|
||||
(begin
|
||||
(dict-set! cache "rel-facts" facts)
|
||||
(dict-set! cache "rels-db" db))
|
||||
nil)
|
||||
db))))))
|
||||
|
||||
; issues of a repo, by graph (sorted issue node ids)
|
||||
(define
|
||||
gitea/repo-issue-nodes
|
||||
(fn
|
||||
(forge owner name)
|
||||
(artdag/sort-strings
|
||||
(relations-parents-of
|
||||
(gitea/rels-db forge)
|
||||
(gitea/repo-node owner name)
|
||||
(quote parent)))))
|
||||
|
||||
; issues a user authored
|
||||
(define
|
||||
gitea/user-authored
|
||||
(fn
|
||||
(forge user)
|
||||
(artdag/sort-strings
|
||||
(relations-parents-of
|
||||
(gitea/rels-db forge)
|
||||
(gitea/user-node user)
|
||||
(quote origin)))))
|
||||
|
||||
; issues assigned to a user
|
||||
(define
|
||||
gitea/user-assigned
|
||||
(fn
|
||||
(forge user)
|
||||
(artdag/sort-strings
|
||||
(relations-children-of
|
||||
(gitea/rels-db forge)
|
||||
(gitea/user-node user)
|
||||
(quote member)))))
|
||||
|
||||
; issues carrying a label
|
||||
(define
|
||||
gitea/label-issues
|
||||
(fn
|
||||
(forge owner name label)
|
||||
(artdag/sort-strings
|
||||
(relations-parents-of
|
||||
(gitea/rels-db forge)
|
||||
(gitea/label-node owner name label)
|
||||
(quote link)))))
|
||||
|
||||
; everyone touching an issue: author + assignees + commenters
|
||||
(define
|
||||
gitea/issue-participants
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((inode (gitea/issue-node owner name n)) (db (gitea/rels-db forge)))
|
||||
(artdag/sort-strings
|
||||
(relations-dedup
|
||||
(concat
|
||||
(relations-children-of db inode (quote origin))
|
||||
(concat
|
||||
(relations-parents-of db inode (quote member))
|
||||
(relations-parents-of db inode (quote reply)))))))))
|
||||
|
||||
; ── web ──────────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-issue-n
|
||||
(fn
|
||||
(req)
|
||||
(let
|
||||
((s (dream-param req "n")))
|
||||
(if (gitea/digits? s) (parse-int s) nil))))
|
||||
|
||||
(define
|
||||
gitea/w-issue-item
|
||||
(fn
|
||||
(owner name r)
|
||||
(str
|
||||
"<li class=\""
|
||||
(get r :state)
|
||||
"\"><a href=\"/"
|
||||
owner
|
||||
"/"
|
||||
name
|
||||
"/issues/"
|
||||
(get r :number)
|
||||
"\">#"
|
||||
(get r :number)
|
||||
" "
|
||||
(dream-escape (get r :title))
|
||||
"</a> ["
|
||||
(get r :state)
|
||||
"]</li>")))
|
||||
|
||||
(define
|
||||
gitea/w-issues-page
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(gitea/w-page
|
||||
(str owner "/" name " issues")
|
||||
(str
|
||||
"<h1>Issues</h1><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn (r) (gitea/w-issue-item owner name r))
|
||||
(gitea/issue-records forge owner name)))
|
||||
"</ul>"))))))
|
||||
|
||||
(define
|
||||
gitea/w-comment-html
|
||||
(fn
|
||||
(owner name n i c)
|
||||
(str
|
||||
"<div class=\"comment\"><p class=\"author\">"
|
||||
(dream-escape (get c :author))
|
||||
"</p>"
|
||||
(gitea/md-html (get c :body) (str "c-" owner "-" name "-" n "-" i))
|
||||
"</div>")))
|
||||
|
||||
(define
|
||||
gitea/w-issue-page
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(if
|
||||
(or (nil? n) (not (gitea/w-readable? forge req owner name)))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((rec (gitea/issue-get forge owner name n)))
|
||||
(if
|
||||
(nil? rec)
|
||||
(dream-not-found)
|
||||
(gitea/w-page
|
||||
(str "#" n " " (get rec :title))
|
||||
(str
|
||||
"<h1>#"
|
||||
n
|
||||
" "
|
||||
(dream-escape (get rec :title))
|
||||
"</h1>"
|
||||
"<p class=\"state\">"
|
||||
(get rec :state)
|
||||
"</p>"
|
||||
"<p class=\"author\">"
|
||||
(dream-escape (get rec :author))
|
||||
"</p>"
|
||||
"<p class=\"labels\">"
|
||||
(dream-escape (join ", " (get rec :labels)))
|
||||
"</p>"
|
||||
"<p class=\"assignees\">"
|
||||
(dream-escape (join ", " (get rec :assignees)))
|
||||
"</p>"
|
||||
"<div class=\"body\">"
|
||||
(gitea/issue-html owner name rec)
|
||||
"</div>"
|
||||
"<h2>Comments</h2>"
|
||||
(join
|
||||
""
|
||||
(map-indexed
|
||||
(fn (i c) (gitea/w-comment-html owner name n i c))
|
||||
(get rec :comments)))))))))))
|
||||
|
||||
; ── json api ─────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-api-issues
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(dream-json-value
|
||||
(map (fn (r) {:state (get r :state) :title (get r :title) :number (get r :number)}) (gitea/issue-records forge owner name)))))))
|
||||
|
||||
; any authenticated reader may open an issue
|
||||
(define
|
||||
gitea/w-api-issue-create
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((not (gitea/can? forge user "read" owner name))
|
||||
(if
|
||||
(nil? user)
|
||||
(if
|
||||
(gitea/repo-exists? forge owner name)
|
||||
(if
|
||||
(equal?
|
||||
(get (gitea/repo-get forge owner name) :visibility)
|
||||
"public")
|
||||
(gitea/w-unauthorized)
|
||||
(dream-not-found))
|
||||
(dream-not-found))
|
||||
(dream-not-found)))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
(else
|
||||
(let
|
||||
((body (dream-json-body req)))
|
||||
(let
|
||||
((res (gitea/issue-create! forge owner name user (get body :title) (or (get body :body) "") {:created-at (or (get body :created-at) 0) :assignees (or (get body :assignees) (list)) :labels (or (get body :labels) (list))})))
|
||||
(if
|
||||
(get res :error)
|
||||
(gitea/w-json-status 400 {:error (get res :error)})
|
||||
(gitea/w-json-status 201 {:title (get res :title) :number (get res :number)}))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-issue-comment
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((or (nil? n) (not (gitea/can? forge user "read" owner name)))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
(else
|
||||
(let
|
||||
((body (dream-json-body req)))
|
||||
(let
|
||||
((res (gitea/issue-comment! forge owner name n user (or (get body :body) "") {:at (or (get body :created-at) 0)})))
|
||||
(if
|
||||
(get res :error)
|
||||
(gitea/w-json-status 404 {:error (get res :error)})
|
||||
(dream-json-value {:author user}))))))))))
|
||||
|
||||
; the author or anyone with write may close/reopen
|
||||
(define
|
||||
gitea/issue-can-close?
|
||||
(fn
|
||||
(forge user owner name rec)
|
||||
(or
|
||||
(= user (get rec :author))
|
||||
(gitea/can? forge user "write" owner name))))
|
||||
|
||||
(define
|
||||
gitea/w-api-issue-state
|
||||
(fn
|
||||
(forge req state)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((or (nil? n) (not (gitea/can? forge user "read" owner name)))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
(else
|
||||
(let
|
||||
((rec (gitea/issue-get forge owner name n)))
|
||||
(cond
|
||||
((nil? rec) (dream-not-found))
|
||||
((not (gitea/issue-can-close? forge user owner name rec))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(begin
|
||||
(gitea/issue-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn (r) (assoc r :state state)))
|
||||
(dream-json-value {:state state :number n})))))))))))
|
||||
|
||||
; label + assignee management requires write
|
||||
(define
|
||||
gitea/w-api-issue-edit
|
||||
(fn
|
||||
(forge req f)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((or (nil? n) (not (gitea/can? forge user "read" owner name)))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "write" owner name))
|
||||
(gitea/w-forbidden))
|
||||
((nil? (gitea/issue-get forge owner name n)) (dream-not-found))
|
||||
(else
|
||||
(let
|
||||
((res (f owner name n)))
|
||||
(if
|
||||
(and (dict? res) (get res :error))
|
||||
(gitea/w-json-status 400 {:error (get res :error)})
|
||||
(dream-json-value {:number n})))))))))
|
||||
|
||||
(define
|
||||
gitea/issue-routes
|
||||
(fn
|
||||
(forge)
|
||||
(list
|
||||
(dream-get
|
||||
"/:owner/:name/issues"
|
||||
(fn (req) (gitea/w-issues-page forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/issues/:n"
|
||||
(fn (req) (gitea/w-issue-page forge req)))
|
||||
(dream-get
|
||||
"/api/repos/:owner/:name/issues"
|
||||
(fn (req) (gitea/w-api-issues forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/issues"
|
||||
(fn (req) (gitea/w-api-issue-create forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/issues/:n/comments"
|
||||
(fn (req) (gitea/w-api-issue-comment forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/issues/:n/close"
|
||||
(fn (req) (gitea/w-api-issue-state forge req "closed")))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/issues/:n/reopen"
|
||||
(fn (req) (gitea/w-api-issue-state forge req "open")))
|
||||
(dream-put
|
||||
"/api/repos/:owner/:name/issues/:n/labels/:label"
|
||||
(fn
|
||||
(req)
|
||||
(gitea/w-api-issue-edit
|
||||
forge
|
||||
req
|
||||
(fn
|
||||
(o r n)
|
||||
(gitea/issue-label! forge o r n (dream-param req "label"))))))
|
||||
(dream-delete
|
||||
"/api/repos/:owner/:name/issues/:n/labels/:label"
|
||||
(fn
|
||||
(req)
|
||||
(gitea/w-api-issue-edit
|
||||
forge
|
||||
req
|
||||
(fn
|
||||
(o r n)
|
||||
(gitea/issue-unlabel! forge o r n (dream-param req "label"))))))
|
||||
(dream-put
|
||||
"/api/repos/:owner/:name/issues/:n/assignees/:user"
|
||||
(fn
|
||||
(req)
|
||||
(gitea/w-api-issue-edit
|
||||
forge
|
||||
req
|
||||
(fn
|
||||
(o r n)
|
||||
(gitea/issue-assign! forge o r n (dream-param req "user"))))))
|
||||
(dream-delete
|
||||
"/api/repos/:owner/:name/issues/:n/assignees/:user"
|
||||
(fn
|
||||
(req)
|
||||
(gitea/w-api-issue-edit
|
||||
forge
|
||||
req
|
||||
(fn
|
||||
(o r n)
|
||||
(gitea/issue-unassign! forge o r n (dream-param req "user")))))))))
|
||||
|
||||
(set! gitea/route-packs (append gitea/route-packs (list gitea/issue-routes)))
|
||||
808
lib/gitea/pr.sx
Normal file
808
lib/gitea/pr.sx
Normal file
@@ -0,0 +1,808 @@
|
||||
; lib/gitea/pr.sx — sx-gitea Phase 5: pull requests.
|
||||
;
|
||||
; A PR is a kv record: source branch -> target branch, title/author/body
|
||||
; (Markdown, rendered via content like issues), a review thread (latest
|
||||
; verdict per reviewer wins), and a merge outcome. Numbers share the
|
||||
; per-repo counter with issues (gitea/issue-next!), GitHub-style.
|
||||
;
|
||||
; The diff is always LIVE — computed with sx-git from the merge base of
|
||||
; the current branch heads to the source head (so a target that moved on
|
||||
; never shows spurious deletions), never stored.
|
||||
;
|
||||
; Lifecycle is a lib/flow durable workflow (deterministic-replay
|
||||
; suspend): open -(approval)-> approved -(merge)-> merged. review! resumes
|
||||
; the approval suspend when the verdict set first becomes approving;
|
||||
; merge!/close! resume/cancel the rest. The flow env lives in the forge
|
||||
; handle; the record's :state stays the source of truth (merge re-checks
|
||||
; approval live — the flow is the durable journal of the lifecycle).
|
||||
;
|
||||
; Merging uses git/merge-commits (3-way over the merge base): up-to-date
|
||||
; and fast-forward move nothing or just the ref; a true merge writes a
|
||||
; two-parent commit; conflicts abort with the conflicting paths. All ref
|
||||
; moves go through branch-cas! — a concurrent push surfaces as "stale".
|
||||
;
|
||||
; The merge queue is a per-repo list of approved PR numbers;
|
||||
; queue-process! merges them in order, keeping the failures queued.
|
||||
;
|
||||
; Requires: lib/gitea/{repo,access,web,issues}.sx and their stacks, plus
|
||||
; the flow stack (lib/guest/lex, lib/guest/reflective/{env,quoting},
|
||||
; lib/scheme/{parser,eval,runtime}, lib/flow/{spec,store,remote,host,api}).
|
||||
|
||||
; ── record plumbing ──────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/pr-key
|
||||
(fn (owner name n) (str "gitea/pr/" owner "/" name "/" (gitea/pad8 n))))
|
||||
|
||||
(define
|
||||
gitea/pr-get
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/pr-key owner name n))))
|
||||
|
||||
(define
|
||||
gitea/prs
|
||||
(fn
|
||||
(forge owner name)
|
||||
(map
|
||||
(fn (s) (parse-int s))
|
||||
(gitea/names-under forge (str "gitea/pr/" owner "/" name "/")))))
|
||||
|
||||
(define
|
||||
gitea/pr-records
|
||||
(fn
|
||||
(forge owner name)
|
||||
(map
|
||||
(fn (n) (gitea/pr-get forge owner name n))
|
||||
(gitea/prs forge owner name))))
|
||||
|
||||
(define
|
||||
gitea/pr-update!
|
||||
(fn
|
||||
(forge owner name n f)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(if
|
||||
(nil? rec)
|
||||
nil
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/pr-key owner name n)
|
||||
(f rec))))))
|
||||
|
||||
; ── the lifecycle flow ───────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/pr-flow-src
|
||||
"(defflow pr-lifecycle (sequence (flow-node (lambda (x) (suspend (quote approval)))) (flow-node (lambda (x) (suspend (quote merge)))) (flow-node (lambda (x) (quote merged)))))")
|
||||
|
||||
(define
|
||||
gitea/flow-env
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((cache (get forge :cache)))
|
||||
(if
|
||||
(and cache (get cache "flow-env"))
|
||||
(get cache "flow-env")
|
||||
(let
|
||||
((env (flow-make-env)))
|
||||
(begin
|
||||
(flow-run-in env gitea/pr-flow-src)
|
||||
(if cache (dict-set! cache "flow-env" env) nil)
|
||||
env))))))
|
||||
|
||||
; => flow id (int) | nil
|
||||
(define
|
||||
gitea/pr-flow-start!
|
||||
(fn
|
||||
(forge n)
|
||||
(let
|
||||
((res (flow-run-in (gitea/flow-env forge) (str "(flow/start pr-lifecycle " n ")"))))
|
||||
(if
|
||||
(and (list? res) (>= (len res) 2))
|
||||
(nth res 1)
|
||||
nil))))
|
||||
|
||||
(define
|
||||
gitea/flow-pending-tag
|
||||
(fn
|
||||
(forge fid)
|
||||
(let
|
||||
((pending (flow-run-in (gitea/flow-env forge) "(flow/pending)")))
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(if
|
||||
(and (list? p) (= (first p) fid))
|
||||
(str (nth p 1))
|
||||
acc))
|
||||
nil
|
||||
pending))))
|
||||
|
||||
(define
|
||||
gitea/pr-flow-resume!
|
||||
(fn
|
||||
(forge fid value)
|
||||
(flow-run-in
|
||||
(gitea/flow-env forge)
|
||||
(str "(flow/resume " fid " (quote " value "))"))))
|
||||
|
||||
(define
|
||||
gitea/pr-flow-cancel!
|
||||
(fn
|
||||
(forge fid)
|
||||
(flow-run-in (gitea/flow-env forge) (str "(flow/cancel " fid ")"))))
|
||||
|
||||
; lifecycle stage as seen from the durable flow
|
||||
(define
|
||||
gitea/pr-flow-status
|
||||
(fn
|
||||
(forge rec)
|
||||
(let
|
||||
((fid (get rec :flow-id)))
|
||||
(if
|
||||
(nil? fid)
|
||||
"none"
|
||||
(let
|
||||
((st (str (flow-run-in (gitea/flow-env forge) (str "(flow/status " fid ")")))))
|
||||
(cond
|
||||
((= st "done") "merged")
|
||||
((= st "cancelled") "closed")
|
||||
((= st "suspended")
|
||||
(let
|
||||
((tag (gitea/flow-pending-tag forge fid)))
|
||||
(cond
|
||||
((= tag "approval") "review")
|
||||
((= tag "merge") "approved")
|
||||
(else "suspended"))))
|
||||
(else st)))))))
|
||||
|
||||
; ── create ───────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/pr-create!
|
||||
(fn
|
||||
(forge owner name author title source target body opts)
|
||||
(cond
|
||||
((not (gitea/repo-exists? forge owner name)) {:error "no-such-repo"})
|
||||
((not (gitea/owner-exists? forge author)) {:error "no-such-user"})
|
||||
((or (not (string? title)) (= title "")) {:error "empty-title"})
|
||||
((= source target) {:error "same-branch"})
|
||||
(else
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(cond
|
||||
((nil? (git/branch-get grepo source)) {:error "no-such-source"})
|
||||
((nil? (git/branch-get grepo target)) {:error "no-such-target"})
|
||||
(else
|
||||
(let
|
||||
((o (or opts {})))
|
||||
(let
|
||||
((n (gitea/issue-next! forge owner name)))
|
||||
(let
|
||||
((rec {:source source :state "open" :title title :merge-cid nil :reviews (list) :body (or body "") :number n :author author :created-at (or (get o :created-at) 0) :target target :flow-id (gitea/pr-flow-start! forge n)}))
|
||||
(begin
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/pr-key owner name n)
|
||||
rec)
|
||||
rec)))))))))))
|
||||
|
||||
; ── live diff (merge base -> source head) ────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/pr-heads
|
||||
(fn
|
||||
(forge owner name rec)
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(let
|
||||
((tcid (git/branch-get grepo (get rec :target)))
|
||||
(scid (git/branch-get grepo (get rec :source))))
|
||||
{:base-cid (if (or (nil? tcid) (nil? scid)) nil (or (git/merge-base grepo tcid scid) tcid)) :grepo grepo :target-cid tcid :source-cid scid}))))
|
||||
|
||||
; {:added :modified :deleted} from the merge base to the source head
|
||||
(define
|
||||
gitea/pr-diff
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(if
|
||||
(nil? rec)
|
||||
nil
|
||||
(let
|
||||
((h (gitea/pr-heads forge owner name rec)))
|
||||
(if
|
||||
(or (nil? (get h :base-cid)) (nil? (get h :source-cid)))
|
||||
nil
|
||||
(git/commit-diff
|
||||
(get h :grepo)
|
||||
(get h :base-cid)
|
||||
(get h :source-cid))))))))
|
||||
|
||||
(define
|
||||
gitea/pr-diff-unified
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(if
|
||||
(nil? rec)
|
||||
nil
|
||||
(let
|
||||
((h (gitea/pr-heads forge owner name rec)))
|
||||
(if
|
||||
(or (nil? (get h :base-cid)) (nil? (get h :source-cid)))
|
||||
nil
|
||||
(git/commit-diff-unified
|
||||
(get h :grepo)
|
||||
(get h :base-cid)
|
||||
(get h :source-cid))))))))
|
||||
|
||||
; ── reviews ──────────────────────────────────────────────────────────
|
||||
|
||||
(define gitea/pr-verdicts (list "approve" "request-changes"))
|
||||
|
||||
; latest verdict per reviewer
|
||||
(define
|
||||
gitea/pr-latest-verdicts
|
||||
(fn
|
||||
(rec)
|
||||
(reduce
|
||||
(fn (acc r) (assoc acc (get r :reviewer) (get r :verdict)))
|
||||
{}
|
||||
(get rec :reviews))))
|
||||
|
||||
(define
|
||||
gitea/pr-approved?
|
||||
(fn
|
||||
(rec)
|
||||
(let
|
||||
((latest (gitea/pr-latest-verdicts rec)))
|
||||
(let
|
||||
((vs (map (fn (k) (get latest k)) (keys latest))))
|
||||
(and
|
||||
(contains? vs "approve")
|
||||
(not (contains? vs "request-changes")))))))
|
||||
|
||||
; resume the approval suspend the first time the verdict set approves
|
||||
(define
|
||||
gitea/pr-sync-flow!
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(if
|
||||
(and
|
||||
rec
|
||||
(gitea/pr-approved? rec)
|
||||
(not (nil? (get rec :flow-id)))
|
||||
(= (gitea/flow-pending-tag forge (get rec :flow-id)) "approval"))
|
||||
(begin
|
||||
(gitea/pr-flow-resume! forge (get rec :flow-id) "approved")
|
||||
true)
|
||||
false))))
|
||||
|
||||
(define
|
||||
gitea/pr-review!
|
||||
(fn
|
||||
(forge owner name n reviewer verdict body opts)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(cond
|
||||
((nil? rec) {:error "no-such-pr"})
|
||||
((not (= (get rec :state) "open")) {:error "not-open"})
|
||||
((not (gitea/owner-exists? forge reviewer)) {:error "no-such-user"})
|
||||
((= reviewer (get rec :author)) {:error "own-pr"})
|
||||
((not (contains? gitea/pr-verdicts verdict)) {:error "invalid-verdict"})
|
||||
(else
|
||||
(let
|
||||
((review {:verdict verdict :body (or body "") :at (or (get (or opts {}) :at) 0) :reviewer reviewer}))
|
||||
(begin
|
||||
(gitea/pr-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn
|
||||
(r)
|
||||
(assoc r :reviews (append (get r :reviews) (list review)))))
|
||||
(gitea/pr-sync-flow! forge owner name n)
|
||||
review)))))))
|
||||
|
||||
; ── merge ────────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/pr-mark-merged!
|
||||
(fn
|
||||
(forge owner name n cid)
|
||||
(let
|
||||
((rec (gitea/pr-update! forge owner name n (fn (r) (assoc (assoc r :state "merged") :merge-cid cid)))))
|
||||
(begin
|
||||
(if
|
||||
(and
|
||||
rec
|
||||
(not (nil? (get rec :flow-id)))
|
||||
(= (gitea/flow-pending-tag forge (get rec :flow-id)) "merge"))
|
||||
(gitea/pr-flow-resume! forge (get rec :flow-id) "merged")
|
||||
nil)
|
||||
rec))))
|
||||
|
||||
(define
|
||||
gitea/pr-merge!
|
||||
(fn
|
||||
(forge owner name n merger opts)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(cond
|
||||
((nil? rec) {:error "no-such-pr"})
|
||||
((not (= (get rec :state) "open")) {:error "not-open"})
|
||||
((not (gitea/pr-approved? rec)) {:error "not-approved"})
|
||||
(else
|
||||
(let
|
||||
((h (gitea/pr-heads forge owner name rec)))
|
||||
(let
|
||||
((grepo (get h :grepo))
|
||||
(tcid (get h :target-cid))
|
||||
(scid (get h :source-cid)))
|
||||
(cond
|
||||
((or (nil? tcid) (nil? scid)) {:error "missing-branch"})
|
||||
(else
|
||||
(let
|
||||
((m (git/merge-commits grepo tcid scid)))
|
||||
(cond
|
||||
((= (get m :result) "up-to-date")
|
||||
(gitea/pr-mark-merged! forge owner name n tcid))
|
||||
((= (get m :result) "fast-forward")
|
||||
(let
|
||||
((res (git/branch-cas! grepo (get rec :target) tcid scid)))
|
||||
(if
|
||||
(and (dict? res) (get res :conflict))
|
||||
{:error "stale"}
|
||||
(gitea/pr-mark-merged! forge owner name n scid))))
|
||||
((= (get m :result) "merged")
|
||||
(let
|
||||
((mc (git/write grepo (git/commit (get m :tree) (list tcid scid) {:message (str "Merge PR #" n ": " (get rec :title)) :time (or (get (or opts {}) :time) 0) :author (or merger "")}))))
|
||||
(let
|
||||
((res (git/branch-cas! grepo (get rec :target) tcid mc)))
|
||||
(if
|
||||
(and (dict? res) (get res :conflict))
|
||||
{:error "stale"}
|
||||
(gitea/pr-mark-merged! forge owner name n mc)))))
|
||||
(else {:conflicts (get m :conflicts) :error "conflicts"}))))))))))))
|
||||
|
||||
; ── close / reopen ───────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/pr-close!
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(if
|
||||
(or (nil? rec) (not (= (get rec :state) "open")))
|
||||
nil
|
||||
(begin
|
||||
(if
|
||||
(nil? (get rec :flow-id))
|
||||
nil
|
||||
(gitea/pr-flow-cancel! forge (get rec :flow-id)))
|
||||
(gitea/pr-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn (r) (assoc r :state "closed"))))))))
|
||||
|
||||
; reopening restarts the lifecycle (a cancelled flow cannot resume)
|
||||
(define
|
||||
gitea/pr-reopen!
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(if
|
||||
(or (nil? rec) (not (= (get rec :state) "closed")))
|
||||
nil
|
||||
(gitea/pr-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
n
|
||||
(fn
|
||||
(r)
|
||||
(assoc
|
||||
(assoc r :state "open")
|
||||
:flow-id (gitea/pr-flow-start! forge n))))))))
|
||||
|
||||
; ── merge queue ──────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/queue-key
|
||||
(fn (owner name) (str "gitea/mergeq/" owner "/" name)))
|
||||
|
||||
(define
|
||||
gitea/queue
|
||||
(fn
|
||||
(forge owner name)
|
||||
(persist/kv-get-or
|
||||
(gitea/forge-db forge)
|
||||
(gitea/queue-key owner name)
|
||||
(list))))
|
||||
|
||||
(define
|
||||
gitea/queue-add!
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(cond
|
||||
((nil? rec) {:error "no-such-pr"})
|
||||
((not (= (get rec :state) "open")) {:error "not-open"})
|
||||
((not (gitea/pr-approved? rec)) {:error "not-approved"})
|
||||
(else
|
||||
(let
|
||||
((q (gitea/queue forge owner name)))
|
||||
(if
|
||||
(contains? q n)
|
||||
q
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/queue-key owner name)
|
||||
(append q (list n))))))))))
|
||||
|
||||
(define
|
||||
gitea/queue-remove!
|
||||
(fn
|
||||
(forge owner name n)
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/queue-key owner name)
|
||||
(filter (fn (x) (not (= x n))) (gitea/queue forge owner name)))))
|
||||
|
||||
; merge queued PRs in order; merged ones leave the queue, failures stay
|
||||
; => ({:number n :merged true} | {:number n :error reason} ...)
|
||||
(define
|
||||
gitea/queue-process!
|
||||
(fn
|
||||
(forge owner name merger)
|
||||
(let
|
||||
((results (map (fn (n) (let ((res (gitea/pr-merge! forge owner name n merger {}))) (if (get res :error) {:error (get res :error) :number n} {:merged true :number n}))) (gitea/queue forge owner name))))
|
||||
(begin
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/queue-key owner name)
|
||||
(map
|
||||
(fn (r) (get r :number))
|
||||
(filter (fn (r) (get r :error)) results)))
|
||||
results))))
|
||||
|
||||
; ── web ──────────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-pr-item
|
||||
(fn
|
||||
(owner name r)
|
||||
(str
|
||||
"<li class=\""
|
||||
(get r :state)
|
||||
"\"><a href=\"/"
|
||||
owner
|
||||
"/"
|
||||
name
|
||||
"/pulls/"
|
||||
(get r :number)
|
||||
"\">#"
|
||||
(get r :number)
|
||||
" "
|
||||
(dream-escape (get r :title))
|
||||
"</a> ["
|
||||
(get r :state)
|
||||
"] "
|
||||
(dream-escape (get r :source))
|
||||
" -> "
|
||||
(dream-escape (get r :target))
|
||||
"</li>")))
|
||||
|
||||
(define
|
||||
gitea/w-pulls-page
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(gitea/w-page
|
||||
(str owner "/" name " pull requests")
|
||||
(str
|
||||
"<h1>Pull Requests</h1><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn (r) (gitea/w-pr-item owner name r))
|
||||
(gitea/pr-records forge owner name)))
|
||||
"</ul>"))))))
|
||||
|
||||
(define
|
||||
gitea/w-review-html
|
||||
(fn
|
||||
(owner name n i r)
|
||||
(str
|
||||
"<div class=\"review "
|
||||
(get r :verdict)
|
||||
"\"><p>"
|
||||
(dream-escape (get r :reviewer))
|
||||
": "
|
||||
(get r :verdict)
|
||||
"</p>"
|
||||
(gitea/md-html (get r :body) (str "rv-" owner "-" name "-" n "-" i))
|
||||
"</div>")))
|
||||
|
||||
(define
|
||||
gitea/w-pull-page
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(if
|
||||
(or (nil? n) (not (gitea/w-readable? forge req owner name)))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(if
|
||||
(nil? rec)
|
||||
(dream-not-found)
|
||||
(gitea/w-page
|
||||
(str "#" n " " (get rec :title))
|
||||
(str
|
||||
"<h1>#"
|
||||
n
|
||||
" "
|
||||
(dream-escape (get rec :title))
|
||||
"</h1>"
|
||||
"<p class=\"state\">"
|
||||
(get rec :state)
|
||||
"</p>"
|
||||
"<p class=\"branches\">"
|
||||
(dream-escape (get rec :source))
|
||||
" -> "
|
||||
(dream-escape (get rec :target))
|
||||
"</p>"
|
||||
"<p class=\"lifecycle\">"
|
||||
(gitea/pr-flow-status forge rec)
|
||||
"</p>"
|
||||
"<div class=\"body\">"
|
||||
(gitea/md-html
|
||||
(get rec :body)
|
||||
(str "pr-" owner "-" name "-" n))
|
||||
"</div>"
|
||||
"<h2>Reviews</h2>"
|
||||
(join
|
||||
""
|
||||
(map-indexed
|
||||
(fn (i r) (gitea/w-review-html owner name n i r))
|
||||
(get rec :reviews)))
|
||||
"<h2>Diff</h2><pre>"
|
||||
(dream-escape
|
||||
(or (gitea/pr-diff-unified forge owner name n) ""))
|
||||
"</pre>"))))))))
|
||||
|
||||
; ── json api ─────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-api-pulls
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(dream-json-value
|
||||
(map (fn (r) {:source (get r :source) :state (get r :state) :title (get r :title) :number (get r :number) :target (get r :target)}) (gitea/pr-records forge owner name)))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-pr-create
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((not (gitea/can? forge user "read" owner name))
|
||||
(if
|
||||
(nil? user)
|
||||
(if
|
||||
(and
|
||||
(gitea/repo-exists? forge owner name)
|
||||
(equal?
|
||||
(get (gitea/repo-get forge owner name) :visibility)
|
||||
"public"))
|
||||
(gitea/w-unauthorized)
|
||||
(dream-not-found))
|
||||
(dream-not-found)))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
(else
|
||||
(let
|
||||
((body (dream-json-body req)))
|
||||
(let
|
||||
((res (gitea/pr-create! forge owner name user (get body :title) (get body :source) (get body :target) (or (get body :body) "") {:created-at (or (get body :created-at) 0)})))
|
||||
(if
|
||||
(get res :error)
|
||||
(gitea/w-json-status 400 {:error (get res :error)})
|
||||
(gitea/w-json-status 201 {:title (get res :title) :number (get res :number)}))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-pr-review
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((or (nil? n) (not (gitea/can? forge user "read" owner name)))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
(else
|
||||
(let
|
||||
((body (dream-json-body req)))
|
||||
(let
|
||||
((res (gitea/pr-review! forge owner name n user (get body :verdict) (or (get body :body) "") {:at (or (get body :created-at) 0)})))
|
||||
(cond
|
||||
((equal? (get res :error) "no-such-pr") (dream-not-found))
|
||||
((get res :error)
|
||||
(gitea/w-json-status 400 {:error (get res :error)}))
|
||||
(else (dream-json-value {:verdict (get res :verdict) :reviewer user})))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-pr-merge
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((or (nil? n) (not (gitea/can? forge user "read" owner name)))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "write" owner name))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(let
|
||||
((res (gitea/pr-merge! forge owner name n user {:time (or (get (dream-json-body req) :time) 0)})))
|
||||
(cond
|
||||
((equal? (get res :error) "no-such-pr") (dream-not-found))
|
||||
((get res :error)
|
||||
(gitea/w-json-status 409 {:error (get res :error)}))
|
||||
(else (dream-json-value {:state (get res :state) :merge-cid (get res :merge-cid) :number n}))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-pr-close
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((or (nil? n) (not (gitea/can? forge user "read" owner name)))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
(else
|
||||
(let
|
||||
((rec (gitea/pr-get forge owner name n)))
|
||||
(cond
|
||||
((nil? rec) (dream-not-found))
|
||||
((not (or (= user (get rec :author)) (gitea/can? forge user "write" owner name)))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(let
|
||||
((res (gitea/pr-close! forge owner name n)))
|
||||
(if
|
||||
(nil? res)
|
||||
(gitea/w-json-status 409 {:error "not-open"})
|
||||
(dream-json-value {:state "closed" :number n}))))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-queue
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(dream-json-value (gitea/queue forge owner name))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-queue-add
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(n (gitea/w-issue-n req)))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((or (nil? n) (not (gitea/can? forge user "read" owner name)))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "write" owner name))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(let
|
||||
((res (gitea/queue-add! forge owner name n)))
|
||||
(if
|
||||
(and (dict? res) (get res :error))
|
||||
(gitea/w-json-status 409 {:error (get res :error)})
|
||||
(dream-json-value (gitea/queue forge owner name))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-queue-process
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((not (gitea/can? forge user "read" owner name))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "write" owner name))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(dream-json-value (gitea/queue-process! forge owner name user))))))))
|
||||
|
||||
(define
|
||||
gitea/pr-routes
|
||||
(fn
|
||||
(forge)
|
||||
(list
|
||||
(dream-get
|
||||
"/:owner/:name/pulls"
|
||||
(fn (req) (gitea/w-pulls-page forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/pulls/:n"
|
||||
(fn (req) (gitea/w-pull-page forge req)))
|
||||
(dream-get
|
||||
"/api/repos/:owner/:name/pulls"
|
||||
(fn (req) (gitea/w-api-pulls forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/pulls"
|
||||
(fn (req) (gitea/w-api-pr-create forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/pulls/:n/reviews"
|
||||
(fn (req) (gitea/w-api-pr-review forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/pulls/:n/merge"
|
||||
(fn (req) (gitea/w-api-pr-merge forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/pulls/:n/close"
|
||||
(fn (req) (gitea/w-api-pr-close forge req)))
|
||||
(dream-get
|
||||
"/api/repos/:owner/:name/merge-queue"
|
||||
(fn (req) (gitea/w-api-queue forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/pulls/:n/enqueue"
|
||||
(fn (req) (gitea/w-api-queue-add forge req)))
|
||||
(dream-post
|
||||
"/api/repos/:owner/:name/merge-queue/process"
|
||||
(fn (req) (gitea/w-api-queue-process forge req))))))
|
||||
|
||||
(set! gitea/route-packs (append gitea/route-packs (list gitea/pr-routes)))
|
||||
270
lib/gitea/repo.sx
Normal file
270
lib/gitea/repo.sx
Normal file
@@ -0,0 +1,270 @@
|
||||
; lib/gitea/repo.sx — sx-gitea Phase 1: forge core.
|
||||
;
|
||||
; The forge is a handle over a persist backend. Owner principals and repo
|
||||
; records live in the kv store under "gitea/..."; each repo's git objects
|
||||
; and refs live in their own git/repo-named namespace "forge/<owner>/<name>",
|
||||
; so deleting a repo is a prefix purge and repos are invisible to each other.
|
||||
; Owner principals are a lightweight directory here; Phase 2 (access) backs
|
||||
; them with identity users/orgs.
|
||||
;
|
||||
; Requires: lib/persist/{event,backend,log,kv}.sx, lib/artdag/dag.sx,
|
||||
; lib/git/{object,ref,dag,worktree,diff,merge,porcelain}.sx
|
||||
|
||||
(define gitea/forge (fn (db) {:cache {} :db db}))
|
||||
(define gitea/forge-db (fn (forge) (get forge :db)))
|
||||
|
||||
; ── names ────────────────────────────────────────────────────────────
|
||||
; Owner and repo names share one rule: nonempty, no "/" or spaces, and not
|
||||
; a word the router owns (an owner called "api" would shadow /api routes).
|
||||
|
||||
(define
|
||||
gitea/reserved-names
|
||||
(list "api" "tree" "blob" "raw" "commit" "commits" "branches"))
|
||||
|
||||
(define
|
||||
gitea/valid-name?
|
||||
(fn
|
||||
(name)
|
||||
(and
|
||||
(string? name)
|
||||
(> (string-length name) 0)
|
||||
(not (contains? name "/"))
|
||||
(not (contains? name " "))
|
||||
(not (contains? gitea/reserved-names name)))))
|
||||
|
||||
; ── owners ───────────────────────────────────────────────────────────
|
||||
|
||||
(define gitea/owner-key (fn (name) (str "gitea/owner/" name)))
|
||||
|
||||
(define
|
||||
gitea/owner-get
|
||||
(fn
|
||||
(forge name)
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/owner-key name))))
|
||||
|
||||
(define
|
||||
gitea/owner-exists?
|
||||
(fn
|
||||
(forge name)
|
||||
(persist/kv-has? (gitea/forge-db forge) (gitea/owner-key name))))
|
||||
|
||||
(define
|
||||
gitea/owner-create!
|
||||
(fn
|
||||
(forge kind name)
|
||||
(if
|
||||
(not (gitea/valid-name? name))
|
||||
{:name name :error "invalid-name"}
|
||||
(persist/kv-put-new
|
||||
(gitea/forge-db forge)
|
||||
(gitea/owner-key name)
|
||||
{:name name :kind kind}))))
|
||||
|
||||
(define
|
||||
gitea/user-create!
|
||||
(fn (forge name) (gitea/owner-create! forge "user" name)))
|
||||
(define
|
||||
gitea/org-create!
|
||||
(fn (forge name) (gitea/owner-create! forge "org" name)))
|
||||
|
||||
(define gitea/user? (fn (owner) (equal? (get owner :kind) "user")))
|
||||
(define gitea/org? (fn (owner) (equal? (get owner :kind) "org")))
|
||||
|
||||
(define
|
||||
gitea/names-under
|
||||
(fn
|
||||
(forge pfx)
|
||||
(artdag/sort-strings
|
||||
(map
|
||||
(fn (k) (substr k (string-length pfx)))
|
||||
(filter
|
||||
(fn (k) (starts-with? k pfx))
|
||||
(persist/kv-keys (gitea/forge-db forge)))))))
|
||||
|
||||
(define gitea/owners (fn (forge) (gitea/names-under forge "gitea/owner/")))
|
||||
|
||||
; ── repo records ─────────────────────────────────────────────────────
|
||||
|
||||
(define gitea/repo-key (fn (owner name) (str "gitea/repo/" owner "/" name)))
|
||||
(define gitea/repo-ns (fn (owner name) (str "forge/" owner "/" name)))
|
||||
|
||||
(define gitea/repo-record (fn (owner name opts) {:name name :description (or (get opts :description) "") :default-branch (or (get opts :default-branch) "main") :owner owner :created-at (or (get opts :created-at) 0) :visibility (or (get opts :visibility) "public")}))
|
||||
|
||||
; create-only: {:error ...} on bad input, {:conflict ...} if it exists,
|
||||
; else initialize the git store (HEAD -> unborn heads/main) and return
|
||||
; the record.
|
||||
(define
|
||||
gitea/repo-create!
|
||||
(fn
|
||||
(forge owner name opts)
|
||||
(cond
|
||||
((not (gitea/owner-exists? forge owner)) {:error "no-such-owner" :owner owner})
|
||||
((not (gitea/valid-name? name)) {:name name :error "invalid-name"})
|
||||
(else
|
||||
(let
|
||||
((rec (gitea/repo-record owner name (or opts {}))))
|
||||
(let
|
||||
((res (persist/kv-put-new (gitea/forge-db forge) (gitea/repo-key owner name) rec)))
|
||||
(if
|
||||
(get res :conflict)
|
||||
res
|
||||
(begin
|
||||
(git/init! (gitea/forge-db forge) (gitea/repo-ns owner name))
|
||||
rec))))))))
|
||||
|
||||
(define
|
||||
gitea/repo-get
|
||||
(fn
|
||||
(forge owner name)
|
||||
(persist/kv-get (gitea/forge-db forge) (gitea/repo-key owner name))))
|
||||
|
||||
(define
|
||||
gitea/repo-exists?
|
||||
(fn
|
||||
(forge owner name)
|
||||
(persist/kv-has? (gitea/forge-db forge) (gitea/repo-key owner name))))
|
||||
|
||||
(define
|
||||
gitea/repo-update!
|
||||
(fn
|
||||
(forge owner name f)
|
||||
(let
|
||||
((rec (gitea/repo-get forge owner name)))
|
||||
(if
|
||||
(nil? rec)
|
||||
nil
|
||||
(persist/kv-put
|
||||
(gitea/forge-db forge)
|
||||
(gitea/repo-key owner name)
|
||||
(f rec))))))
|
||||
|
||||
; the sx-git handle for a repo's own object/ref namespace
|
||||
(define
|
||||
gitea/repo-git
|
||||
(fn
|
||||
(forge owner name)
|
||||
(git/repo-named (gitea/forge-db forge) (gitea/repo-ns owner name))))
|
||||
|
||||
; everything owned by a repo record, beyond the record itself: its git
|
||||
; namespace and any per-repo rows other phases hang off it
|
||||
(define
|
||||
gitea/repo-purge-prefixes
|
||||
(fn
|
||||
(owner name)
|
||||
(list
|
||||
(str (gitea/repo-ns owner name) "/")
|
||||
(str "gitea/collab/" owner "/" name "/")
|
||||
(str "gitea/issue/" owner "/" name "/"))))
|
||||
|
||||
(define
|
||||
gitea/repo-purge-keys
|
||||
(fn (owner name) (list (str "gitea/issue-seq/" owner "/" name))))
|
||||
|
||||
; delete the record and purge every key the repo owns — a recreated repo
|
||||
; under the same name must start truly empty (no ghost collaborators,
|
||||
; issues, or objects)
|
||||
(define
|
||||
gitea/repo-delete!
|
||||
(fn
|
||||
(forge owner name)
|
||||
(if
|
||||
(not (gitea/repo-exists? forge owner name))
|
||||
false
|
||||
(let
|
||||
((db (gitea/forge-db forge))
|
||||
(prefixes (gitea/repo-purge-prefixes owner name)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(k)
|
||||
(if
|
||||
(reduce
|
||||
(fn (acc p) (or acc (starts-with? k p)))
|
||||
false
|
||||
prefixes)
|
||||
(persist/kv-delete db k)
|
||||
nil))
|
||||
(persist/kv-keys db))
|
||||
(for-each
|
||||
(fn (k) (persist/kv-delete db k))
|
||||
(gitea/repo-purge-keys owner name))
|
||||
(persist/kv-delete db (gitea/repo-key owner name))
|
||||
true)))))
|
||||
|
||||
(define gitea/repos (fn (forge) (gitea/names-under forge "gitea/repo/")))
|
||||
|
||||
(define
|
||||
gitea/repos-for
|
||||
(fn (forge owner) (gitea/names-under forge (str "gitea/repo/" owner "/"))))
|
||||
|
||||
; ── ref resolution / tree navigation (shared by browse views) ────────
|
||||
|
||||
; follow annotated tag objects down to the commit they name
|
||||
(define
|
||||
gitea/peel-to-commit
|
||||
(fn
|
||||
(grepo cid)
|
||||
(let
|
||||
((obj (git/read grepo cid)))
|
||||
(cond
|
||||
((nil? obj) nil)
|
||||
((git/tag? obj) (gitea/peel-to-commit grepo (git/tag-target obj)))
|
||||
(else cid)))))
|
||||
|
||||
; a browse ref is a branch name, a tag name, or a raw cid — in that order
|
||||
(define
|
||||
gitea/resolve-ref
|
||||
(fn
|
||||
(grepo refname)
|
||||
(let
|
||||
((b (git/branch-get grepo refname)))
|
||||
(if
|
||||
b
|
||||
(gitea/peel-to-commit grepo b)
|
||||
(let
|
||||
((t (git/tag-get grepo refname)))
|
||||
(if
|
||||
t
|
||||
(gitea/peel-to-commit grepo t)
|
||||
(if
|
||||
(git/has? grepo refname)
|
||||
(gitea/peel-to-commit grepo refname)
|
||||
nil)))))))
|
||||
|
||||
(define
|
||||
gitea/path-segs
|
||||
(fn (path) (filter (fn (s) (not (equal? s ""))) (split path "/"))))
|
||||
|
||||
; walk tree entries by path segments => {:kind "tree"|"blob" :cid cid} | nil
|
||||
(define
|
||||
gitea/entry-at
|
||||
(fn
|
||||
(grepo tree-cid segs)
|
||||
(if
|
||||
(empty? segs)
|
||||
{:kind "tree" :cid tree-cid}
|
||||
(let
|
||||
((tree (git/read grepo tree-cid)))
|
||||
(if
|
||||
(not (git/tree? tree))
|
||||
nil
|
||||
(let
|
||||
((entry (git/tree-entry-for tree (first segs))))
|
||||
(cond
|
||||
((nil? entry) nil)
|
||||
((empty? (rest segs)) {:kind (git/entry-kind entry) :cid (git/entry-cid entry)})
|
||||
((equal? (git/entry-kind entry) "tree")
|
||||
(gitea/entry-at grepo (git/entry-cid entry) (rest segs)))
|
||||
(else nil))))))))
|
||||
|
||||
; entry at path under a COMMIT's tree ("" => the root tree)
|
||||
(define
|
||||
gitea/tree-at
|
||||
(fn
|
||||
(grepo commit-cid path)
|
||||
(let
|
||||
((c (git/read grepo commit-cid)))
|
||||
(if
|
||||
(not (git/commit? c))
|
||||
nil
|
||||
(gitea/entry-at grepo (git/commit-tree c) (gitea/path-segs path))))))
|
||||
15
lib/gitea/scoreboard.json
Normal file
15
lib/gitea/scoreboard.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"suites": {
|
||||
"repo": {"pass": 91, "fail": 0},
|
||||
"access": {"pass": 103, "fail": 0},
|
||||
"wire": {"pass": 78, "fail": 0},
|
||||
"issues": {"pass": 88, "fail": 0},
|
||||
"pr": {"pass": 100, "fail": 0},
|
||||
"activity": {"pass": 60, "fail": 0},
|
||||
"search": {"pass": 35, "fail": 0},
|
||||
"fed": {"pass": 60, "fail": 0}
|
||||
},
|
||||
"total_pass": 615,
|
||||
"total_fail": 0,
|
||||
"total": 615
|
||||
}
|
||||
15
lib/gitea/scoreboard.md
Normal file
15
lib/gitea/scoreboard.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# sx-gitea Conformance Scoreboard
|
||||
|
||||
_Generated by `lib/gitea/conformance.sh`_
|
||||
|
||||
| Suite | Pass | Fail | Total |
|
||||
|-------|-----:|-----:|------:|
|
||||
| repo | 91 | 0 | 91 |
|
||||
| access | 103 | 0 | 103 |
|
||||
| wire | 78 | 0 | 78 |
|
||||
| issues | 88 | 0 | 88 |
|
||||
| pr | 100 | 0 | 100 |
|
||||
| activity | 60 | 0 | 60 |
|
||||
| search | 35 | 0 | 35 |
|
||||
| fed | 60 | 0 | 60 |
|
||||
| **Total** | **615** | **0** | **615** |
|
||||
383
lib/gitea/search.sx
Normal file
383
lib/gitea/search.sx
Normal file
@@ -0,0 +1,383 @@
|
||||
; lib/gitea/search.sx — sx-gitea Phase 7: code + issue/PR search.
|
||||
;
|
||||
; search-on-sx (haskell-on-sx) does the heavy lifting: the forge builds a
|
||||
; document corpus SX-side — code files from the default branch head
|
||||
; (path + blob text), issues (title + body + comment bodies), PRs (title
|
||||
; + body + review bodies) — embeds it as one Haskell program, and asks
|
||||
; searchRankTfIdf for ranked doc ids. Queries speak the search query
|
||||
; language (terms, AND/OR/NOT, "phrases").
|
||||
;
|
||||
; Cost model: ONE evaluation parses the Haskell search layers (~seconds),
|
||||
; extra queries are nearly free. So the core primitive is
|
||||
; gitea/search-multi — ANY number of corpora and queries in a single
|
||||
; evaluation (each corpus becomes an idxN binding) — and everything else
|
||||
; sugars over it. Only the six layers searchRankTfIdf needs are compiled
|
||||
; (tokenize/index/query/parse/rank/rankq), not the full search/src.
|
||||
;
|
||||
; Requires: lib/gitea/{repo,access,web,issues,pr}.sx and their stacks,
|
||||
; lib/haskell/* + lib/search/{tokenize,index,query,parse,rank,rankq,
|
||||
; testlib}.sx (search-hk->sx, hk-core, hk-eval-program).
|
||||
|
||||
; ── corpus ───────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/code-docs
|
||||
(fn
|
||||
(forge owner name)
|
||||
(let
|
||||
((rec (gitea/repo-get forge owner name)))
|
||||
(if
|
||||
(nil? rec)
|
||||
(list)
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(let
|
||||
((head-cid (git/branch-get grepo (get rec :default-branch))))
|
||||
(if
|
||||
(nil? head-cid)
|
||||
(list)
|
||||
(let
|
||||
((files (git/commit-files grepo head-cid)))
|
||||
(map
|
||||
(fn (p) {:text (str p " " (get files p)) :kind "code" :ref p})
|
||||
(artdag/sort-strings (keys files)))))))))))
|
||||
|
||||
(define
|
||||
gitea/issue-docs
|
||||
(fn
|
||||
(forge owner name)
|
||||
(map (fn (r) {:text (str (get r :title) " " (get r :body) " " (join " " (map (fn (c) (get c :body)) (get r :comments)))) :kind "issue" :ref (str (get r :number))}) (gitea/issue-records forge owner name))))
|
||||
|
||||
(define
|
||||
gitea/pr-search-docs
|
||||
(fn
|
||||
(forge owner name)
|
||||
(map (fn (r) {:text (str (get r :title) " " (get r :body) " " (join " " (map (fn (v) (get v :body)) (get r :reviews)))) :kind "pr" :ref (str (get r :number))}) (gitea/pr-records forge owner name))))
|
||||
|
||||
(define gitea/search-kinds-all (list "code" "issue" "pr"))
|
||||
|
||||
(define
|
||||
gitea/repo-docs
|
||||
(fn
|
||||
(forge owner name kinds)
|
||||
(filter
|
||||
(fn (d) (contains? kinds (get d :kind)))
|
||||
(concat
|
||||
(gitea/code-docs forge owner name)
|
||||
(concat
|
||||
(gitea/issue-docs forge owner name)
|
||||
(gitea/pr-search-docs forge owner name))))))
|
||||
|
||||
(define
|
||||
gitea/visible-docs
|
||||
(fn
|
||||
(forge user kinds)
|
||||
(reduce
|
||||
(fn
|
||||
(acc full)
|
||||
(let
|
||||
((p (gitea/split-full full)))
|
||||
(concat
|
||||
acc
|
||||
(map
|
||||
(fn (d) (assoc d :repo full))
|
||||
(gitea/repo-docs forge (get p :owner) (get p :name) kinds)))))
|
||||
(list)
|
||||
(gitea/visible-repos forge user))))
|
||||
|
||||
; ── haskell program assembly ─────────────────────────────────────────
|
||||
|
||||
; only the layers searchRankTfIdf needs — parsing is the dominant cost
|
||||
(define
|
||||
gitea/search-src
|
||||
(str
|
||||
search/tokenize-src
|
||||
"\n"
|
||||
search/index-src
|
||||
"\n"
|
||||
search/query-src
|
||||
"\n"
|
||||
search/parse-src
|
||||
"\n"
|
||||
search/rank-src
|
||||
"\n"
|
||||
search/rankq-src
|
||||
"\n"))
|
||||
|
||||
(define
|
||||
gitea/hk-escape
|
||||
(fn
|
||||
(s)
|
||||
(replace
|
||||
(replace (replace (replace (or s "") "\\" "\\\\") "\"" "\\\"") "\n" "\\n")
|
||||
"\t"
|
||||
"\\t")))
|
||||
|
||||
; <name> = indexDoc k "text_k" ( ... (indexDoc 1 "text_1" emptyIndex))
|
||||
(define
|
||||
gitea/index-binding
|
||||
(fn
|
||||
(idxname docs)
|
||||
(str
|
||||
idxname
|
||||
" = "
|
||||
(reduce
|
||||
(fn
|
||||
(acc pair)
|
||||
(str
|
||||
"indexDoc "
|
||||
(first pair)
|
||||
" \""
|
||||
(gitea/hk-escape (get (nth pair 1) :text))
|
||||
"\" ("
|
||||
acc
|
||||
")"))
|
||||
"emptyIndex"
|
||||
(map-indexed (fn (i d) (list (+ i 1) d)) docs))
|
||||
"\n")))
|
||||
|
||||
(define
|
||||
gitea/hk-search-eval
|
||||
(fn
|
||||
(extra)
|
||||
(search-hk->sx
|
||||
(hk-deep-force
|
||||
(get
|
||||
(hk-eval-program (hk-core (str gitea/search-src extra)))
|
||||
"result")))))
|
||||
|
||||
; THE core: corpora = (docs ...), specs = ({:corpus i :query q :n n} ...).
|
||||
; One haskell evaluation; => one ranked {:kind :ref (:repo)} list per spec.
|
||||
(define
|
||||
gitea/search-multi
|
||||
(fn
|
||||
(corpora specs)
|
||||
(let
|
||||
((bindings (join "" (map-indexed (fn (i docs) (gitea/index-binding (str "idx" i) docs)) corpora)))
|
||||
(exprs
|
||||
(map
|
||||
(fn
|
||||
(s)
|
||||
(if
|
||||
(empty? (nth corpora (get s :corpus)))
|
||||
"[]"
|
||||
(str
|
||||
"take "
|
||||
(get s :n)
|
||||
" (searchRankTfIdf \""
|
||||
(gitea/hk-escape (get s :query))
|
||||
"\" idx"
|
||||
(get s :corpus)
|
||||
")")))
|
||||
specs)))
|
||||
(let
|
||||
((id-lists (gitea/hk-search-eval (str bindings "result = [" (join ", " exprs) "]\n"))))
|
||||
(map-indexed
|
||||
(fn
|
||||
(si ids)
|
||||
(let
|
||||
((docs (nth corpora (get (nth specs si) :corpus))))
|
||||
(map
|
||||
(fn
|
||||
(i)
|
||||
(let
|
||||
((d (nth docs (- i 1))))
|
||||
(if (get d :repo) {:repo (get d :repo) :kind (get d :kind) :ref (get d :ref)} {:kind (get d :kind) :ref (get d :ref)})))
|
||||
ids)))
|
||||
id-lists)))))
|
||||
|
||||
; many queries against ONE corpus, still one evaluation
|
||||
(define
|
||||
gitea/search-many
|
||||
(fn
|
||||
(docs queries n)
|
||||
(if
|
||||
(empty? docs)
|
||||
(map (fn (q) (list)) queries)
|
||||
(gitea/search-multi (list docs) (map (fn (q) {:n n :query q :corpus 0}) queries)))))
|
||||
|
||||
; ── repo-scoped search ───────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/search-repo
|
||||
(fn
|
||||
(forge owner name query kinds n)
|
||||
(first
|
||||
(gitea/search-many
|
||||
(gitea/repo-docs forge owner name kinds)
|
||||
(list query)
|
||||
n))))
|
||||
|
||||
(define
|
||||
gitea/search-code
|
||||
(fn
|
||||
(forge owner name query n)
|
||||
(gitea/search-repo forge owner name query (list "code") n)))
|
||||
|
||||
(define
|
||||
gitea/search-issues
|
||||
(fn
|
||||
(forge owner name query n)
|
||||
(gitea/search-repo forge owner name query (list "issue") n)))
|
||||
|
||||
(define
|
||||
gitea/search-prs
|
||||
(fn
|
||||
(forge owner name query n)
|
||||
(gitea/search-repo forge owner name query (list "pr") n)))
|
||||
|
||||
; ── global search over visible repos ─────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/search-visible
|
||||
(fn
|
||||
(forge user query kinds n)
|
||||
(first
|
||||
(gitea/search-many
|
||||
(gitea/visible-docs forge user kinds)
|
||||
(list query)
|
||||
n))))
|
||||
|
||||
; ── web ──────────────────────────────────────────────────────────────
|
||||
|
||||
(define gitea/w-query-param (fn (req name) (get (get req :query) name)))
|
||||
|
||||
(define
|
||||
gitea/w-search-kinds
|
||||
(fn
|
||||
(req)
|
||||
(let
|
||||
((k (gitea/w-query-param req "kind")))
|
||||
(if
|
||||
(contains? gitea/search-kinds-all k)
|
||||
(list k)
|
||||
gitea/search-kinds-all))))
|
||||
|
||||
(define
|
||||
gitea/w-search-item
|
||||
(fn
|
||||
(owner name r)
|
||||
(let
|
||||
((kind (get r :kind)) (ref (get r :ref)))
|
||||
(cond
|
||||
((= kind "code")
|
||||
(str
|
||||
"<li class=\"code\"><a href=\"/"
|
||||
owner
|
||||
"/"
|
||||
name
|
||||
"/blob/main/"
|
||||
ref
|
||||
"\">"
|
||||
(dream-escape ref)
|
||||
"</a></li>"))
|
||||
((= kind "issue")
|
||||
(str
|
||||
"<li class=\"issue\"><a href=\"/"
|
||||
owner
|
||||
"/"
|
||||
name
|
||||
"/issues/"
|
||||
ref
|
||||
"\">#"
|
||||
ref
|
||||
"</a></li>"))
|
||||
(else
|
||||
(str
|
||||
"<li class=\"pr\"><a href=\"/"
|
||||
owner
|
||||
"/"
|
||||
name
|
||||
"/pulls/"
|
||||
ref
|
||||
"\">#"
|
||||
ref
|
||||
"</a></li>"))))))
|
||||
|
||||
(define
|
||||
gitea/w-search-page
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(q (gitea/w-query-param req "q")))
|
||||
(cond
|
||||
((not (gitea/w-readable? forge req owner name)) (dream-not-found))
|
||||
((or (nil? q) (= q ""))
|
||||
(gitea/w-json-status 400 {:error "missing-query"}))
|
||||
(else
|
||||
(gitea/w-page
|
||||
(str "search: " q)
|
||||
(str
|
||||
"<h1>Search</h1><p>"
|
||||
(dream-escape q)
|
||||
"</p><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn (r) (gitea/w-search-item owner name r))
|
||||
(gitea/search-repo
|
||||
forge
|
||||
owner
|
||||
name
|
||||
q
|
||||
(gitea/w-search-kinds req)
|
||||
20)))
|
||||
"</ul>")))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-search-repo
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(q (gitea/w-query-param req "q")))
|
||||
(cond
|
||||
((not (gitea/w-readable? forge req owner name)) (dream-not-found))
|
||||
((or (nil? q) (= q ""))
|
||||
(gitea/w-json-status 400 {:error "missing-query"}))
|
||||
(else
|
||||
(dream-json-value
|
||||
(gitea/search-repo
|
||||
forge
|
||||
owner
|
||||
name
|
||||
q
|
||||
(gitea/w-search-kinds req)
|
||||
20)))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-search
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((q (gitea/w-query-param req "q")))
|
||||
(if
|
||||
(or (nil? q) (= q ""))
|
||||
(gitea/w-json-status 400 {:error "missing-query"})
|
||||
(dream-json-value
|
||||
(gitea/search-visible
|
||||
forge
|
||||
(gitea/w-user forge req)
|
||||
q
|
||||
(gitea/w-search-kinds req)
|
||||
20))))))
|
||||
|
||||
(define
|
||||
gitea/search-routes
|
||||
(fn
|
||||
(forge)
|
||||
(list
|
||||
(dream-get
|
||||
"/:owner/:name/search"
|
||||
(fn (req) (gitea/w-search-page forge req)))
|
||||
(dream-get
|
||||
"/api/repos/:owner/:name/search"
|
||||
(fn (req) (gitea/w-api-search-repo forge req)))
|
||||
(dream-get "/api/search" (fn (req) (gitea/w-api-search forge req))))))
|
||||
|
||||
(set!
|
||||
gitea/route-packs
|
||||
(append gitea/route-packs (list gitea/search-routes)))
|
||||
546
lib/gitea/tests/access.sx
Normal file
546
lib/gitea/tests/access.sx
Normal file
@@ -0,0 +1,546 @@
|
||||
; lib/gitea/tests/access.sx — Phase 2: visibility, collaborators, org
|
||||
; teams, acl-backed can?, tokens, and auth-gated web routes.
|
||||
|
||||
(define gitea-access-pass 0)
|
||||
(define gitea-access-fail 0)
|
||||
(define gitea-access-fails (list))
|
||||
|
||||
(define
|
||||
gitea-access-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-access-pass (+ gitea-access-pass 1))
|
||||
(begin
|
||||
(set! gitea-access-fail (+ gitea-access-fail 1))
|
||||
(set!
|
||||
gitea-access-fails
|
||||
(append gitea-access-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
(define ga-db (persist/mem-backend))
|
||||
(define ga-forge (gitea/forge ga-db))
|
||||
|
||||
(gitea/user-create! ga-forge "alice")
|
||||
(gitea/user-create! ga-forge "bob")
|
||||
(gitea/user-create! ga-forge "carol")
|
||||
(gitea/user-create! ga-forge "eve")
|
||||
(gitea/org-create! ga-forge "acme")
|
||||
|
||||
(gitea/repo-create! ga-forge "alice" "pub" {})
|
||||
(gitea/repo-create! ga-forge "alice" "sec" {:visibility "private"})
|
||||
(gitea/repo-create! ga-forge "acme" "app" {:visibility "private"})
|
||||
|
||||
; ── can? basics ──────────────────────────────────────────────────────
|
||||
|
||||
(gitea-access-test
|
||||
"public read anon"
|
||||
(gitea/can? ga-forge nil "read" "alice" "pub")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"public read any user"
|
||||
(gitea/can? ga-forge "eve" "read" "alice" "pub")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"public write anon denied"
|
||||
(gitea/can? ga-forge nil "write" "alice" "pub")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"public write stranger denied"
|
||||
(gitea/can? ga-forge "eve" "write" "alice" "pub")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"private read anon denied"
|
||||
(gitea/can? ga-forge nil "read" "alice" "sec")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"private read stranger denied"
|
||||
(gitea/can? ga-forge "eve" "read" "alice" "sec")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"owner reads private"
|
||||
(gitea/can? ga-forge "alice" "read" "alice" "sec")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"owner writes private"
|
||||
(gitea/can? ga-forge "alice" "write" "alice" "sec")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"owner admins private"
|
||||
(gitea/can? ga-forge "alice" "admin" "alice" "sec")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"owner admins public"
|
||||
(gitea/can? ga-forge "alice" "admin" "alice" "pub")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"missing repo denied"
|
||||
(gitea/can? ga-forge "alice" "read" "alice" "nope")
|
||||
false)
|
||||
|
||||
; ── collaborators ────────────────────────────────────────────────────
|
||||
|
||||
(gitea-access-test
|
||||
"collab-add! bob write"
|
||||
(get (gitea/collab-add! ga-forge "alice" "sec" "bob" "write") :role)
|
||||
"write")
|
||||
(gitea-access-test
|
||||
"collab-role"
|
||||
(gitea/collab-role ga-forge "alice" "sec" "bob")
|
||||
"write")
|
||||
(gitea-access-test
|
||||
"collabs list"
|
||||
(gitea/collabs ga-forge "alice" "sec")
|
||||
(list "bob"))
|
||||
(gitea-access-test
|
||||
"write collab reads"
|
||||
(gitea/can? ga-forge "bob" "read" "alice" "sec")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"write collab writes"
|
||||
(gitea/can? ga-forge "bob" "write" "alice" "sec")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"write collab cannot admin"
|
||||
(gitea/can? ga-forge "bob" "admin" "alice" "sec")
|
||||
false)
|
||||
|
||||
(gitea/collab-add! ga-forge "alice" "sec" "carol" "read")
|
||||
(gitea-access-test
|
||||
"read collab reads"
|
||||
(gitea/can? ga-forge "carol" "read" "alice" "sec")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"read collab cannot write"
|
||||
(gitea/can? ga-forge "carol" "write" "alice" "sec")
|
||||
false)
|
||||
|
||||
(gitea/collab-add! ga-forge "alice" "sec" "carol" "write")
|
||||
(gitea-access-test
|
||||
"collab upsert to write"
|
||||
(gitea/can? ga-forge "carol" "write" "alice" "sec")
|
||||
true)
|
||||
|
||||
(gitea-access-test
|
||||
"collab-add! missing repo"
|
||||
(get (gitea/collab-add! ga-forge "alice" "nope" "bob" "read") :error)
|
||||
"no-such-repo")
|
||||
(gitea-access-test
|
||||
"collab-add! missing user"
|
||||
(get (gitea/collab-add! ga-forge "alice" "sec" "zeb" "read") :error)
|
||||
"no-such-user")
|
||||
(gitea-access-test
|
||||
"collab-add! bad role"
|
||||
(get (gitea/collab-add! ga-forge "alice" "sec" "bob" "boss") :error)
|
||||
"invalid-role")
|
||||
|
||||
(gitea-access-test
|
||||
"collab-remove! carol"
|
||||
(gitea/collab-remove! ga-forge "alice" "sec" "carol")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"removed collab cannot write"
|
||||
(gitea/can? ga-forge "carol" "write" "alice" "sec")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"removed collab cannot read private"
|
||||
(gitea/can? ga-forge "carol" "read" "alice" "sec")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"collab-remove! again false"
|
||||
(gitea/collab-remove! ga-forge "alice" "sec" "carol")
|
||||
false)
|
||||
|
||||
; ── teams ────────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-access-test
|
||||
"team-create! owners"
|
||||
(get (gitea/team-create! ga-forge "acme" "owners" "admin") :role)
|
||||
"admin")
|
||||
(gitea-access-test
|
||||
"team-create! duplicate conflicts"
|
||||
(get (gitea/team-create! ga-forge "acme" "owners" "read") :conflict)
|
||||
true)
|
||||
(gitea-access-test
|
||||
"team-create! on user rejected"
|
||||
(get (gitea/team-create! ga-forge "alice" "crew" "read") :error)
|
||||
"no-such-org")
|
||||
(gitea-access-test
|
||||
"team-create! bad role"
|
||||
(get (gitea/team-create! ga-forge "acme" "crew" "boss") :error)
|
||||
"invalid-role")
|
||||
|
||||
(gitea/team-add-member! ga-forge "acme" "owners" "alice")
|
||||
(gitea-access-test
|
||||
"team-members"
|
||||
(gitea/team-members ga-forge "acme" "owners")
|
||||
(list "alice"))
|
||||
(gitea-access-test
|
||||
"team-add-member! missing team"
|
||||
(get (gitea/team-add-member! ga-forge "acme" "ghosts" "bob") :error)
|
||||
"no-such-team")
|
||||
(gitea-access-test
|
||||
"team-add-member! missing user"
|
||||
(get (gitea/team-add-member! ga-forge "acme" "owners" "zeb") :error)
|
||||
"no-such-user")
|
||||
|
||||
(gitea-access-test
|
||||
"owners member admins org repo"
|
||||
(gitea/can? ga-forge "alice" "admin" "acme" "app")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"owners member reads org repo"
|
||||
(gitea/can? ga-forge "alice" "read" "acme" "app")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"non-member cannot read org private"
|
||||
(gitea/can? ga-forge "bob" "read" "acme" "app")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"org-admin? alice"
|
||||
(gitea/org-admin? ga-forge "acme" "alice")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"org-admin? bob"
|
||||
(gitea/org-admin? ga-forge "acme" "bob")
|
||||
false)
|
||||
|
||||
(gitea/team-create! ga-forge "acme" "devs" "write")
|
||||
(gitea/team-set-repos! ga-forge "acme" "devs" (list "app"))
|
||||
(gitea/team-add-member! ga-forge "acme" "devs" "bob")
|
||||
|
||||
(gitea-access-test
|
||||
"devs member writes covered repo"
|
||||
(gitea/can? ga-forge "bob" "write" "acme" "app")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"devs member cannot admin"
|
||||
(gitea/can? ga-forge "bob" "admin" "acme" "app")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"org-admin? devs member"
|
||||
(gitea/org-admin? ga-forge "acme" "bob")
|
||||
false)
|
||||
|
||||
(gitea/repo-create! ga-forge "acme" "site" {:visibility "private"})
|
||||
|
||||
(gitea-access-test
|
||||
"scoped team does not cover new repo"
|
||||
(gitea/can? ga-forge "bob" "read" "acme" "site")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"all-repos team covers new repo"
|
||||
(gitea/can? ga-forge "alice" "admin" "acme" "site")
|
||||
true)
|
||||
|
||||
(gitea/team-set-repos! ga-forge "acme" "devs" "all")
|
||||
(gitea-access-test
|
||||
"widened team covers site"
|
||||
(gitea/can? ga-forge "bob" "write" "acme" "site")
|
||||
true)
|
||||
(gitea/team-set-repos! ga-forge "acme" "devs" (list "app"))
|
||||
(gitea-access-test
|
||||
"re-narrowed team loses site"
|
||||
(gitea/can? ga-forge "bob" "write" "acme" "site")
|
||||
false)
|
||||
|
||||
(gitea/team-remove-member! ga-forge "acme" "devs" "bob")
|
||||
(gitea-access-test
|
||||
"removed member loses access"
|
||||
(gitea/can? ga-forge "bob" "write" "acme" "app")
|
||||
false)
|
||||
(gitea/team-add-member! ga-forge "acme" "devs" "bob")
|
||||
|
||||
(gitea-access-test
|
||||
"team-delete!"
|
||||
(gitea/team-delete! ga-forge "acme" "devs")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"deleted team gone"
|
||||
(gitea/teams ga-forge "acme")
|
||||
(list "owners"))
|
||||
(gitea-access-test
|
||||
"deleted team members purged"
|
||||
(gitea/team-members ga-forge "acme" "devs")
|
||||
(list))
|
||||
(gitea-access-test
|
||||
"deleted team access revoked"
|
||||
(gitea/can? ga-forge "bob" "write" "acme" "app")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"team-delete! missing false"
|
||||
(gitea/team-delete! ga-forge "acme" "devs")
|
||||
false)
|
||||
|
||||
; ── visibility ───────────────────────────────────────────────────────
|
||||
|
||||
(gitea-access-test
|
||||
"visible anon"
|
||||
(gitea/visible-repos ga-forge nil)
|
||||
(list "alice/pub"))
|
||||
(gitea-access-test
|
||||
"visible eve"
|
||||
(gitea/visible-repos ga-forge "eve")
|
||||
(list "alice/pub"))
|
||||
(gitea-access-test
|
||||
"visible bob (collab on sec)"
|
||||
(gitea/visible-repos ga-forge "bob")
|
||||
(list "alice/pub" "alice/sec"))
|
||||
(gitea-access-test
|
||||
"visible alice (owner + org admin)"
|
||||
(gitea/visible-repos ga-forge "alice")
|
||||
(list "acme/app" "acme/site" "alice/pub" "alice/sec"))
|
||||
|
||||
; ── create permission ────────────────────────────────────────────────
|
||||
|
||||
(gitea-access-test
|
||||
"create under self"
|
||||
(gitea/create-allowed? ga-forge "alice" "alice")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"create under other user"
|
||||
(gitea/create-allowed? ga-forge "bob" "alice")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"create anon"
|
||||
(gitea/create-allowed? ga-forge nil "alice")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"org admin creates in org"
|
||||
(gitea/create-allowed? ga-forge "alice" "acme")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"non-admin cannot create in org"
|
||||
(gitea/create-allowed? ga-forge "eve" "acme")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"create under unknown owner"
|
||||
(gitea/create-allowed? ga-forge "alice" "zeb")
|
||||
false)
|
||||
|
||||
; ── tokens ───────────────────────────────────────────────────────────
|
||||
|
||||
(gitea/token-create! ga-forge "alice" "tok-a")
|
||||
(gitea/token-create! ga-forge "bob" "tok-b")
|
||||
(gitea/token-create! ga-forge "eve" "tok-e")
|
||||
|
||||
(gitea-access-test
|
||||
"token resolves user"
|
||||
(gitea/token-user ga-forge "tok-a")
|
||||
"alice")
|
||||
(gitea-access-test "unknown token" (gitea/token-user ga-forge "tok-zzz") nil)
|
||||
(gitea-access-test
|
||||
"token for unknown user"
|
||||
(get (gitea/token-create! ga-forge "zeb" "tok-z") :error)
|
||||
"no-such-user")
|
||||
(gitea/token-create! ga-forge "carol" "tok-c")
|
||||
(gitea/token-revoke! ga-forge "tok-c")
|
||||
(gitea-access-test "revoked token" (gitea/token-user ga-forge "tok-c") nil)
|
||||
|
||||
; ── auth-gated web routes ────────────────────────────────────────────
|
||||
|
||||
; content in the private repo, to browse
|
||||
(define ga-gsec (gitea/repo-git ga-forge "alice" "sec"))
|
||||
(git/add! ga-gsec "secret.txt" "s3cret\n")
|
||||
(git/commit! ga-gsec {:message "hide" :time 1 :author "alice"})
|
||||
|
||||
(define ga-app (gitea/app ga-forge))
|
||||
(define ga-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
|
||||
(define
|
||||
ga-get
|
||||
(fn (target tok) (ga-app (dream-request "GET" target (ga-hdr tok) ""))))
|
||||
(define
|
||||
ga-post
|
||||
(fn
|
||||
(target tok body)
|
||||
(ga-app (dream-request "POST" target (ga-hdr tok) body))))
|
||||
(define
|
||||
ga-put
|
||||
(fn
|
||||
(target tok body)
|
||||
(ga-app (dream-request "PUT" target (ga-hdr tok) body))))
|
||||
(define
|
||||
ga-del
|
||||
(fn
|
||||
(target tok)
|
||||
(ga-app (dream-request "DELETE" target (ga-hdr tok) ""))))
|
||||
|
||||
(gitea-access-test
|
||||
"web: public repo anon"
|
||||
(dream-status (ga-get "/alice/pub" nil))
|
||||
200)
|
||||
(gitea-access-test
|
||||
"web: private repo anon hidden"
|
||||
(dream-status (ga-get "/alice/sec" nil))
|
||||
404)
|
||||
(gitea-access-test
|
||||
"web: private repo stranger hidden"
|
||||
(dream-status (ga-get "/alice/sec" "tok-e"))
|
||||
404)
|
||||
(gitea-access-test
|
||||
"web: private repo collab"
|
||||
(dream-status (ga-get "/alice/sec" "tok-b"))
|
||||
200)
|
||||
(gitea-access-test
|
||||
"web: private repo owner"
|
||||
(dream-status (ga-get "/alice/sec" "tok-a"))
|
||||
200)
|
||||
|
||||
(gitea-access-test
|
||||
"web: private tree anon hidden"
|
||||
(dream-status (ga-get "/alice/sec/tree/main" nil))
|
||||
404)
|
||||
(gitea-access-test
|
||||
"web: private tree collab"
|
||||
(dream-status (ga-get "/alice/sec/tree/main" "tok-b"))
|
||||
200)
|
||||
(gitea-access-test
|
||||
"web: private raw stranger hidden"
|
||||
(dream-status (ga-get "/alice/sec/raw/main/secret.txt" "tok-e"))
|
||||
404)
|
||||
(gitea-access-test
|
||||
"web: private raw collab exact"
|
||||
(dream-resp-body (ga-get "/alice/sec/raw/main/secret.txt" "tok-b"))
|
||||
"s3cret\n")
|
||||
(gitea-access-test
|
||||
"web: private commits owner"
|
||||
(dream-status (ga-get "/alice/sec/commits/main" "tok-a"))
|
||||
200)
|
||||
|
||||
(gitea-access-test
|
||||
"web: index anon shows public"
|
||||
(contains? (dream-resp-body (ga-get "/" nil)) "alice/pub")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"web: index anon hides private"
|
||||
(contains? (dream-resp-body (ga-get "/" nil)) "alice/sec")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"web: index owner shows private"
|
||||
(contains? (dream-resp-body (ga-get "/" "tok-a")) "alice/sec")
|
||||
true)
|
||||
|
||||
(gitea-access-test
|
||||
"web: api repos anon"
|
||||
(dream-json-parse (dream-resp-body (ga-get "/api/repos" nil)))
|
||||
(list "alice/pub"))
|
||||
(gitea-access-test
|
||||
"web: api repos owner"
|
||||
(dream-json-parse (dream-resp-body (ga-get "/api/repos" "tok-a")))
|
||||
(list "acme/app" "acme/site" "alice/pub" "alice/sec"))
|
||||
|
||||
(gitea-access-test
|
||||
"web: create anon 401"
|
||||
(dream-status (ga-post "/api/repos" nil (dream-json-encode {:name "x" :owner "alice"})))
|
||||
401)
|
||||
(gitea-access-test
|
||||
"web: create for other user 403"
|
||||
(dream-status
|
||||
(ga-post "/api/repos" "tok-b" (dream-json-encode {:name "x" :owner "alice"})))
|
||||
403)
|
||||
(gitea-access-test
|
||||
"web: create own 201"
|
||||
(dream-status
|
||||
(ga-post "/api/repos" "tok-a" (dream-json-encode {:name "x" :owner "alice"})))
|
||||
201)
|
||||
(gitea-access-test
|
||||
"web: org admin create 201"
|
||||
(dream-status
|
||||
(ga-post "/api/repos" "tok-a" (dream-json-encode {:name "tools" :owner "acme"})))
|
||||
201)
|
||||
(gitea-access-test
|
||||
"web: org non-admin create 403"
|
||||
(dream-status
|
||||
(ga-post "/api/repos" "tok-e" (dream-json-encode {:name "z" :owner "acme"})))
|
||||
403)
|
||||
(gitea-access-test
|
||||
"web: create unknown owner 400"
|
||||
(dream-status
|
||||
(ga-post "/api/repos" "tok-a" (dream-json-encode {:name "z" :owner "zeb"})))
|
||||
400)
|
||||
|
||||
(gitea-access-test
|
||||
"web: delete anon public 401"
|
||||
(dream-status (ga-del "/api/repos/alice/x" nil))
|
||||
401)
|
||||
(gitea-access-test
|
||||
"web: delete anon private hidden 404"
|
||||
(dream-status (ga-del "/api/repos/alice/sec" nil))
|
||||
404)
|
||||
(gitea-access-test
|
||||
"web: delete stranger private hidden 404"
|
||||
(dream-status (ga-del "/api/repos/alice/sec" "tok-e"))
|
||||
404)
|
||||
(gitea-access-test
|
||||
"web: delete non-admin 403"
|
||||
(dream-status (ga-del "/api/repos/alice/x" "tok-b"))
|
||||
403)
|
||||
(gitea-access-test
|
||||
"web: delete admin 200"
|
||||
(dream-status (ga-del "/api/repos/alice/x" "tok-a"))
|
||||
200)
|
||||
(gitea-access-test
|
||||
"web: deleted repo gone"
|
||||
(dream-status (ga-del "/api/repos/alice/x" "tok-a"))
|
||||
404)
|
||||
|
||||
(gitea-access-test
|
||||
"web: collab put anon 401"
|
||||
(dream-status (ga-put "/api/repos/alice/pub/collab/eve" nil "{}"))
|
||||
401)
|
||||
(gitea-access-test
|
||||
"web: collab put non-admin 403"
|
||||
(dream-status
|
||||
(ga-put
|
||||
"/api/repos/alice/pub/collab/eve"
|
||||
"tok-b"
|
||||
(dream-json-encode {:role "write"})))
|
||||
403)
|
||||
(gitea-access-test
|
||||
"web: collab put on hidden repo 404"
|
||||
(dream-status
|
||||
(ga-put
|
||||
"/api/repos/alice/sec/collab/eve"
|
||||
"tok-e"
|
||||
(dream-json-encode {:role "write"})))
|
||||
404)
|
||||
(gitea-access-test
|
||||
"web: collab put admin 200"
|
||||
(dream-status
|
||||
(ga-put
|
||||
"/api/repos/alice/pub/collab/eve"
|
||||
"tok-a"
|
||||
(dream-json-encode {:role "write"})))
|
||||
200)
|
||||
(gitea-access-test
|
||||
"web: collab granted write"
|
||||
(gitea/can? ga-forge "eve" "write" "alice" "pub")
|
||||
true)
|
||||
(gitea-access-test
|
||||
"web: collab put bad role 400"
|
||||
(dream-status
|
||||
(ga-put
|
||||
"/api/repos/alice/pub/collab/eve"
|
||||
"tok-a"
|
||||
(dream-json-encode {:role "boss"})))
|
||||
400)
|
||||
(gitea-access-test
|
||||
"web: collab put unknown user 400"
|
||||
(dream-status
|
||||
(ga-put
|
||||
"/api/repos/alice/pub/collab/zeb"
|
||||
"tok-a"
|
||||
(dream-json-encode {:role "read"})))
|
||||
400)
|
||||
(gitea-access-test
|
||||
"web: collab delete admin 200"
|
||||
(dream-status (ga-del "/api/repos/alice/pub/collab/eve" "tok-a"))
|
||||
200)
|
||||
(gitea-access-test
|
||||
"web: collab revoked"
|
||||
(gitea/can? ga-forge "eve" "write" "alice" "pub")
|
||||
false)
|
||||
(gitea-access-test
|
||||
"web: collab delete missing 404"
|
||||
(dream-status (ga-del "/api/repos/alice/pub/collab/eve" "tok-a"))
|
||||
404)
|
||||
432
lib/gitea/tests/activity.sx
Normal file
432
lib/gitea/tests/activity.sx
Normal file
@@ -0,0 +1,432 @@
|
||||
; lib/gitea/tests/activity.sx — Phase 6: instrumented activity log, feed
|
||||
; timelines with visibility, follows + dashboard, durable notifications
|
||||
; into per-user inboxes, and the activity web routes.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-markdown!)
|
||||
(content-bootstrap-table!)
|
||||
|
||||
(define gitea-act-pass 0)
|
||||
(define gitea-act-fail 0)
|
||||
(define gitea-act-fails (list))
|
||||
|
||||
(define
|
||||
gitea-act-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-act-pass (+ gitea-act-pass 1))
|
||||
(begin
|
||||
(set! gitea-act-fail (+ gitea-act-fail 1))
|
||||
(set! gitea-act-fails (append gitea-act-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
; ── setup + instrumented history ─────────────────────────────────────
|
||||
; activity ledger (seq/at/actor/verb):
|
||||
; 1 @5 alice create-repo repo:alice/proj (public)
|
||||
; 2 @6 alice create-repo repo:alice/sec (private)
|
||||
; 3 @10 alice open-issue issue:alice/proj#1
|
||||
; 4 @11 bob comment issue:alice/proj#1
|
||||
; 5 @12 bob open-pr pr:alice/proj#2
|
||||
; 6 @13 carol review pr:alice/proj#2
|
||||
; 7 @14 alice merge-pr pr:alice/proj#2
|
||||
; 8 @20 alice open-issue issue:alice/sec#1 (private)
|
||||
; 9 @21 alice open-issue issue:alice/proj#3 (assignee bob)
|
||||
|
||||
(define ga-db (persist/mem-backend))
|
||||
(define ga-forge (gitea/forge ga-db))
|
||||
(gitea/user-create! ga-forge "alice")
|
||||
(gitea/user-create! ga-forge "bob")
|
||||
(gitea/user-create! ga-forge "carol")
|
||||
(gitea/user-create! ga-forge "eve")
|
||||
(gitea/token-create! ga-forge "alice" "tok-a")
|
||||
(gitea/token-create! ga-forge "bob" "tok-b")
|
||||
(gitea/token-create! ga-forge "carol" "tok-c")
|
||||
|
||||
(gitea/repo-create! ga-forge "alice" "proj" {:created-at 5})
|
||||
(gitea/repo-create! ga-forge "alice" "sec" {:created-at 6 :visibility "private"})
|
||||
(gitea/collab-add! ga-forge "alice" "sec" "bob" "read")
|
||||
|
||||
(define ga-g (gitea/repo-git ga-forge "alice" "proj"))
|
||||
(git/add! ga-g "README.md" "base\n")
|
||||
(git/commit! ga-g {:message "c1" :time 1 :author "alice"})
|
||||
(git/branch! ga-g "feat")
|
||||
(git/checkout! ga-g "feat")
|
||||
(git/add! ga-g "f.txt" "feature\n")
|
||||
(git/commit! ga-g {:message "c2" :time 2 :author "bob"})
|
||||
(git/checkout! ga-g "main")
|
||||
|
||||
(gitea/issue-create!
|
||||
ga-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"First issue"
|
||||
"body"
|
||||
{:created-at 10})
|
||||
(gitea/issue-comment!
|
||||
ga-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"bob"
|
||||
"a comment"
|
||||
{:at 11})
|
||||
(gitea/pr-create!
|
||||
ga-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Feature"
|
||||
"feat"
|
||||
"main"
|
||||
""
|
||||
{:created-at 12})
|
||||
(gitea/pr-review!
|
||||
ga-forge
|
||||
"alice"
|
||||
"proj"
|
||||
2
|
||||
"carol"
|
||||
"approve"
|
||||
"lgtm"
|
||||
{:at 13})
|
||||
(gitea/pr-merge! ga-forge "alice" "proj" 2 "alice" {:time 14})
|
||||
(gitea/issue-create!
|
||||
ga-forge
|
||||
"alice"
|
||||
"sec"
|
||||
"alice"
|
||||
"Secret issue"
|
||||
""
|
||||
{:created-at 20})
|
||||
(gitea/issue-create!
|
||||
ga-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"Assigned issue"
|
||||
""
|
||||
{:assignees (list "bob") :created-at 21})
|
||||
|
||||
(gitea-act-test "activity count" (gitea/activity-count ga-forge) 9)
|
||||
|
||||
; failed mutations emit nothing
|
||||
(gitea/issue-create! ga-forge "alice" "none" "alice" "x" "" {})
|
||||
(gitea/pr-review!
|
||||
ga-forge
|
||||
"alice"
|
||||
"proj"
|
||||
2
|
||||
"bob"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
(gitea-act-test
|
||||
"errors emit no activity"
|
||||
(gitea/activity-count ga-forge)
|
||||
9)
|
||||
|
||||
; ── timelines ────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-act-test
|
||||
"anon timeline hides private"
|
||||
(len (gitea/timeline ga-forge nil 50))
|
||||
7)
|
||||
(gitea-act-test
|
||||
"owner timeline sees all"
|
||||
(len (gitea/timeline ga-forge "alice" 50))
|
||||
9)
|
||||
(gitea-act-test
|
||||
"timeline newest first"
|
||||
(get (first (gitea/timeline ga-forge nil 50)) :verb)
|
||||
"open-issue")
|
||||
(gitea-act-test
|
||||
"timeline take"
|
||||
(len (gitea/timeline ga-forge "alice" 2))
|
||||
2)
|
||||
|
||||
(gitea-act-test
|
||||
"repo timeline proj"
|
||||
(len (gitea/repo-timeline ga-forge "alice" "proj" 50))
|
||||
7)
|
||||
(gitea-act-test
|
||||
"repo timeline sec"
|
||||
(len (gitea/repo-timeline ga-forge "alice" "sec" 50))
|
||||
2)
|
||||
(gitea-act-test
|
||||
"repo timeline order"
|
||||
(get (first (gitea/repo-timeline ga-forge "alice" "proj" 50)) :at)
|
||||
21)
|
||||
|
||||
(gitea-act-test
|
||||
"user timeline bob"
|
||||
(len (gitea/user-timeline ga-forge nil "bob" 50))
|
||||
2)
|
||||
(gitea-act-test
|
||||
"user timeline bob order"
|
||||
(get (first (gitea/user-timeline ga-forge nil "bob" 50)) :verb)
|
||||
"open-pr")
|
||||
(gitea-act-test
|
||||
"user timeline alice anon"
|
||||
(len (gitea/user-timeline ga-forge nil "alice" 50))
|
||||
4)
|
||||
(gitea-act-test
|
||||
"user timeline alice as collab"
|
||||
(len (gitea/user-timeline ga-forge "bob" "alice" 50))
|
||||
6)
|
||||
|
||||
; ── follows + dashboard ──────────────────────────────────────────────
|
||||
|
||||
(gitea/follow! ga-forge "carol" "user:alice")
|
||||
(gitea-act-test
|
||||
"follows list"
|
||||
(gitea/follows ga-forge "carol")
|
||||
(list "user:alice"))
|
||||
(gitea-act-test
|
||||
"follow unknown follower"
|
||||
(get (gitea/follow! ga-forge "zeb" "user:alice") :error)
|
||||
"no-such-user")
|
||||
(gitea-act-test
|
||||
"follow unknown user target"
|
||||
(get (gitea/follow! ga-forge "carol" "user:zeb") :error)
|
||||
"no-such-target")
|
||||
(gitea-act-test
|
||||
"follow unknown repo target"
|
||||
(get (gitea/follow! ga-forge "carol" "repo:alice/none") :error)
|
||||
"no-such-target")
|
||||
(gitea-act-test
|
||||
"follow malformed target"
|
||||
(get (gitea/follow! ga-forge "carol" "alice") :error)
|
||||
"no-such-target")
|
||||
|
||||
(gitea-act-test
|
||||
"dashboard follows a user"
|
||||
(len (gitea/dashboard ga-forge "carol" 50))
|
||||
4)
|
||||
(gitea-act-test
|
||||
"dashboard first actor"
|
||||
(get (first (gitea/dashboard ga-forge "carol" 50)) :actor)
|
||||
"alice")
|
||||
|
||||
(gitea/follow! ga-forge "bob" "repo:alice/proj")
|
||||
(gitea-act-test
|
||||
"dashboard follows a repo, excludes own actions"
|
||||
(len (gitea/dashboard ga-forge "bob" 50))
|
||||
5)
|
||||
|
||||
(gitea-act-test
|
||||
"unfollow"
|
||||
(gitea/unfollow! ga-forge "carol" "user:alice")
|
||||
true)
|
||||
(gitea-act-test
|
||||
"dashboard after unfollow"
|
||||
(len (gitea/dashboard ga-forge "carol" 50))
|
||||
0)
|
||||
(gitea-act-test
|
||||
"unfollow twice"
|
||||
(gitea/unfollow! ga-forge "carol" "user:alice")
|
||||
false)
|
||||
|
||||
; ── notifications ────────────────────────────────────────────────────
|
||||
|
||||
(gitea-act-test
|
||||
"recipients of a comment"
|
||||
(gitea/notify-recipients
|
||||
ga-forge
|
||||
(feed/activity
|
||||
"bob"
|
||||
"comment"
|
||||
"issue:alice/proj#1"
|
||||
11
|
||||
(list "repo:alice/proj")))
|
||||
(list "alice"))
|
||||
(gitea-act-test
|
||||
"recipients of a review"
|
||||
(gitea/notify-recipients
|
||||
ga-forge
|
||||
(feed/activity
|
||||
"carol"
|
||||
"review"
|
||||
"pr:alice/proj#2"
|
||||
13
|
||||
(list "repo:alice/proj")))
|
||||
(list "bob"))
|
||||
(gitea-act-test
|
||||
"recipients exclude the actor"
|
||||
(gitea/notify-recipients
|
||||
ga-forge
|
||||
(feed/activity
|
||||
"alice"
|
||||
"comment"
|
||||
"issue:alice/proj#1"
|
||||
30
|
||||
(list "repo:alice/proj")))
|
||||
(list "bob"))
|
||||
|
||||
(define ga-pend (gitea/pending-notifications ga-forge))
|
||||
(gitea-act-test
|
||||
"pending message count"
|
||||
(len (get ga-pend :messages))
|
||||
4)
|
||||
(gitea-act-test "pending last seq" (get ga-pend :last-seq) 9)
|
||||
|
||||
(define ga-out1 (gitea/notify! ga-forge))
|
||||
(gitea-act-test
|
||||
"notify delivers all"
|
||||
(len (filter (fn (o) (= (first o) "delivered")) ga-out1))
|
||||
4)
|
||||
(gitea-act-test "inbox alice" (gitea/inbox-count ga-forge "alice") 1)
|
||||
(gitea-act-test
|
||||
"inbox alice body"
|
||||
(get (first (gitea/inbox ga-forge "alice")) :body)
|
||||
"bob comment issue:alice/proj#1")
|
||||
(gitea-act-test "inbox bob" (gitea/inbox-count ga-forge "bob") 3)
|
||||
|
||||
(gitea-act-test "notify rerun is a no-op" (gitea/notify! ga-forge) (list))
|
||||
(gitea-act-test
|
||||
"inboxes stable after rerun"
|
||||
(gitea/inbox-count ga-forge "bob")
|
||||
3)
|
||||
|
||||
; a fresh comment (carol) notifies the author and the other commenter
|
||||
(gitea/issue-comment!
|
||||
ga-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"carol"
|
||||
"me too"
|
||||
{:at 30})
|
||||
(define ga-out2 (gitea/notify! ga-forge))
|
||||
(gitea-act-test "incremental delivery" (len ga-out2) 2)
|
||||
(gitea-act-test
|
||||
"inbox alice grows"
|
||||
(gitea/inbox-count ga-forge "alice")
|
||||
2)
|
||||
(gitea-act-test
|
||||
"inbox bob grows"
|
||||
(gitea/inbox-count ga-forge "bob")
|
||||
4)
|
||||
|
||||
; ── web ──────────────────────────────────────────────────────────────
|
||||
|
||||
(define ga-app (gitea/app ga-forge))
|
||||
(define ga-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
|
||||
(define
|
||||
ga-get
|
||||
(fn (target tok) (ga-app (dream-request "GET" target (ga-hdr tok) ""))))
|
||||
(define
|
||||
ga-post
|
||||
(fn
|
||||
(target tok body)
|
||||
(ga-app (dream-request "POST" target (ga-hdr tok) body))))
|
||||
(define
|
||||
ga-del
|
||||
(fn
|
||||
(target tok)
|
||||
(ga-app (dream-request "DELETE" target (ga-hdr tok) ""))))
|
||||
|
||||
(gitea-act-test
|
||||
"activity page 200"
|
||||
(dream-status (ga-get "/activity" nil))
|
||||
200)
|
||||
(gitea-act-test
|
||||
"activity page shows merges"
|
||||
(contains? (dream-resp-body (ga-get "/activity" nil)) "merge-pr")
|
||||
true)
|
||||
(gitea-act-test
|
||||
"activity page hides private from anon"
|
||||
(contains? (dream-resp-body (ga-get "/activity" nil)) "alice/sec")
|
||||
false)
|
||||
(gitea-act-test
|
||||
"activity page shows private to owner"
|
||||
(contains? (dream-resp-body (ga-get "/activity" "tok-a")) "alice/sec")
|
||||
true)
|
||||
|
||||
(gitea-act-test
|
||||
"repo activity page 200"
|
||||
(dream-status (ga-get "/alice/proj/activity" nil))
|
||||
200)
|
||||
(gitea-act-test
|
||||
"repo activity shows pr open"
|
||||
(contains? (dream-resp-body (ga-get "/alice/proj/activity" nil)) "open-pr")
|
||||
true)
|
||||
(gitea-act-test
|
||||
"private repo activity anon 404"
|
||||
(dream-status (ga-get "/alice/sec/activity" nil))
|
||||
404)
|
||||
(gitea-act-test
|
||||
"private repo activity collab 200"
|
||||
(dream-status (ga-get "/alice/sec/activity" "tok-b"))
|
||||
200)
|
||||
|
||||
(gitea-act-test
|
||||
"api user activity len"
|
||||
(len
|
||||
(dream-json-parse
|
||||
(dream-resp-body (ga-get "/api/users/bob/activity" nil))))
|
||||
2)
|
||||
(gitea-act-test
|
||||
"api user activity unknown 404"
|
||||
(dream-status (ga-get "/api/users/zeb/activity" nil))
|
||||
404)
|
||||
|
||||
(gitea-act-test
|
||||
"api dashboard anon 401"
|
||||
(dream-status (ga-get "/api/dashboard" nil))
|
||||
401)
|
||||
(gitea-act-test
|
||||
"api dashboard bob"
|
||||
(len
|
||||
(dream-json-parse (dream-resp-body (ga-get "/api/dashboard" "tok-b"))))
|
||||
6)
|
||||
|
||||
(gitea-act-test
|
||||
"api follow anon 401"
|
||||
(dream-status (ga-post "/api/follow" nil (dream-json-encode {:target "user:bob"})))
|
||||
401)
|
||||
(gitea-act-test
|
||||
"api follow 200"
|
||||
(dream-status
|
||||
(ga-post "/api/follow" "tok-c" (dream-json-encode {:target "user:bob"})))
|
||||
200)
|
||||
(gitea-act-test
|
||||
"api follow recorded"
|
||||
(gitea/follows ga-forge "carol")
|
||||
(list "user:bob"))
|
||||
(gitea-act-test
|
||||
"api follow bad target 400"
|
||||
(dream-status
|
||||
(ga-post "/api/follow" "tok-c" (dream-json-encode {:target "nope"})))
|
||||
400)
|
||||
(gitea-act-test
|
||||
"api unfollow 200"
|
||||
(dream-status (ga-del "/api/follow/user:bob" "tok-c"))
|
||||
200)
|
||||
(gitea-act-test
|
||||
"api unfollow missing 404"
|
||||
(dream-status (ga-del "/api/follow/user:bob" "tok-c"))
|
||||
404)
|
||||
|
||||
(gitea-act-test
|
||||
"api notifications anon 401"
|
||||
(dream-status (ga-get "/api/notifications" nil))
|
||||
401)
|
||||
(gitea-act-test
|
||||
"api notifications bodies"
|
||||
(contains?
|
||||
(dream-json-parse
|
||||
(dream-resp-body (ga-get "/api/notifications" "tok-a")))
|
||||
"bob comment issue:alice/proj#1")
|
||||
true)
|
||||
|
||||
(gitea-act-test
|
||||
"api notify run anon 401"
|
||||
(dream-status (ga-post "/api/notify/run" nil "{}"))
|
||||
401)
|
||||
(gitea-act-test
|
||||
"api notify run 200"
|
||||
(dream-status (ga-post "/api/notify/run" "tok-a" "{}"))
|
||||
200)
|
||||
371
lib/gitea/tests/fed.sx
Normal file
371
lib/gitea/tests/fed.sx
Normal file
@@ -0,0 +1,371 @@
|
||||
; lib/gitea/tests/fed.sx — Phase 8: ForgeFed. Two in-memory forges:
|
||||
; AP actor docs + outbox, trust-gated inbox with provenance log and
|
||||
; materialized federated issues/comments/PRs (proxy users), cursor-based
|
||||
; outbound delivery, cross-instance repo mirrors, federated timeline.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-markdown!)
|
||||
(content-bootstrap-table!)
|
||||
|
||||
(define gitea-fed-pass 0)
|
||||
(define gitea-fed-fail 0)
|
||||
(define gitea-fed-fails (list))
|
||||
|
||||
(define
|
||||
gitea-fed-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-fed-pass (+ gitea-fed-pass 1))
|
||||
(begin
|
||||
(set! gitea-fed-fail (+ gitea-fed-fail 1))
|
||||
(set! gitea-fed-fails (append gitea-fed-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
; ── forge A ──────────────────────────────────────────────────────────
|
||||
|
||||
(define gf-dbA (persist/mem-backend))
|
||||
(define gf-A (gitea/forge gf-dbA))
|
||||
(gitea/instance! gf-A "forge-a")
|
||||
(gitea/user-create! gf-A "alice")
|
||||
(gitea/org-create! gf-A "acme")
|
||||
(gitea/repo-create! gf-A "alice" "lib" {:created-at 1})
|
||||
(gitea/repo-create! gf-A "alice" "hid" {:created-at 2 :visibility "private"})
|
||||
|
||||
(define gf-gA (gitea/repo-git gf-A "alice" "lib"))
|
||||
(git/add! gf-gA "README.md" "the lib\n")
|
||||
(git/commit! gf-gA {:message "c1" :time 3 :author "alice"})
|
||||
|
||||
(gitea/issue-create!
|
||||
gf-A
|
||||
"alice"
|
||||
"lib"
|
||||
"alice"
|
||||
"Public issue"
|
||||
"hello"
|
||||
{:created-at 4})
|
||||
(gitea/issue-create!
|
||||
gf-A
|
||||
"alice"
|
||||
"hid"
|
||||
"alice"
|
||||
"Hidden issue"
|
||||
"shh"
|
||||
{:created-at 5})
|
||||
|
||||
; ── forge B ──────────────────────────────────────────────────────────
|
||||
|
||||
(define gf-dbB (persist/mem-backend))
|
||||
(define gf-B (gitea/forge gf-dbB))
|
||||
(gitea/instance! gf-B "forge-b")
|
||||
(gitea/user-create! gf-B "bob")
|
||||
(gitea/repo-create! gf-B "bob" "hub" {:created-at 10})
|
||||
|
||||
(define gf-gB (gitea/repo-git gf-B "bob" "hub"))
|
||||
(git/add! gf-gB "main.txt" "hub main\n")
|
||||
(git/commit! gf-gB {:message "h1" :time 11 :author "bob"})
|
||||
(git/branch! gf-gB "feat")
|
||||
(git/checkout! gf-gB "feat")
|
||||
(git/add! gf-gB "feat.txt" "hub feat\n")
|
||||
(git/commit! gf-gB {:message "h2" :time 12 :author "bob"})
|
||||
(git/checkout! gf-gB "main")
|
||||
|
||||
(define gf-appA (gitea/app gf-A))
|
||||
(define gf-appB (gitea/app gf-B))
|
||||
(gitea/peer-register! gf-B "forge-a" gf-appA nil)
|
||||
(gitea/peer-register! gf-A "forge-b" gf-appB nil)
|
||||
|
||||
; ── identity + actor documents ───────────────────────────────────────
|
||||
|
||||
(gitea-fed-test "instance id" (gitea/instance-id gf-A) "forge-a")
|
||||
(gitea-fed-test
|
||||
"actor id"
|
||||
(gitea/actor-id gf-A "user:alice")
|
||||
"forge-a/user:alice")
|
||||
|
||||
(gitea-fed-test
|
||||
"ap user type"
|
||||
(get (gitea/ap-user gf-A "alice") :type)
|
||||
"Person")
|
||||
(gitea-fed-test
|
||||
"ap user id"
|
||||
(get (gitea/ap-user gf-A "alice") :id)
|
||||
"forge-a/user:alice")
|
||||
(gitea-fed-test
|
||||
"ap org type"
|
||||
(get (gitea/ap-user gf-A "acme") :type)
|
||||
"Group")
|
||||
(gitea-fed-test "ap user missing" (gitea/ap-user gf-A "zeb") nil)
|
||||
|
||||
(gitea-fed-test
|
||||
"ap repo type"
|
||||
(get (gitea/ap-repo gf-A "alice" "lib") :type)
|
||||
"Repository")
|
||||
(gitea-fed-test
|
||||
"ap repo attribution"
|
||||
(get (gitea/ap-repo gf-A "alice" "lib") :attributedTo)
|
||||
"forge-a/user:alice")
|
||||
(gitea-fed-test
|
||||
"ap repo clone endpoint"
|
||||
(get (gitea/ap-repo gf-A "alice" "lib") :clone)
|
||||
"/alice/lib/info/refs")
|
||||
|
||||
(gitea-fed-test
|
||||
"outbox is ap-shaped"
|
||||
(get (first (gitea/ap-outbox gf-A "alice" 10)) :actor)
|
||||
"forge-a/user:alice")
|
||||
(gitea-fed-test
|
||||
"outbox hides private repos"
|
||||
(len (gitea/ap-outbox gf-A "alice" 10))
|
||||
2)
|
||||
|
||||
; ── trust ────────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-fed-test "untrusted by default" (gitea/trusted? gf-B "forge-a") false)
|
||||
(gitea-fed-test
|
||||
"inbox rejects untrusted"
|
||||
(get (gitea/fed-receive! gf-B "forge-a" {:verb "open-issue"}) :error)
|
||||
"untrusted-peer")
|
||||
(gitea-fed-test
|
||||
"rejected activity not logged"
|
||||
(len (gitea/fed-log gf-B))
|
||||
0)
|
||||
|
||||
(gitea/trust! gf-B "forge-a")
|
||||
(gitea-fed-test "trusted after trust!" (gitea/trusted? gf-B "forge-a") true)
|
||||
(gitea-fed-test "trusted peers" (gitea/trusted-peers gf-B) (list "forge-a"))
|
||||
|
||||
; ── inbound materialization ──────────────────────────────────────────
|
||||
|
||||
(define gf-r1 (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :detail {:title "Fed issue" :body "opened from forge-a"} :object "issue:bob/hub#0" :at 50 :tags (list "repo:bob/hub") :verb "open-issue"}))
|
||||
|
||||
(gitea-fed-test "fed issue accepted" (get gf-r1 :materialized) "issue")
|
||||
(gitea-fed-test "fed issue number" (get gf-r1 :number) 1)
|
||||
(gitea-fed-test
|
||||
"proxy user created"
|
||||
(gitea/owner-exists? gf-B "alice@forge-a")
|
||||
true)
|
||||
(gitea-fed-test
|
||||
"fed issue author"
|
||||
(get (gitea/issue-get gf-B "bob" "hub" 1) :author)
|
||||
"alice@forge-a")
|
||||
(gitea-fed-test
|
||||
"fed issue title"
|
||||
(get (gitea/issue-get gf-B "bob" "hub" 1) :title)
|
||||
"Fed issue")
|
||||
(gitea-fed-test
|
||||
"fed log provenance"
|
||||
(get (first (gitea/fed-log gf-B)) :origin)
|
||||
"forge-a")
|
||||
|
||||
(define gf-owners-before (len (gitea/owners gf-B)))
|
||||
(define gf-r2 (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :detail {:body "following up"} :object "issue:bob/hub#1" :at 51 :tags (list "repo:bob/hub") :verb "comment"}))
|
||||
|
||||
(gitea-fed-test "fed comment accepted" (get gf-r2 :materialized) "comment")
|
||||
(gitea-fed-test
|
||||
"fed comment recorded"
|
||||
(len (get (gitea/issue-get gf-B "bob" "hub" 1) :comments))
|
||||
1)
|
||||
(gitea-fed-test
|
||||
"proxy user reused"
|
||||
(len (gitea/owners gf-B))
|
||||
gf-owners-before)
|
||||
|
||||
(define gf-r3 (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :detail {:source "feat" :title "Fed PR" :body "take my branch" :target "main"} :object "pr:bob/hub#0" :at 52 :tags (list "repo:bob/hub") :verb "open-pr"}))
|
||||
|
||||
(gitea-fed-test "fed pr accepted" (get gf-r3 :materialized) "pr")
|
||||
(gitea-fed-test
|
||||
"fed pr author"
|
||||
(get (gitea/pr-get gf-B "bob" "hub" (get gf-r3 :number)) :author)
|
||||
"alice@forge-a")
|
||||
(gitea-fed-test
|
||||
"fed pr branches"
|
||||
(get (gitea/pr-get gf-B "bob" "hub" (get gf-r3 :number)) :source)
|
||||
"feat")
|
||||
|
||||
(gitea-fed-test
|
||||
"unknown verb still logged"
|
||||
(get (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :object "repo:bob/hub" :at 53 :tags (list "repo:bob/hub") :verb "star"}) :materialized)
|
||||
"none")
|
||||
(gitea-fed-test
|
||||
"comment with bad object"
|
||||
(get (gitea/fed-receive! gf-B "forge-a" {:actor "forge-a/user:alice" :object "nonsense" :at 54 :verb "comment"}) :error)
|
||||
"bad-object")
|
||||
(gitea-fed-test "fed log grows" (len (gitea/fed-log gf-B)) 5)
|
||||
|
||||
; ── inbox over the wire ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gf-postB
|
||||
(fn
|
||||
(body)
|
||||
(gf-appB (dream-request "POST" "/api/ap/inbox" {} body))))
|
||||
|
||||
(gitea-fed-test
|
||||
"web inbox missing peer 400"
|
||||
(dream-status (gf-postB (dream-json-encode {:activity {}})))
|
||||
400)
|
||||
(gitea-fed-test
|
||||
"web inbox untrusted 403"
|
||||
(dream-status (gf-postB (dream-json-encode {:activity {} :peer "forge-x"})))
|
||||
403)
|
||||
(gitea-fed-test
|
||||
"web inbox trusted 200"
|
||||
(dream-status (gf-postB (dream-json-encode {:activity {:actor "forge-a/user:alice" :detail {:body "via web"} :object "issue:bob/hub#1" :at 55 :tags (list "repo:bob/hub") :verb "comment"} :peer "forge-a"})))
|
||||
200)
|
||||
(gitea-fed-test
|
||||
"web inbox materialized"
|
||||
(len (get (gitea/issue-get gf-B "bob" "hub" 1) :comments))
|
||||
2)
|
||||
|
||||
; ── mirrors (cross-instance repo follow) ─────────────────────────────
|
||||
|
||||
(define gf-m1 (gitea/mirror! gf-B "bob" "libmirror" "forge-a" "alice" "lib"))
|
||||
(gitea-fed-test "mirror clones" (get gf-m1 :owner) "bob")
|
||||
(gitea-fed-test
|
||||
"mirror branch matches upstream"
|
||||
(git/branch-get (gitea/repo-git gf-B "bob" "libmirror") "main")
|
||||
(git/branch-get gf-gA "main"))
|
||||
(gitea-fed-test
|
||||
"mirror recorded"
|
||||
(get (gitea/mirror-of gf-B "bob" "libmirror") :peer)
|
||||
"forge-a")
|
||||
(gitea-fed-test "mirrors list" (gitea/mirrors gf-B) (list "bob/libmirror"))
|
||||
|
||||
(git/checkout! gf-gA "main")
|
||||
(git/add! gf-gA "more.txt" "more\n")
|
||||
(define gf-c2 (git/commit! gf-gA {:message "c2" :time 6 :author "alice"}))
|
||||
|
||||
(gitea-fed-test
|
||||
"mirror-sync pulls new commits"
|
||||
(get (gitea/mirror-sync! gf-B "bob" "libmirror") :stored)
|
||||
3)
|
||||
(gitea-fed-test
|
||||
"mirror advanced"
|
||||
(git/branch-get (gitea/repo-git gf-B "bob" "libmirror") "main")
|
||||
gf-c2)
|
||||
|
||||
(gitea/untrust! gf-B "forge-a")
|
||||
(gitea-fed-test
|
||||
"sync of untrusted peer refused"
|
||||
(get (gitea/mirror-sync! gf-B "bob" "libmirror") :error)
|
||||
"untrusted-peer")
|
||||
(gitea-fed-test
|
||||
"mirror of untrusted peer refused"
|
||||
(get (gitea/mirror! gf-B "bob" "another" "forge-a" "alice" "lib") :error)
|
||||
"untrusted-peer")
|
||||
(gitea/trust! gf-B "forge-a")
|
||||
(gitea-fed-test
|
||||
"sync ok after re-trust"
|
||||
(get (gitea/mirror-sync! gf-B "bob" "libmirror") :stored)
|
||||
0)
|
||||
(gitea-fed-test
|
||||
"non-mirror sync refused"
|
||||
(get (gitea/mirror-sync! gf-B "bob" "hub") :error)
|
||||
"not-a-mirror")
|
||||
|
||||
; ── outbound delivery ────────────────────────────────────────────────
|
||||
|
||||
(gitea/trust! gf-A "forge-b")
|
||||
(define gf-d1 (gitea/fed-deliver! gf-A))
|
||||
|
||||
; A's public activity so far: create-repo lib, open-issue lib#1,
|
||||
; comment... none; private create/issue excluded
|
||||
(gitea-fed-test
|
||||
"deliver pushes public only"
|
||||
(get gf-d1 :delivered)
|
||||
2)
|
||||
(gitea-fed-test
|
||||
"deliver reaches trusted peers"
|
||||
(get gf-d1 :peers)
|
||||
(list "forge-b"))
|
||||
(gitea-fed-test
|
||||
"peer logged deliveries"
|
||||
(len (gitea/fed-log gf-B))
|
||||
8)
|
||||
(gitea-fed-test
|
||||
"delivered origin"
|
||||
(get
|
||||
(first
|
||||
(filter
|
||||
(fn (e) (= (get (get e :activity) :verb) "create-repo"))
|
||||
(gitea/fed-log gf-B)))
|
||||
:origin)
|
||||
"forge-a")
|
||||
|
||||
(gitea-fed-test
|
||||
"second deliver is a no-op"
|
||||
(get (gitea/fed-deliver! gf-A) :delivered)
|
||||
0)
|
||||
|
||||
(gitea/issue-comment!
|
||||
gf-A
|
||||
"alice"
|
||||
"lib"
|
||||
1
|
||||
"alice"
|
||||
"one more"
|
||||
{:at 7})
|
||||
(gitea-fed-test
|
||||
"incremental deliver"
|
||||
(get (gitea/fed-deliver! gf-A) :delivered)
|
||||
1)
|
||||
|
||||
; ── federated timeline ───────────────────────────────────────────────
|
||||
|
||||
(define gf-tl (gitea/fed-timeline gf-B nil 100))
|
||||
(gitea-fed-test
|
||||
"fed timeline mixes local and foreign"
|
||||
(>
|
||||
(len (filter (fn (a) (= (get a :origin) "forge-a")) gf-tl))
|
||||
0)
|
||||
true)
|
||||
(gitea-fed-test
|
||||
"local activities carry no origin"
|
||||
(>
|
||||
(len
|
||||
(filter
|
||||
(fn
|
||||
(a)
|
||||
(and (nil? (get a :origin)) (= (get a :verb) "create-repo")))
|
||||
gf-tl))
|
||||
0)
|
||||
true)
|
||||
|
||||
; ── actor docs over the wire ─────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gf-getA
|
||||
(fn (target) (gf-appA (dream-request "GET" target {} ""))))
|
||||
|
||||
(gitea-fed-test
|
||||
"web ap user 200"
|
||||
(dream-status (gf-getA "/api/ap/users/alice"))
|
||||
200)
|
||||
(gitea-fed-test
|
||||
"web ap user type"
|
||||
(get
|
||||
(dream-json-parse (dream-resp-body (gf-getA "/api/ap/users/alice")))
|
||||
:type)
|
||||
"Person")
|
||||
(gitea-fed-test
|
||||
"web ap user missing 404"
|
||||
(dream-status (gf-getA "/api/ap/users/zeb"))
|
||||
404)
|
||||
(gitea-fed-test
|
||||
"web ap repo 200"
|
||||
(dream-status (gf-getA "/api/ap/repos/alice/lib"))
|
||||
200)
|
||||
(gitea-fed-test
|
||||
"web ap private repo hidden"
|
||||
(dream-status (gf-getA "/api/ap/repos/alice/hid"))
|
||||
404)
|
||||
(gitea-fed-test
|
||||
"web outbox 200"
|
||||
(dream-status (gf-getA "/api/ap/users/alice/outbox"))
|
||||
200)
|
||||
(gitea-fed-test
|
||||
"web fed timeline 200"
|
||||
(dream-status (gf-getA "/api/fed/timeline"))
|
||||
200)
|
||||
571
lib/gitea/tests/issues.sx
Normal file
571
lib/gitea/tests/issues.sx
Normal file
@@ -0,0 +1,571 @@
|
||||
; lib/gitea/tests/issues.sx — Phase 4: issue CRUD, comments, labels,
|
||||
; assignees, content-document bodies (Markdown round-trip + HTML render),
|
||||
; the derived relations graph, repo-delete purge regression, and the
|
||||
; issue web routes + JSON API.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-markdown!)
|
||||
(content-bootstrap-table!)
|
||||
|
||||
(define gitea-issues-pass 0)
|
||||
(define gitea-issues-fail 0)
|
||||
(define gitea-issues-fails (list))
|
||||
|
||||
(define
|
||||
gitea-issues-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-issues-pass (+ gitea-issues-pass 1))
|
||||
(begin
|
||||
(set! gitea-issues-fail (+ gitea-issues-fail 1))
|
||||
(set!
|
||||
gitea-issues-fails
|
||||
(append gitea-issues-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
; ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-issues-test "pad8" (gitea/pad8 7) "00000007")
|
||||
(gitea-issues-test "pad8 wide" (gitea/pad8 12345) "00012345")
|
||||
(gitea-issues-test "digits? yes" (gitea/digits? "123") true)
|
||||
(gitea-issues-test "digits? no" (gitea/digits? "12a") false)
|
||||
(gitea-issues-test "digits? empty" (gitea/digits? "") false)
|
||||
|
||||
; ── setup ────────────────────────────────────────────────────────────
|
||||
|
||||
(define gi-db (persist/mem-backend))
|
||||
(define gi-forge (gitea/forge gi-db))
|
||||
(gitea/user-create! gi-forge "alice")
|
||||
(gitea/user-create! gi-forge "bob")
|
||||
(gitea/user-create! gi-forge "carol")
|
||||
(gitea/user-create! gi-forge "eve")
|
||||
(gitea/repo-create! gi-forge "alice" "proj" {})
|
||||
(gitea/repo-create! gi-forge "alice" "sec" {:visibility "private"})
|
||||
(gitea/collab-add! gi-forge "alice" "sec" "bob" "read")
|
||||
(gitea/token-create! gi-forge "alice" "tok-a")
|
||||
(gitea/token-create! gi-forge "bob" "tok-b")
|
||||
(gitea/token-create! gi-forge "eve" "tok-e")
|
||||
|
||||
; ── issue CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gi-i1
|
||||
(gitea/issue-create!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"Crash on boot"
|
||||
"It crashes."
|
||||
{:created-at 10}))
|
||||
|
||||
(gitea-issues-test "create number" (get gi-i1 :number) 1)
|
||||
(gitea-issues-test "create state" (get gi-i1 :state) "open")
|
||||
(gitea-issues-test "create title" (get gi-i1 :title) "Crash on boot")
|
||||
(gitea-issues-test "create author" (get gi-i1 :author) "alice")
|
||||
(gitea-issues-test "create created-at" (get gi-i1 :created-at) 10)
|
||||
|
||||
(define
|
||||
gi-i2
|
||||
(gitea/issue-create!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Add docs"
|
||||
"Docs please."
|
||||
{}))
|
||||
(gitea-issues-test "second number" (get gi-i2 :number) 2)
|
||||
|
||||
(gitea-issues-test
|
||||
"issue-get"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 1) :title)
|
||||
"Crash on boot")
|
||||
(gitea-issues-test
|
||||
"issues list"
|
||||
(gitea/issues gi-forge "alice" "proj")
|
||||
(list 1 2))
|
||||
(gitea-issues-test
|
||||
"issue-records len"
|
||||
(len (gitea/issue-records gi-forge "alice" "proj"))
|
||||
2)
|
||||
|
||||
(gitea-issues-test
|
||||
"create on missing repo"
|
||||
(get
|
||||
(gitea/issue-create! gi-forge "alice" "none" "alice" "t" "" {})
|
||||
:error)
|
||||
"no-such-repo")
|
||||
(gitea-issues-test
|
||||
"create by missing user"
|
||||
(get
|
||||
(gitea/issue-create! gi-forge "alice" "proj" "zeb" "t" "" {})
|
||||
:error)
|
||||
"no-such-user")
|
||||
(gitea-issues-test
|
||||
"create empty title"
|
||||
(get
|
||||
(gitea/issue-create! gi-forge "alice" "proj" "alice" "" "" {})
|
||||
:error)
|
||||
"empty-title")
|
||||
|
||||
(gitea/issue-close! gi-forge "alice" "proj" 2)
|
||||
(gitea-issues-test
|
||||
"close!"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 2) :state)
|
||||
"closed")
|
||||
(gitea/issue-reopen! gi-forge "alice" "proj" 2)
|
||||
(gitea-issues-test
|
||||
"reopen!"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 2) :state)
|
||||
"open")
|
||||
(gitea-issues-test
|
||||
"close missing"
|
||||
(gitea/issue-close! gi-forge "alice" "proj" 99)
|
||||
nil)
|
||||
(gitea/issue-close! gi-forge "alice" "proj" 2)
|
||||
|
||||
; ── comments ─────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-issues-test
|
||||
"comment author"
|
||||
(get
|
||||
(gitea/issue-comment!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"bob"
|
||||
"Repro *here*."
|
||||
{:at 11})
|
||||
:author)
|
||||
"bob")
|
||||
(gitea/issue-comment!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"carol"
|
||||
"Same for me."
|
||||
{:at 12})
|
||||
|
||||
(gitea-issues-test
|
||||
"comments appended"
|
||||
(len (get (gitea/issue-get gi-forge "alice" "proj" 1) :comments))
|
||||
2)
|
||||
(gitea-issues-test
|
||||
"comment order"
|
||||
(get
|
||||
(first
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 1) :comments))
|
||||
:body)
|
||||
"Repro *here*.")
|
||||
(gitea-issues-test
|
||||
"comment on missing issue"
|
||||
(get
|
||||
(gitea/issue-comment!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
99
|
||||
"bob"
|
||||
"x"
|
||||
{})
|
||||
:error)
|
||||
"no-such-issue")
|
||||
(gitea-issues-test
|
||||
"comment by missing user"
|
||||
(get
|
||||
(gitea/issue-comment!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"zeb"
|
||||
"x"
|
||||
{})
|
||||
:error)
|
||||
"no-such-user")
|
||||
|
||||
; ── labels / assignees ───────────────────────────────────────────────
|
||||
|
||||
(gitea/issue-label! gi-forge "alice" "proj" 1 "ui")
|
||||
(gitea/issue-label! gi-forge "alice" "proj" 1 "bug")
|
||||
(gitea-issues-test
|
||||
"labels sorted"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
|
||||
(list "bug" "ui"))
|
||||
(gitea/issue-label! gi-forge "alice" "proj" 1 "bug")
|
||||
(gitea-issues-test
|
||||
"label idempotent"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
|
||||
(list "bug" "ui"))
|
||||
(gitea/issue-unlabel! gi-forge "alice" "proj" 1 "bug")
|
||||
(gitea-issues-test
|
||||
"unlabel"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
|
||||
(list "ui"))
|
||||
(gitea-issues-test
|
||||
"invalid label"
|
||||
(get (gitea/issue-label! gi-forge "alice" "proj" 1 "") :error)
|
||||
"invalid-label")
|
||||
|
||||
(gitea/issue-assign! gi-forge "alice" "proj" 2 "carol")
|
||||
(gitea-issues-test
|
||||
"assign"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 2) :assignees)
|
||||
(list "carol"))
|
||||
(gitea-issues-test
|
||||
"assign unknown user"
|
||||
(get (gitea/issue-assign! gi-forge "alice" "proj" 2 "zeb") :error)
|
||||
"no-such-user")
|
||||
|
||||
; ── views ────────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-issues-test
|
||||
"issues-open"
|
||||
(len (gitea/issues-open gi-forge "alice" "proj"))
|
||||
1)
|
||||
(gitea-issues-test
|
||||
"issues-closed"
|
||||
(len (gitea/issues-closed gi-forge "alice" "proj"))
|
||||
1)
|
||||
(gitea-issues-test
|
||||
"issues-with-label"
|
||||
(map
|
||||
(fn (r) (get r :number))
|
||||
(gitea/issues-with-label gi-forge "alice" "proj" "ui"))
|
||||
(list 1))
|
||||
(gitea-issues-test
|
||||
"issues-assigned"
|
||||
(map
|
||||
(fn (r) (get r :number))
|
||||
(gitea/issues-assigned gi-forge "alice" "proj" "carol"))
|
||||
(list 2))
|
||||
|
||||
; ── content documents ────────────────────────────────────────────────
|
||||
|
||||
(define gi-md "# Heading\n\npara text.\n\n```sx\n(+ 1 2)\n```")
|
||||
(define
|
||||
gi-i3
|
||||
(gitea/issue-create!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"With md body"
|
||||
gi-md
|
||||
{}))
|
||||
(define gi-doc (gitea/issue-doc "alice" "proj" gi-i3))
|
||||
|
||||
(gitea-issues-test "issue doc block count" (content/count gi-doc) 3)
|
||||
(gitea-issues-test
|
||||
"issue doc types"
|
||||
(content/types gi-doc)
|
||||
(list "heading" "text" "code"))
|
||||
(gitea-issues-test
|
||||
"issue html heading"
|
||||
(contains? (gitea/issue-html "alice" "proj" gi-i3) "<h1>Heading</h1>")
|
||||
true)
|
||||
(gitea-issues-test
|
||||
"issue html code block"
|
||||
(contains? (gitea/issue-html "alice" "proj" gi-i3) "<pre><code")
|
||||
true)
|
||||
(gitea-issues-test "markdown round trip" (content/markdown gi-doc) gi-md)
|
||||
(gitea-issues-test
|
||||
"comment md renders"
|
||||
(contains? (gitea/md-html "Repro *here*." "t1") "<p>")
|
||||
true)
|
||||
|
||||
; ── relations graph ──────────────────────────────────────────────────
|
||||
|
||||
(gitea-issues-test
|
||||
"repo issue nodes"
|
||||
(gitea/repo-issue-nodes gi-forge "alice" "proj")
|
||||
(list "issue:alice/proj#1" "issue:alice/proj#2" "issue:alice/proj#3"))
|
||||
(gitea-issues-test
|
||||
"authored by alice"
|
||||
(gitea/user-authored gi-forge "alice")
|
||||
(list "issue:alice/proj#1" "issue:alice/proj#3"))
|
||||
(gitea-issues-test
|
||||
"authored by bob"
|
||||
(gitea/user-authored gi-forge "bob")
|
||||
(list "issue:alice/proj#2"))
|
||||
(gitea-issues-test
|
||||
"assigned to carol"
|
||||
(gitea/user-assigned gi-forge "carol")
|
||||
(list "issue:alice/proj#2"))
|
||||
(gitea-issues-test
|
||||
"label issues"
|
||||
(gitea/label-issues gi-forge "alice" "proj" "ui")
|
||||
(list "issue:alice/proj#1"))
|
||||
(gitea-issues-test
|
||||
"participants incl commenters"
|
||||
(gitea/issue-participants gi-forge "alice" "proj" 1)
|
||||
(list "user:alice" "user:bob" "user:carol"))
|
||||
(gitea-issues-test
|
||||
"participants author+assignee"
|
||||
(gitea/issue-participants gi-forge "alice" "proj" 2)
|
||||
(list "user:bob" "user:carol"))
|
||||
|
||||
; ── repo delete purges issue state ───────────────────────────────────
|
||||
|
||||
(gitea/repo-create! gi-forge "alice" "tmp" {})
|
||||
(gitea/issue-create! gi-forge "alice" "tmp" "alice" "Ghost?" "" {})
|
||||
(gitea/collab-add! gi-forge "alice" "tmp" "carol" "write")
|
||||
(gitea/repo-delete! gi-forge "alice" "tmp")
|
||||
(gitea/repo-create! gi-forge "alice" "tmp" {})
|
||||
|
||||
(gitea-issues-test
|
||||
"recreated repo has no ghost issues"
|
||||
(gitea/issues gi-forge "alice" "tmp")
|
||||
(list))
|
||||
(gitea-issues-test
|
||||
"recreated repo has no ghost collabs"
|
||||
(gitea/collabs gi-forge "alice" "tmp")
|
||||
(list))
|
||||
(gitea-issues-test
|
||||
"issue numbering restarts"
|
||||
(get
|
||||
(gitea/issue-create! gi-forge "alice" "tmp" "alice" "Fresh" "" {})
|
||||
:number)
|
||||
1)
|
||||
(gitea/repo-delete! gi-forge "alice" "tmp")
|
||||
(gitea-issues-test
|
||||
"deleted repo leaves no issue edges"
|
||||
(gitea/repo-issue-nodes gi-forge "alice" "tmp")
|
||||
(list))
|
||||
|
||||
; ── web routes ───────────────────────────────────────────────────────
|
||||
|
||||
(define gi-app (gitea/app gi-forge))
|
||||
(define gi-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
|
||||
(define
|
||||
gi-get
|
||||
(fn (target tok) (gi-app (dream-request "GET" target (gi-hdr tok) ""))))
|
||||
(define
|
||||
gi-post
|
||||
(fn
|
||||
(target tok body)
|
||||
(gi-app (dream-request "POST" target (gi-hdr tok) body))))
|
||||
(define
|
||||
gi-put
|
||||
(fn
|
||||
(target tok body)
|
||||
(gi-app (dream-request "PUT" target (gi-hdr tok) body))))
|
||||
(define
|
||||
gi-del
|
||||
(fn
|
||||
(target tok)
|
||||
(gi-app (dream-request "DELETE" target (gi-hdr tok) ""))))
|
||||
|
||||
(gitea-issues-test
|
||||
"issues page 200"
|
||||
(dream-status (gi-get "/alice/proj/issues" nil))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"issues page lists title"
|
||||
(contains?
|
||||
(dream-resp-body (gi-get "/alice/proj/issues" nil))
|
||||
"Crash on boot")
|
||||
true)
|
||||
(gitea-issues-test
|
||||
"issues page shows state"
|
||||
(contains? (dream-resp-body (gi-get "/alice/proj/issues" nil)) "[closed]")
|
||||
true)
|
||||
|
||||
(gitea-issues-test
|
||||
"issue page 200"
|
||||
(dream-status (gi-get "/alice/proj/issues/1" nil))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"issue page shows author"
|
||||
(contains? (dream-resp-body (gi-get "/alice/proj/issues/1" nil)) "alice")
|
||||
true)
|
||||
(gitea-issues-test
|
||||
"issue page renders body html"
|
||||
(contains?
|
||||
(dream-resp-body (gi-get "/alice/proj/issues/3" nil))
|
||||
"<h1>Heading</h1>")
|
||||
true)
|
||||
(gitea-issues-test
|
||||
"issue page renders comments"
|
||||
(contains?
|
||||
(dream-resp-body (gi-get "/alice/proj/issues/1" nil))
|
||||
"Same for me.")
|
||||
true)
|
||||
(gitea-issues-test
|
||||
"issue page bad number 404"
|
||||
(dream-status (gi-get "/alice/proj/issues/abc" nil))
|
||||
404)
|
||||
(gitea-issues-test
|
||||
"issue page missing 404"
|
||||
(dream-status (gi-get "/alice/proj/issues/99" nil))
|
||||
404)
|
||||
(gitea-issues-test
|
||||
"private issues anon 404"
|
||||
(dream-status (gi-get "/alice/sec/issues" nil))
|
||||
404)
|
||||
(gitea-issues-test
|
||||
"private issues collab 200"
|
||||
(dream-status (gi-get "/alice/sec/issues" "tok-b"))
|
||||
200)
|
||||
|
||||
(gitea-issues-test
|
||||
"api issues len"
|
||||
(len
|
||||
(dream-json-parse
|
||||
(dream-resp-body (gi-get "/api/repos/alice/proj/issues" nil))))
|
||||
3)
|
||||
(gitea-issues-test
|
||||
"api issues first number"
|
||||
(get
|
||||
(first
|
||||
(dream-json-parse
|
||||
(dream-resp-body (gi-get "/api/repos/alice/proj/issues" nil))))
|
||||
:number)
|
||||
1)
|
||||
|
||||
(gitea-issues-test
|
||||
"api create anon 401"
|
||||
(dream-status
|
||||
(gi-post
|
||||
"/api/repos/alice/proj/issues"
|
||||
nil
|
||||
(dream-json-encode {:title "t"})))
|
||||
401)
|
||||
(gitea-issues-test
|
||||
"api create reader 201"
|
||||
(dream-status
|
||||
(gi-post
|
||||
"/api/repos/alice/proj/issues"
|
||||
"tok-e"
|
||||
(dream-json-encode {:title "From eve" :body "hi"})))
|
||||
201)
|
||||
(gitea-issues-test
|
||||
"api created number"
|
||||
(len (gitea/issues gi-forge "alice" "proj"))
|
||||
4)
|
||||
(gitea-issues-test
|
||||
"api create on private hidden 404"
|
||||
(dream-status
|
||||
(gi-post
|
||||
"/api/repos/alice/sec/issues"
|
||||
"tok-e"
|
||||
(dream-json-encode {:title "x"})))
|
||||
404)
|
||||
(gitea-issues-test
|
||||
"api create empty title 400"
|
||||
(dream-status
|
||||
(gi-post
|
||||
"/api/repos/alice/proj/issues"
|
||||
"tok-e"
|
||||
(dream-json-encode {:title ""})))
|
||||
400)
|
||||
|
||||
(gitea-issues-test
|
||||
"api comment 200"
|
||||
(dream-status
|
||||
(gi-post
|
||||
"/api/repos/alice/proj/issues/4/comments"
|
||||
"tok-b"
|
||||
(dream-json-encode {:body "noted"})))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"api comment recorded"
|
||||
(len (get (gitea/issue-get gi-forge "alice" "proj" 4) :comments))
|
||||
1)
|
||||
(gitea-issues-test
|
||||
"api comment anon 401"
|
||||
(dream-status
|
||||
(gi-post
|
||||
"/api/repos/alice/proj/issues/4/comments"
|
||||
nil
|
||||
(dream-json-encode {:body "x"})))
|
||||
401)
|
||||
(gitea-issues-test
|
||||
"api comment missing issue 404"
|
||||
(dream-status
|
||||
(gi-post
|
||||
"/api/repos/alice/proj/issues/99/comments"
|
||||
"tok-b"
|
||||
(dream-json-encode {:body "x"})))
|
||||
404)
|
||||
|
||||
; eve authored #4 and may close it without write; reopen as alice (write)
|
||||
(gitea-issues-test
|
||||
"api close by author 200"
|
||||
(dream-status (gi-post "/api/repos/alice/proj/issues/4/close" "tok-e" "{}"))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"api close applied"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 4) :state)
|
||||
"closed")
|
||||
(gitea-issues-test
|
||||
"api reopen by write 200"
|
||||
(dream-status
|
||||
(gi-post "/api/repos/alice/proj/issues/4/reopen" "tok-a" "{}"))
|
||||
200)
|
||||
|
||||
; issue #5: authored by alice — eve (reader, not author) may not close
|
||||
(gitea/issue-create!
|
||||
gi-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"Owner issue"
|
||||
""
|
||||
{})
|
||||
(gitea-issues-test
|
||||
"api close by stranger 403"
|
||||
(dream-status (gi-post "/api/repos/alice/proj/issues/5/close" "tok-e" "{}"))
|
||||
403)
|
||||
|
||||
(gitea-issues-test
|
||||
"api label put by write 200"
|
||||
(dream-status
|
||||
(gi-put "/api/repos/alice/proj/issues/5/labels/bug" "tok-a" "{}"))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"api label applied"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 5) :labels)
|
||||
(list "bug"))
|
||||
(gitea-issues-test
|
||||
"api label by reader 403"
|
||||
(dream-status
|
||||
(gi-put "/api/repos/alice/proj/issues/5/labels/x" "tok-e" "{}"))
|
||||
403)
|
||||
(gitea-issues-test
|
||||
"api label delete 200"
|
||||
(dream-status (gi-del "/api/repos/alice/proj/issues/5/labels/bug" "tok-a"))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"api label removed"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 5) :labels)
|
||||
(list))
|
||||
|
||||
(gitea-issues-test
|
||||
"api assign 200"
|
||||
(dream-status
|
||||
(gi-put "/api/repos/alice/proj/issues/5/assignees/bob" "tok-a" "{}"))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"api assign applied"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 5) :assignees)
|
||||
(list "bob"))
|
||||
(gitea-issues-test
|
||||
"api assign unknown user 400"
|
||||
(dream-status
|
||||
(gi-put "/api/repos/alice/proj/issues/5/assignees/zeb" "tok-a" "{}"))
|
||||
400)
|
||||
(gitea-issues-test
|
||||
"api unassign 200"
|
||||
(dream-status
|
||||
(gi-del "/api/repos/alice/proj/issues/5/assignees/bob" "tok-a"))
|
||||
200)
|
||||
(gitea-issues-test
|
||||
"api unassign applied"
|
||||
(get (gitea/issue-get gi-forge "alice" "proj" 5) :assignees)
|
||||
(list))
|
||||
865
lib/gitea/tests/pr.sx
Normal file
865
lib/gitea/tests/pr.sx
Normal file
@@ -0,0 +1,865 @@
|
||||
; lib/gitea/tests/pr.sx — Phase 5: PR records, live merge-base diffs,
|
||||
; review threads (latest verdict per reviewer), the durable flow
|
||||
; lifecycle, all four merge shapes (merge/ff/up-to-date/conflicts), the
|
||||
; merge queue, and the PR web routes + JSON API.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-markdown!)
|
||||
(content-bootstrap-table!)
|
||||
|
||||
(define gitea-pr-pass 0)
|
||||
(define gitea-pr-fail 0)
|
||||
(define gitea-pr-fails (list))
|
||||
|
||||
(define
|
||||
gitea-pr-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-pr-pass (+ gitea-pr-pass 1))
|
||||
(begin
|
||||
(set! gitea-pr-fail (+ gitea-pr-fail 1))
|
||||
(set! gitea-pr-fails (append gitea-pr-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
; ── setup: repo with diverged branches ───────────────────────────────
|
||||
|
||||
(define gp-db (persist/mem-backend))
|
||||
(define gp-forge (gitea/forge gp-db))
|
||||
(gitea/user-create! gp-forge "alice")
|
||||
(gitea/user-create! gp-forge "bob")
|
||||
(gitea/user-create! gp-forge "carol")
|
||||
(gitea/user-create! gp-forge "eve")
|
||||
(gitea/repo-create! gp-forge "alice" "proj" {})
|
||||
(gitea/repo-create! gp-forge "alice" "sec" {:visibility "private"})
|
||||
(gitea/token-create! gp-forge "alice" "tok-a")
|
||||
(gitea/token-create! gp-forge "bob" "tok-b")
|
||||
(gitea/token-create! gp-forge "carol" "tok-c")
|
||||
(gitea/token-create! gp-forge "eve" "tok-e")
|
||||
|
||||
(define gp-g (gitea/repo-git gp-forge "alice" "proj"))
|
||||
(git/add! gp-g "README.md" "base\n")
|
||||
(git/add! gp-g "lib.txt" "lib\n")
|
||||
(define gp-c1 (git/commit! gp-g {:message "c1" :time 1 :author "alice"}))
|
||||
(git/branch! gp-g "feat")
|
||||
(git/checkout! gp-g "feat")
|
||||
(git/add! gp-g "feature.txt" "feature line\n")
|
||||
(define gp-c2 (git/commit! gp-g {:message "c2 feature" :time 2 :author "bob"}))
|
||||
(git/checkout! gp-g "main")
|
||||
(git/add! gp-g "other.txt" "other\n")
|
||||
(define gp-c3 (git/commit! gp-g {:message "c3 other" :time 3 :author "alice"}))
|
||||
|
||||
; ── create / validate ────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gp-pr1
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Add feature"
|
||||
"feat"
|
||||
"main"
|
||||
"Adds a *feature*."
|
||||
{:created-at 5}))
|
||||
|
||||
(gitea-pr-test "pr number" (get gp-pr1 :number) 1)
|
||||
(gitea-pr-test "pr state" (get gp-pr1 :state) "open")
|
||||
(gitea-pr-test "pr source" (get gp-pr1 :source) "feat")
|
||||
(gitea-pr-test "pr target" (get gp-pr1 :target) "main")
|
||||
(gitea-pr-test "pr has flow id" (nil? (get gp-pr1 :flow-id)) false)
|
||||
(gitea-pr-test
|
||||
"flow starts at review"
|
||||
(gitea/pr-flow-status gp-forge gp-pr1)
|
||||
"review")
|
||||
|
||||
(gitea-pr-test
|
||||
"unknown source"
|
||||
(get
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"t"
|
||||
"nope"
|
||||
"main"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"no-such-source")
|
||||
(gitea-pr-test
|
||||
"unknown target"
|
||||
(get
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"t"
|
||||
"feat"
|
||||
"nope"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"no-such-target")
|
||||
(gitea-pr-test
|
||||
"same branch"
|
||||
(get
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"t"
|
||||
"main"
|
||||
"main"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"same-branch")
|
||||
(gitea-pr-test
|
||||
"empty title"
|
||||
(get
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
""
|
||||
"feat"
|
||||
"main"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"empty-title")
|
||||
(gitea-pr-test
|
||||
"missing repo"
|
||||
(get
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"none"
|
||||
"bob"
|
||||
"t"
|
||||
"feat"
|
||||
"main"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"no-such-repo")
|
||||
(gitea-pr-test
|
||||
"missing author"
|
||||
(get
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"zeb"
|
||||
"t"
|
||||
"feat"
|
||||
"main"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"no-such-user")
|
||||
|
||||
(gitea-pr-test
|
||||
"prs list"
|
||||
(gitea/prs gp-forge "alice" "proj")
|
||||
(list 1))
|
||||
|
||||
; numbers are shared with issues
|
||||
(gitea-pr-test
|
||||
"shared counter with issues"
|
||||
(get
|
||||
(gitea/issue-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"An issue"
|
||||
""
|
||||
{})
|
||||
:number)
|
||||
2)
|
||||
|
||||
; ── live diff against the merge base ─────────────────────────────────
|
||||
|
||||
(gitea-pr-test
|
||||
"diff added"
|
||||
(get (gitea/pr-diff gp-forge "alice" "proj" 1) :added)
|
||||
(list "feature.txt"))
|
||||
(gitea-pr-test
|
||||
"diff no spurious deletions"
|
||||
(get (gitea/pr-diff gp-forge "alice" "proj" 1) :deleted)
|
||||
(list))
|
||||
(gitea-pr-test
|
||||
"diff unified shows addition"
|
||||
(contains?
|
||||
(gitea/pr-diff-unified gp-forge "alice" "proj" 1)
|
||||
"+feature line")
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"diff of missing pr"
|
||||
(gitea/pr-diff gp-forge "alice" "proj" 99)
|
||||
nil)
|
||||
|
||||
; ── reviews ──────────────────────────────────────────────────────────
|
||||
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"carol"
|
||||
"request-changes"
|
||||
"needs tests"
|
||||
{:at 6})
|
||||
(gitea-pr-test
|
||||
"changes requested blocks approval"
|
||||
(gitea/pr-approved? (gitea/pr-get gp-forge "alice" "proj" 1))
|
||||
false)
|
||||
(gitea-pr-test
|
||||
"flow still at review"
|
||||
(gitea/pr-flow-status
|
||||
gp-forge
|
||||
(gitea/pr-get gp-forge "alice" "proj" 1))
|
||||
"review")
|
||||
|
||||
(gitea-pr-test
|
||||
"author cannot review own pr"
|
||||
(get
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"bob"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"own-pr")
|
||||
(gitea-pr-test
|
||||
"invalid verdict"
|
||||
(get
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"carol"
|
||||
"meh"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"invalid-verdict")
|
||||
(gitea-pr-test
|
||||
"unknown reviewer"
|
||||
(get
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"zeb"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"no-such-user")
|
||||
(gitea-pr-test
|
||||
"review missing pr"
|
||||
(get
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
99
|
||||
"carol"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"no-such-pr")
|
||||
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"carol"
|
||||
"approve"
|
||||
"looks good now"
|
||||
{:at 7})
|
||||
(gitea-pr-test
|
||||
"latest verdict wins"
|
||||
(gitea/pr-approved? (gitea/pr-get gp-forge "alice" "proj" 1))
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"reviews accumulate"
|
||||
(len (get (gitea/pr-get gp-forge "alice" "proj" 1) :reviews))
|
||||
2)
|
||||
(gitea-pr-test
|
||||
"flow advanced to approved"
|
||||
(gitea/pr-flow-status
|
||||
gp-forge
|
||||
(gitea/pr-get gp-forge "alice" "proj" 1))
|
||||
"approved")
|
||||
|
||||
; ── merge: true 3-way ────────────────────────────────────────────────
|
||||
|
||||
(gitea-pr-test
|
||||
"unapproved merge rejected"
|
||||
(get
|
||||
(gitea/pr-merge! gp-forge "alice" "proj" 2 "alice" {})
|
||||
:error)
|
||||
"no-such-pr")
|
||||
|
||||
(define
|
||||
gp-m1
|
||||
(gitea/pr-merge! gp-forge "alice" "proj" 1 "alice" {:time 8}))
|
||||
|
||||
(gitea-pr-test "merge state" (get gp-m1 :state) "merged")
|
||||
(gitea-pr-test "merge cid recorded" (nil? (get gp-m1 :merge-cid)) false)
|
||||
(gitea-pr-test
|
||||
"main moved to merge commit"
|
||||
(git/branch-get gp-g "main")
|
||||
(get gp-m1 :merge-cid))
|
||||
(gitea-pr-test
|
||||
"merge commit has two parents"
|
||||
(git/commit-parents (git/read gp-g (get gp-m1 :merge-cid)))
|
||||
(list gp-c3 gp-c2))
|
||||
(gitea-pr-test
|
||||
"merged tree keeps target file"
|
||||
(get (gitea/tree-at gp-g (get gp-m1 :merge-cid) "other.txt") :kind)
|
||||
"blob")
|
||||
(gitea-pr-test
|
||||
"merged tree gains source file"
|
||||
(get (gitea/tree-at gp-g (get gp-m1 :merge-cid) "feature.txt") :kind)
|
||||
"blob")
|
||||
(gitea-pr-test
|
||||
"flow reports merged"
|
||||
(gitea/pr-flow-status
|
||||
gp-forge
|
||||
(gitea/pr-get gp-forge "alice" "proj" 1))
|
||||
"merged")
|
||||
(gitea-pr-test
|
||||
"merge twice rejected"
|
||||
(get
|
||||
(gitea/pr-merge! gp-forge "alice" "proj" 1 "alice" {})
|
||||
:error)
|
||||
"not-open")
|
||||
(gitea-pr-test
|
||||
"review after merge rejected"
|
||||
(get
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"carol"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
:error)
|
||||
"not-open")
|
||||
|
||||
; ── merge: fast-forward ──────────────────────────────────────────────
|
||||
|
||||
(git/checkout! gp-g "main")
|
||||
(git/branch! gp-g "hot")
|
||||
(git/checkout! gp-g "hot")
|
||||
(git/add! gp-g "hotfix.txt" "fix\n")
|
||||
(define gp-c4 (git/commit! gp-g {:message "hotfix" :time 9 :author "bob"}))
|
||||
(git/checkout! gp-g "main")
|
||||
|
||||
(define
|
||||
gp-pr3
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Hotfix"
|
||||
"hot"
|
||||
"main"
|
||||
""
|
||||
{}))
|
||||
(gitea-pr-test "pr3 number" (get gp-pr3 :number) 3)
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
3
|
||||
"carol"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
(define
|
||||
gp-m3
|
||||
(gitea/pr-merge! gp-forge "alice" "proj" 3 "alice" {}))
|
||||
(gitea-pr-test "ff merge state" (get gp-m3 :state) "merged")
|
||||
(gitea-pr-test
|
||||
"ff moves main to source head"
|
||||
(git/branch-get gp-g "main")
|
||||
gp-c4)
|
||||
(gitea-pr-test "ff merge-cid is source head" (get gp-m3 :merge-cid) gp-c4)
|
||||
|
||||
; ── merge: up-to-date ────────────────────────────────────────────────
|
||||
|
||||
(git/checkout! gp-g "main")
|
||||
(git/branch! gp-g "same")
|
||||
(define
|
||||
gp-pr4
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"No-op"
|
||||
"same"
|
||||
"main"
|
||||
""
|
||||
{}))
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
4
|
||||
"carol"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
(define
|
||||
gp-m4
|
||||
(gitea/pr-merge! gp-forge "alice" "proj" 4 "alice" {}))
|
||||
(gitea-pr-test "up-to-date merge state" (get gp-m4 :state) "merged")
|
||||
(gitea-pr-test "up-to-date leaves main" (git/branch-get gp-g "main") gp-c4)
|
||||
|
||||
; ── merge: conflicts ─────────────────────────────────────────────────
|
||||
|
||||
(git/checkout! gp-g "main")
|
||||
(git/branch! gp-g "conf")
|
||||
(git/checkout! gp-g "conf")
|
||||
(git/add! gp-g "lib.txt" "conf version\n")
|
||||
(git/commit! gp-g {:message "conf change" :time 10 :author "bob"})
|
||||
(git/checkout! gp-g "main")
|
||||
(git/add! gp-g "lib.txt" "main version\n")
|
||||
(git/commit! gp-g {:message "main change" :time 11 :author "alice"})
|
||||
|
||||
(define
|
||||
gp-pr5
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Conflicting"
|
||||
"conf"
|
||||
"main"
|
||||
""
|
||||
{}))
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
5
|
||||
"carol"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
(define
|
||||
gp-m5
|
||||
(gitea/pr-merge! gp-forge "alice" "proj" 5 "alice" {}))
|
||||
(gitea-pr-test "conflict merge errors" (get gp-m5 :error) "conflicts")
|
||||
(gitea-pr-test "conflict paths" (get gp-m5 :conflicts) (list "lib.txt"))
|
||||
(gitea-pr-test
|
||||
"conflict leaves pr open"
|
||||
(get (gitea/pr-get gp-forge "alice" "proj" 5) :state)
|
||||
"open")
|
||||
(gitea-pr-test
|
||||
"conflict leaves flow at approved"
|
||||
(gitea/pr-flow-status
|
||||
gp-forge
|
||||
(gitea/pr-get gp-forge "alice" "proj" 5))
|
||||
"approved")
|
||||
|
||||
; ── close / reopen ───────────────────────────────────────────────────
|
||||
|
||||
(gitea/pr-close! gp-forge "alice" "proj" 5)
|
||||
(gitea-pr-test
|
||||
"close state"
|
||||
(get (gitea/pr-get gp-forge "alice" "proj" 5) :state)
|
||||
"closed")
|
||||
(gitea-pr-test
|
||||
"close cancels flow"
|
||||
(gitea/pr-flow-status
|
||||
gp-forge
|
||||
(gitea/pr-get gp-forge "alice" "proj" 5))
|
||||
"closed")
|
||||
(gitea-pr-test
|
||||
"merge closed pr rejected"
|
||||
(get
|
||||
(gitea/pr-merge! gp-forge "alice" "proj" 5 "alice" {})
|
||||
:error)
|
||||
"not-open")
|
||||
(gitea-pr-test
|
||||
"close twice"
|
||||
(gitea/pr-close! gp-forge "alice" "proj" 5)
|
||||
nil)
|
||||
|
||||
(gitea/pr-reopen! gp-forge "alice" "proj" 5)
|
||||
(gitea-pr-test
|
||||
"reopen state"
|
||||
(get (gitea/pr-get gp-forge "alice" "proj" 5) :state)
|
||||
"open")
|
||||
(gitea-pr-test
|
||||
"reopen restarts lifecycle"
|
||||
(gitea/pr-flow-status
|
||||
gp-forge
|
||||
(gitea/pr-get gp-forge "alice" "proj" 5))
|
||||
"review")
|
||||
|
||||
; ── merge queue ──────────────────────────────────────────────────────
|
||||
|
||||
(git/checkout! gp-g "main")
|
||||
(git/branch! gp-g "q1")
|
||||
(git/checkout! gp-g "q1")
|
||||
(git/add! gp-g "q1.txt" "one\n")
|
||||
(git/commit! gp-g {:message "q1" :time 12 :author "bob"})
|
||||
(git/checkout! gp-g "main")
|
||||
(git/branch! gp-g "q2")
|
||||
(git/checkout! gp-g "q2")
|
||||
(git/add! gp-g "q2.txt" "two\n")
|
||||
(git/commit! gp-g {:message "q2" :time 13 :author "bob"})
|
||||
(git/checkout! gp-g "main")
|
||||
(git/branch! gp-g "q3")
|
||||
(git/checkout! gp-g "q3")
|
||||
(git/add! gp-g "q3.txt" "three\n")
|
||||
(git/commit! gp-g {:message "q3" :time 14 :author "bob"})
|
||||
(git/checkout! gp-g "main")
|
||||
|
||||
(define
|
||||
gp-pr6
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Queue one"
|
||||
"q1"
|
||||
"main"
|
||||
""
|
||||
{}))
|
||||
(define
|
||||
gp-pr7
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Queue two"
|
||||
"q2"
|
||||
"main"
|
||||
""
|
||||
{}))
|
||||
(define
|
||||
gp-pr8
|
||||
(gitea/pr-create!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Queue three"
|
||||
"q3"
|
||||
"main"
|
||||
""
|
||||
{}))
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
6
|
||||
"carol"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
(gitea/pr-review!
|
||||
gp-forge
|
||||
"alice"
|
||||
"proj"
|
||||
7
|
||||
"carol"
|
||||
"approve"
|
||||
""
|
||||
{})
|
||||
; pr8 stays unapproved; pr5 (reopened, approved, conflicting) joins the queue
|
||||
|
||||
(gitea-pr-test
|
||||
"queue starts empty"
|
||||
(gitea/queue gp-forge "alice" "proj")
|
||||
(list))
|
||||
(gitea-pr-test
|
||||
"queue rejects unapproved"
|
||||
(get (gitea/queue-add! gp-forge "alice" "proj" 8) :error)
|
||||
"not-approved")
|
||||
(gitea-pr-test
|
||||
"queue rejects missing"
|
||||
(get (gitea/queue-add! gp-forge "alice" "proj" 99) :error)
|
||||
"no-such-pr")
|
||||
|
||||
(gitea/queue-add! gp-forge "alice" "proj" 6)
|
||||
(gitea/queue-add! gp-forge "alice" "proj" 6)
|
||||
(gitea/queue-add! gp-forge "alice" "proj" 7)
|
||||
(gitea/queue-add! gp-forge "alice" "proj" 5)
|
||||
(gitea-pr-test
|
||||
"queue dedups"
|
||||
(gitea/queue gp-forge "alice" "proj")
|
||||
(list 6 7 5))
|
||||
|
||||
(define gp-qres (gitea/queue-process! gp-forge "alice" "proj" "alice"))
|
||||
(gitea-pr-test "queue processed all" (len gp-qres) 3)
|
||||
(gitea-pr-test
|
||||
"queue pr6 merged"
|
||||
(get (nth gp-qres 0) :merged)
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"queue pr7 merged"
|
||||
(get (nth gp-qres 1) :merged)
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"queue pr5 conflicts"
|
||||
(get (nth gp-qres 2) :error)
|
||||
"conflicts")
|
||||
(gitea-pr-test
|
||||
"failures stay queued"
|
||||
(gitea/queue gp-forge "alice" "proj")
|
||||
(list 5))
|
||||
(gitea-pr-test
|
||||
"pr6 state merged"
|
||||
(get (gitea/pr-get gp-forge "alice" "proj" 6) :state)
|
||||
"merged")
|
||||
(gitea-pr-test
|
||||
"pr7 state merged"
|
||||
(get (gitea/pr-get gp-forge "alice" "proj" 7) :state)
|
||||
"merged")
|
||||
(gitea-pr-test
|
||||
"main has both queue files"
|
||||
(get (gitea/tree-at gp-g (git/branch-get gp-g "main") "q2.txt") :kind)
|
||||
"blob")
|
||||
|
||||
(gitea/queue-remove! gp-forge "alice" "proj" 5)
|
||||
(gitea-pr-test "queue-remove!" (gitea/queue gp-forge "alice" "proj") (list))
|
||||
|
||||
; ── web routes ───────────────────────────────────────────────────────
|
||||
|
||||
(define gp-app (gitea/app gp-forge))
|
||||
(define gp-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
|
||||
(define
|
||||
gp-get
|
||||
(fn (target tok) (gp-app (dream-request "GET" target (gp-hdr tok) ""))))
|
||||
(define
|
||||
gp-post
|
||||
(fn
|
||||
(target tok body)
|
||||
(gp-app (dream-request "POST" target (gp-hdr tok) body))))
|
||||
|
||||
(gitea-pr-test
|
||||
"pulls page 200"
|
||||
(dream-status (gp-get "/alice/proj/pulls" nil))
|
||||
200)
|
||||
(gitea-pr-test
|
||||
"pulls page shows title"
|
||||
(contains?
|
||||
(dream-resp-body (gp-get "/alice/proj/pulls" nil))
|
||||
"Add feature")
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"pulls page shows state"
|
||||
(contains? (dream-resp-body (gp-get "/alice/proj/pulls" nil)) "[merged]")
|
||||
true)
|
||||
|
||||
(gitea-pr-test
|
||||
"pull page 200"
|
||||
(dream-status (gp-get "/alice/proj/pulls/1" nil))
|
||||
200)
|
||||
(gitea-pr-test
|
||||
"pull page shows branches"
|
||||
(contains?
|
||||
(dream-resp-body (gp-get "/alice/proj/pulls/1" nil))
|
||||
"feat -> main")
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"pull page renders body"
|
||||
(contains? (dream-resp-body (gp-get "/alice/proj/pulls/1" nil)) "<p>")
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"pull page shows review verdict"
|
||||
(contains?
|
||||
(dream-resp-body (gp-get "/alice/proj/pulls/1" nil))
|
||||
"carol: approve")
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"pull page shows lifecycle"
|
||||
(contains? (dream-resp-body (gp-get "/alice/proj/pulls/1" nil)) "merged")
|
||||
true)
|
||||
(gitea-pr-test
|
||||
"pull page bad number 404"
|
||||
(dream-status (gp-get "/alice/proj/pulls/abc" nil))
|
||||
404)
|
||||
(gitea-pr-test
|
||||
"pull page missing 404"
|
||||
(dream-status (gp-get "/alice/proj/pulls/99" nil))
|
||||
404)
|
||||
(gitea-pr-test
|
||||
"private pulls anon 404"
|
||||
(dream-status (gp-get "/alice/sec/pulls" nil))
|
||||
404)
|
||||
|
||||
(gitea-pr-test
|
||||
"api pulls len"
|
||||
(len
|
||||
(dream-json-parse
|
||||
(dream-resp-body (gp-get "/api/repos/alice/proj/pulls" nil))))
|
||||
7)
|
||||
(gitea-pr-test
|
||||
"api pulls first source"
|
||||
(get
|
||||
(first
|
||||
(dream-json-parse
|
||||
(dream-resp-body (gp-get "/api/repos/alice/proj/pulls" nil))))
|
||||
:source)
|
||||
"feat")
|
||||
|
||||
(gitea-pr-test
|
||||
"api create anon 401"
|
||||
(dream-status
|
||||
(gp-post "/api/repos/alice/proj/pulls" nil (dream-json-encode {:source "q3" :title "t" :target "main"})))
|
||||
401)
|
||||
(gitea-pr-test
|
||||
"api create 201"
|
||||
(dream-status
|
||||
(gp-post
|
||||
"/api/repos/alice/proj/pulls"
|
||||
"tok-e"
|
||||
(dream-json-encode {:source "q3" :title "Eve PR" :body "please" :target "main"})))
|
||||
201)
|
||||
(gitea-pr-test
|
||||
"api create bad source 400"
|
||||
(dream-status
|
||||
(gp-post
|
||||
"/api/repos/alice/proj/pulls"
|
||||
"tok-e"
|
||||
(dream-json-encode {:source "zz" :title "t" :target "main"})))
|
||||
400)
|
||||
|
||||
; eve's PR is #9
|
||||
(gitea-pr-test
|
||||
"api review 200"
|
||||
(dream-status
|
||||
(gp-post
|
||||
"/api/repos/alice/proj/pulls/9/reviews"
|
||||
"tok-c"
|
||||
(dream-json-encode {:verdict "approve" :body "ok"})))
|
||||
200)
|
||||
(gitea-pr-test
|
||||
"api self-review 400"
|
||||
(dream-status
|
||||
(gp-post
|
||||
"/api/repos/alice/proj/pulls/9/reviews"
|
||||
"tok-e"
|
||||
(dream-json-encode {:verdict "approve"})))
|
||||
400)
|
||||
(gitea-pr-test
|
||||
"api review anon 401"
|
||||
(dream-status
|
||||
(gp-post
|
||||
"/api/repos/alice/proj/pulls/9/reviews"
|
||||
nil
|
||||
(dream-json-encode {:verdict "approve"})))
|
||||
401)
|
||||
(gitea-pr-test
|
||||
"api review missing pr 404"
|
||||
(dream-status
|
||||
(gp-post
|
||||
"/api/repos/alice/proj/pulls/99/reviews"
|
||||
"tok-c"
|
||||
(dream-json-encode {:verdict "approve"})))
|
||||
404)
|
||||
|
||||
(gitea-pr-test
|
||||
"api merge reader 403"
|
||||
(dream-status (gp-post "/api/repos/alice/proj/pulls/9/merge" "tok-e" "{}"))
|
||||
403)
|
||||
(gitea-pr-test
|
||||
"api merge anon 401"
|
||||
(dream-status (gp-post "/api/repos/alice/proj/pulls/9/merge" nil "{}"))
|
||||
401)
|
||||
(gitea-pr-test
|
||||
"api merge write 200"
|
||||
(dream-status (gp-post "/api/repos/alice/proj/pulls/9/merge" "tok-a" "{}"))
|
||||
200)
|
||||
(gitea-pr-test
|
||||
"api merge applied"
|
||||
(get (gitea/pr-get gp-forge "alice" "proj" 9) :state)
|
||||
"merged")
|
||||
|
||||
; reopened conflicting pr 5 still conflicts over the api
|
||||
(gitea-pr-test
|
||||
"api merge conflict 409"
|
||||
(dream-status (gp-post "/api/repos/alice/proj/pulls/5/merge" "tok-a" "{}"))
|
||||
409)
|
||||
|
||||
; eve authors #10 and may close it herself; carol (reader) may not
|
||||
(gp-post "/api/repos/alice/proj/pulls" "tok-e" (dream-json-encode {:source "conf" :title "To close" :target "main"}))
|
||||
(gitea-pr-test
|
||||
"api close by reader 403"
|
||||
(dream-status (gp-post "/api/repos/alice/proj/pulls/10/close" "tok-c" "{}"))
|
||||
403)
|
||||
(gitea-pr-test
|
||||
"api close by author 200"
|
||||
(dream-status (gp-post "/api/repos/alice/proj/pulls/10/close" "tok-e" "{}"))
|
||||
200)
|
||||
(gitea-pr-test
|
||||
"api close applied"
|
||||
(get (gitea/pr-get gp-forge "alice" "proj" 10) :state)
|
||||
"closed")
|
||||
(gitea-pr-test
|
||||
"api close again 409"
|
||||
(dream-status (gp-post "/api/repos/alice/proj/pulls/10/close" "tok-e" "{}"))
|
||||
409)
|
||||
|
||||
; queue over the api: pr5 is approved (reviews survive reopen)
|
||||
(gitea-pr-test
|
||||
"api enqueue reader 403"
|
||||
(dream-status
|
||||
(gp-post "/api/repos/alice/proj/pulls/5/enqueue" "tok-e" "{}"))
|
||||
403)
|
||||
(gitea-pr-test
|
||||
"api enqueue 200"
|
||||
(dream-status
|
||||
(gp-post "/api/repos/alice/proj/pulls/5/enqueue" "tok-a" "{}"))
|
||||
200)
|
||||
(gitea-pr-test
|
||||
"api queue json"
|
||||
(dream-json-parse
|
||||
(dream-resp-body (gp-get "/api/repos/alice/proj/merge-queue" nil)))
|
||||
(list 5))
|
||||
(gitea-pr-test
|
||||
"api queue process 200"
|
||||
(dream-status
|
||||
(gp-post "/api/repos/alice/proj/merge-queue/process" "tok-a" "{}"))
|
||||
200)
|
||||
(gitea-pr-test
|
||||
"api queue process reports conflict"
|
||||
(get
|
||||
(first
|
||||
(dream-json-parse
|
||||
(dream-resp-body
|
||||
(gp-post "/api/repos/alice/proj/merge-queue/process" "tok-a" "{}"))))
|
||||
:error)
|
||||
"conflicts")
|
||||
454
lib/gitea/tests/repo.sx
Normal file
454
lib/gitea/tests/repo.sx
Normal file
@@ -0,0 +1,454 @@
|
||||
; lib/gitea/tests/repo.sx — Phase 1: forge core (owners, repo CRUD, git
|
||||
; wiring, ref/tree navigation) and the dream browse views + JSON API.
|
||||
; Mutating API calls authenticate as alice (Phase 2 gates them).
|
||||
|
||||
(define gitea-repo-pass 0)
|
||||
(define gitea-repo-fail 0)
|
||||
(define gitea-repo-fails (list))
|
||||
|
||||
; compare with = (structural), not equal? — map/filter-derived lists fail
|
||||
; equal? against literals even when they print identically
|
||||
(define
|
||||
gitea-repo-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-repo-pass (+ gitea-repo-pass 1))
|
||||
(begin
|
||||
(set! gitea-repo-fail (+ gitea-repo-fail 1))
|
||||
(set! gitea-repo-fails (append gitea-repo-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
(define gt-db (persist/mem-backend))
|
||||
(define gt-forge (gitea/forge gt-db))
|
||||
|
||||
; ── owners ───────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-repo-test
|
||||
"user-create returns user record"
|
||||
(get (gitea/user-create! gt-forge "alice") :kind)
|
||||
"user")
|
||||
(gitea-repo-test
|
||||
"org-create returns org record"
|
||||
(get (gitea/org-create! gt-forge "acme") :kind)
|
||||
"org")
|
||||
(gitea-repo-test
|
||||
"owner-get finds alice"
|
||||
(get (gitea/owner-get gt-forge "alice") :name)
|
||||
"alice")
|
||||
(gitea-repo-test "owner-exists?" (gitea/owner-exists? gt-forge "alice") true)
|
||||
(gitea-repo-test
|
||||
"user? on user"
|
||||
(gitea/user? (gitea/owner-get gt-forge "alice"))
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"org? on org"
|
||||
(gitea/org? (gitea/owner-get gt-forge "acme"))
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"user? on org"
|
||||
(gitea/user? (gitea/owner-get gt-forge "acme"))
|
||||
false)
|
||||
(gitea-repo-test
|
||||
"duplicate owner conflicts"
|
||||
(get (gitea/user-create! gt-forge "alice") :conflict)
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"owner name with slash rejected"
|
||||
(get (gitea/user-create! gt-forge "a/b") :error)
|
||||
"invalid-name")
|
||||
(gitea-repo-test
|
||||
"owner name empty rejected"
|
||||
(get (gitea/user-create! gt-forge "") :error)
|
||||
"invalid-name")
|
||||
(gitea-repo-test
|
||||
"reserved owner name rejected"
|
||||
(get (gitea/user-create! gt-forge "api") :error)
|
||||
"invalid-name")
|
||||
(gitea-repo-test
|
||||
"owners sorted"
|
||||
(gitea/owners gt-forge)
|
||||
(list "acme" "alice"))
|
||||
|
||||
; ── repo CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
(define gt-rec (gitea/repo-create! gt-forge "alice" "proj" {:description "demo" :created-at 42}))
|
||||
|
||||
(gitea-repo-test "repo-create owner" (get gt-rec :owner) "alice")
|
||||
(gitea-repo-test "repo-create name" (get gt-rec :name) "proj")
|
||||
(gitea-repo-test
|
||||
"repo-create default visibility"
|
||||
(get gt-rec :visibility)
|
||||
"public")
|
||||
(gitea-repo-test
|
||||
"repo-create default branch"
|
||||
(get gt-rec :default-branch)
|
||||
"main")
|
||||
(gitea-repo-test
|
||||
"repo-create keeps created-at"
|
||||
(get gt-rec :created-at)
|
||||
42)
|
||||
(gitea-repo-test
|
||||
"repo-get description"
|
||||
(get (gitea/repo-get gt-forge "alice" "proj") :description)
|
||||
"demo")
|
||||
(gitea-repo-test
|
||||
"repo-exists?"
|
||||
(gitea/repo-exists? gt-forge "alice" "proj")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"repo-get missing"
|
||||
(gitea/repo-get gt-forge "alice" "nope")
|
||||
nil)
|
||||
(gitea-repo-test
|
||||
"repo-create unknown owner"
|
||||
(get (gitea/repo-create! gt-forge "bob" "x" {}) :error)
|
||||
"no-such-owner")
|
||||
(gitea-repo-test
|
||||
"repo-create duplicate conflicts"
|
||||
(get (gitea/repo-create! gt-forge "alice" "proj" {}) :conflict)
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"repo-create bad name"
|
||||
(get (gitea/repo-create! gt-forge "alice" "ba d" {}) :error)
|
||||
"invalid-name")
|
||||
(gitea-repo-test
|
||||
"repos lists alice/proj"
|
||||
(gitea/repos gt-forge)
|
||||
(list "alice/proj"))
|
||||
|
||||
(gitea/repo-create! gt-forge "acme" "proj" {})
|
||||
|
||||
(gitea-repo-test
|
||||
"same name under two owners"
|
||||
(gitea/repos gt-forge)
|
||||
(list "acme/proj" "alice/proj"))
|
||||
(gitea-repo-test
|
||||
"repos-for alice"
|
||||
(gitea/repos-for gt-forge "alice")
|
||||
(list "proj"))
|
||||
(gitea-repo-test
|
||||
"repo-update! description"
|
||||
(begin
|
||||
(gitea/repo-update!
|
||||
gt-forge
|
||||
"alice"
|
||||
"proj"
|
||||
(fn (r) (assoc r :description "rewritten")))
|
||||
(get (gitea/repo-get gt-forge "alice" "proj") :description))
|
||||
"rewritten")
|
||||
(gitea-repo-test
|
||||
"repo-update! missing repo"
|
||||
(gitea/repo-update! gt-forge "alice" "nope" (fn (r) r))
|
||||
nil)
|
||||
|
||||
(gitea/repo-create! gt-forge "alice" "hidden" {:visibility "private"})
|
||||
(gitea-repo-test
|
||||
"private visibility stored"
|
||||
(get (gitea/repo-get gt-forge "alice" "hidden") :visibility)
|
||||
"private")
|
||||
|
||||
; ── git store wiring ─────────────────────────────────────────────────
|
||||
|
||||
(define gt-grepo (gitea/repo-git gt-forge "alice" "proj"))
|
||||
|
||||
(gitea-repo-test "new repo HEAD unborn" (git/head gt-grepo) nil)
|
||||
(gitea-repo-test
|
||||
"new repo HEAD targets main"
|
||||
(git/head-target gt-grepo)
|
||||
"heads/main")
|
||||
(gitea-repo-test "new repo has no branches" (git/branches gt-grepo) (list))
|
||||
|
||||
(git/add! gt-grepo "README.md" "hello forge")
|
||||
(git/add! gt-grepo "src/a.txt" "alpha\n")
|
||||
(git/add! gt-grepo "src/b.txt" "beta\n")
|
||||
(define gt-c1 (git/commit! gt-grepo {:message "init" :time 1 :author "alice"}))
|
||||
|
||||
(gitea-repo-test
|
||||
"commit! advances main"
|
||||
(git/branch-get gt-grepo "main")
|
||||
gt-c1)
|
||||
|
||||
(git/add! gt-grepo "src/a.txt" "alpha2\n")
|
||||
(define gt-c2 (git/commit! gt-grepo {:message "tweak a" :time 2 :author "alice"}))
|
||||
|
||||
(gitea-repo-test
|
||||
"log newest first"
|
||||
(git/log gt-grepo gt-c2)
|
||||
(list gt-c2 gt-c1))
|
||||
(gitea-repo-test "branches lists main" (git/branches gt-grepo) (list "main"))
|
||||
|
||||
(define gt-grepo2 (gitea/repo-git gt-forge "acme" "proj"))
|
||||
|
||||
(gitea-repo-test
|
||||
"objects invisible across repos"
|
||||
(git/has? gt-grepo2 gt-c1)
|
||||
false)
|
||||
|
||||
; ── ref resolution ───────────────────────────────────────────────────
|
||||
|
||||
(gitea-repo-test "resolve branch" (gitea/resolve-ref gt-grepo "main") gt-c2)
|
||||
|
||||
(git/tag-lightweight! gt-grepo "v1")
|
||||
(gitea-repo-test
|
||||
"resolve lightweight tag"
|
||||
(gitea/resolve-ref gt-grepo "v1")
|
||||
gt-c2)
|
||||
|
||||
(git/tag! gt-grepo "v2" {:message "release" :time 3})
|
||||
(gitea-repo-test
|
||||
"resolve annotated tag peels to commit"
|
||||
(gitea/resolve-ref gt-grepo "v2")
|
||||
gt-c2)
|
||||
|
||||
(gitea-repo-test "resolve raw cid" (gitea/resolve-ref gt-grepo gt-c1) gt-c1)
|
||||
(gitea-repo-test
|
||||
"resolve unknown ref"
|
||||
(gitea/resolve-ref gt-grepo "nope")
|
||||
nil)
|
||||
|
||||
; ── tree navigation ──────────────────────────────────────────────────
|
||||
|
||||
(gitea-repo-test
|
||||
"tree-at root is tree"
|
||||
(get (gitea/tree-at gt-grepo gt-c2 "") :kind)
|
||||
"tree")
|
||||
(gitea-repo-test
|
||||
"tree-at file is blob"
|
||||
(get (gitea/tree-at gt-grepo gt-c2 "src/a.txt") :kind)
|
||||
"blob")
|
||||
(gitea-repo-test
|
||||
"tree-at file cid matches content"
|
||||
(get (gitea/tree-at gt-grepo gt-c2 "src/a.txt") :cid)
|
||||
(git/cid (git/blob "alpha2\n")))
|
||||
(gitea-repo-test
|
||||
"tree-at dir is tree"
|
||||
(get (gitea/tree-at gt-grepo gt-c2 "src") :kind)
|
||||
"tree")
|
||||
(gitea-repo-test
|
||||
"tree-at missing path"
|
||||
(gitea/tree-at gt-grepo gt-c2 "src/zzz")
|
||||
nil)
|
||||
(gitea-repo-test
|
||||
"tree-at path through blob"
|
||||
(gitea/tree-at gt-grepo gt-c2 "README.md/x")
|
||||
nil)
|
||||
(gitea-repo-test
|
||||
"tree-at non-commit cid"
|
||||
(gitea/tree-at gt-grepo (git/cid (git/blob "alpha2\n")) "")
|
||||
nil)
|
||||
|
||||
; ── browse views ─────────────────────────────────────────────────────
|
||||
|
||||
(define gt-app (gitea/app gt-forge))
|
||||
(define
|
||||
gt-get
|
||||
(fn (target) (gt-app (dream-request "GET" target {} ""))))
|
||||
|
||||
(gitea/token-create! gt-forge "alice" "tok-alice")
|
||||
(define gt-auth {:authorization "Bearer tok-alice"})
|
||||
(define
|
||||
gt-posta
|
||||
(fn (target body) (gt-app (dream-request "POST" target gt-auth body))))
|
||||
(define
|
||||
gt-dela
|
||||
(fn (target) (gt-app (dream-request "DELETE" target gt-auth ""))))
|
||||
|
||||
(gitea-repo-test "GET / status" (dream-status (gt-get "/")) 200)
|
||||
(gitea-repo-test
|
||||
"GET / lists repos"
|
||||
(contains? (dream-resp-body (gt-get "/")) "alice/proj")
|
||||
true)
|
||||
|
||||
(gitea-repo-test
|
||||
"repo home status"
|
||||
(dream-status (gt-get "/alice/proj"))
|
||||
200)
|
||||
(gitea-repo-test
|
||||
"repo home shows description"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj")) "rewritten")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"repo home shows branch"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj")) "main")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"empty repo home"
|
||||
(contains? (dream-resp-body (gt-get "/acme/proj")) "empty repository")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"unknown repo 404"
|
||||
(dream-status (gt-get "/nobody/none"))
|
||||
404)
|
||||
|
||||
(gitea-repo-test
|
||||
"branches page lists main"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj/branches")) "main")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"branches page unknown repo 404"
|
||||
(dream-status (gt-get "/nobody/none/branches"))
|
||||
404)
|
||||
|
||||
(gitea-repo-test
|
||||
"tree root status"
|
||||
(dream-status (gt-get "/alice/proj/tree/main"))
|
||||
200)
|
||||
(gitea-repo-test
|
||||
"tree root lists src"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj/tree/main")) "src")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"tree root lists README"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj/tree/main")) "README.md")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"tree subdir lists a.txt"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj/tree/main/src")) "a.txt")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"tree at tag"
|
||||
(dream-status (gt-get "/alice/proj/tree/v1"))
|
||||
200)
|
||||
(gitea-repo-test
|
||||
"tree bad ref 404"
|
||||
(dream-status (gt-get "/alice/proj/tree/nope"))
|
||||
404)
|
||||
(gitea-repo-test
|
||||
"tree on blob path 404"
|
||||
(dream-status (gt-get "/alice/proj/tree/main/README.md"))
|
||||
404)
|
||||
|
||||
(gitea-repo-test
|
||||
"blob status"
|
||||
(dream-status (gt-get "/alice/proj/blob/main/src/a.txt"))
|
||||
200)
|
||||
(gitea-repo-test
|
||||
"blob shows content"
|
||||
(contains?
|
||||
(dream-resp-body (gt-get "/alice/proj/blob/main/src/a.txt"))
|
||||
"alpha2")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"blob on tree path 404"
|
||||
(dream-status (gt-get "/alice/proj/blob/main/src"))
|
||||
404)
|
||||
(gitea-repo-test
|
||||
"raw body exact"
|
||||
(dream-resp-body (gt-get "/alice/proj/raw/main/src/a.txt"))
|
||||
"alpha2\n")
|
||||
(gitea-repo-test
|
||||
"raw missing file 404"
|
||||
(dream-status (gt-get "/alice/proj/raw/main/zzz"))
|
||||
404)
|
||||
|
||||
(gitea-repo-test
|
||||
"commits status"
|
||||
(dream-status (gt-get "/alice/proj/commits/main"))
|
||||
200)
|
||||
(gitea-repo-test
|
||||
"commits show newest message"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj/commits/main")) "tweak a")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"commits show oldest message"
|
||||
(contains? (dream-resp-body (gt-get "/alice/proj/commits/main")) "init")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"commits bad ref 404"
|
||||
(dream-status (gt-get "/alice/proj/commits/nope"))
|
||||
404)
|
||||
|
||||
(gitea-repo-test
|
||||
"commit view message"
|
||||
(contains?
|
||||
(dream-resp-body (gt-get (str "/alice/proj/commit/" gt-c2)))
|
||||
"tweak a")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"commit view diff content"
|
||||
(contains?
|
||||
(dream-resp-body (gt-get (str "/alice/proj/commit/" gt-c2)))
|
||||
"alpha2")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"root commit lists files"
|
||||
(contains?
|
||||
(dream-resp-body (gt-get (str "/alice/proj/commit/" gt-c1)))
|
||||
"README.md")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"commit bad cid 404"
|
||||
(dream-status (gt-get "/alice/proj/commit/zzz"))
|
||||
404)
|
||||
|
||||
; ── json api (as alice) ──────────────────────────────────────────────
|
||||
|
||||
(gitea-repo-test
|
||||
"api repos json (anon: public only)"
|
||||
(dream-json-parse (dream-resp-body (gt-get "/api/repos")))
|
||||
(list "acme/proj" "alice/proj"))
|
||||
|
||||
(gitea-repo-test
|
||||
"api create 201"
|
||||
(dream-status (gt-posta "/api/repos" (dream-json-encode {:name "web" :owner "alice"})))
|
||||
201)
|
||||
(gitea-repo-test
|
||||
"api create persisted"
|
||||
(gitea/repo-exists? gt-forge "alice" "web")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"api create duplicate 409"
|
||||
(dream-status (gt-posta "/api/repos" (dream-json-encode {:name "web" :owner "alice"})))
|
||||
409)
|
||||
(gitea-repo-test
|
||||
"api create unknown owner 400"
|
||||
(dream-status (gt-posta "/api/repos" (dream-json-encode {:name "web" :owner "zeb"})))
|
||||
400)
|
||||
(gitea-repo-test
|
||||
"api create bad name 400"
|
||||
(dream-status (gt-posta "/api/repos" (dream-json-encode {:name "b d" :owner "alice"})))
|
||||
400)
|
||||
(gitea-repo-test
|
||||
"api delete 200"
|
||||
(dream-status (gt-dela "/api/repos/alice/web"))
|
||||
200)
|
||||
(gitea-repo-test
|
||||
"api delete gone"
|
||||
(gitea/repo-exists? gt-forge "alice" "web")
|
||||
false)
|
||||
(gitea-repo-test
|
||||
"api delete missing 404"
|
||||
(dream-status (gt-dela "/api/repos/alice/web"))
|
||||
404)
|
||||
|
||||
; ── delete purges the git namespace ──────────────────────────────────
|
||||
|
||||
(gitea/repo-create! gt-forge "alice" "tmp" {})
|
||||
(define gt-gtmp (gitea/repo-git gt-forge "alice" "tmp"))
|
||||
(git/add! gt-gtmp "f.txt" "data")
|
||||
(git/commit! gt-gtmp {:message "x" :time 9})
|
||||
|
||||
(gitea-repo-test
|
||||
"delete returns true"
|
||||
(gitea/repo-delete! gt-forge "alice" "tmp")
|
||||
true)
|
||||
(gitea-repo-test
|
||||
"delete removes record"
|
||||
(gitea/repo-get gt-forge "alice" "tmp")
|
||||
nil)
|
||||
(gitea-repo-test
|
||||
"delete purges git keys"
|
||||
(len
|
||||
(filter
|
||||
(fn (k) (starts-with? k "forge/alice/tmp/"))
|
||||
(persist/kv-keys gt-db)))
|
||||
0)
|
||||
(gitea-repo-test
|
||||
"delete missing returns false"
|
||||
(gitea/repo-delete! gt-forge "alice" "tmp")
|
||||
false)
|
||||
(gitea-repo-test
|
||||
"other repos survive delete"
|
||||
(gitea/repos gt-forge)
|
||||
(list "acme/proj" "alice/hidden" "alice/proj"))
|
||||
266
lib/gitea/tests/search.sx
Normal file
266
lib/gitea/tests/search.sx
Normal file
@@ -0,0 +1,266 @@
|
||||
; lib/gitea/tests/search.sx — Phase 7: code + issue/PR search over
|
||||
; search-on-sx. All SX-level queries run as ONE haskell evaluation
|
||||
; (gitea/search-multi over five corpora); the web tests add one
|
||||
; evaluation per request.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-markdown!)
|
||||
(content-bootstrap-table!)
|
||||
|
||||
(define gitea-search-pass 0)
|
||||
(define gitea-search-fail 0)
|
||||
(define gitea-search-fails (list))
|
||||
|
||||
(define
|
||||
gitea-search-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-search-pass (+ gitea-search-pass 1))
|
||||
(begin
|
||||
(set! gitea-search-fail (+ gitea-search-fail 1))
|
||||
(set!
|
||||
gitea-search-fails
|
||||
(append gitea-search-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
; ── escaping ─────────────────────────────────────────────────────────
|
||||
|
||||
(gitea-search-test "hk-escape quotes" (gitea/hk-escape "a\"b") "a\\\"b")
|
||||
(gitea-search-test "hk-escape newline" (gitea/hk-escape "a\nb") "a\\nb")
|
||||
(gitea-search-test "hk-escape backslash" (gitea/hk-escape "a\\b") "a\\\\b")
|
||||
(gitea-search-test "hk-escape nil" (gitea/hk-escape nil) "")
|
||||
|
||||
; ── setup ────────────────────────────────────────────────────────────
|
||||
|
||||
(define gs-db (persist/mem-backend))
|
||||
(define gs-forge (gitea/forge gs-db))
|
||||
(gitea/user-create! gs-forge "alice")
|
||||
(gitea/user-create! gs-forge "bob")
|
||||
(gitea/token-create! gs-forge "alice" "tok-a")
|
||||
(gitea/repo-create! gs-forge "alice" "proj" {})
|
||||
(gitea/repo-create! gs-forge "alice" "sec" {:visibility "private"})
|
||||
(gitea/repo-create! gs-forge "alice" "empty" {})
|
||||
|
||||
(define gs-g (gitea/repo-git gs-forge "alice" "proj"))
|
||||
(git/add! gs-g "README.md" "SX forge documentation overview")
|
||||
(git/add! gs-g "src/main.sx" "(define forge-main entry) banana runtime")
|
||||
(git/add! gs-g "docs/guide.md" "how to search the forge")
|
||||
(git/commit! gs-g {:message "c1" :time 1 :author "alice"})
|
||||
(git/branch! gs-g "feat")
|
||||
(git/checkout! gs-g "feat")
|
||||
(git/add! gs-g "src/extra.sx" "cherry helpers")
|
||||
(git/commit! gs-g {:message "c2" :time 2 :author "alice"})
|
||||
(git/checkout! gs-g "main")
|
||||
|
||||
(define gs-gs (gitea/repo-git gs-forge "alice" "sec"))
|
||||
(git/add! gs-gs "secret.txt" "banana secret stash")
|
||||
(git/commit! gs-gs {:message "s1" :time 3 :author "alice"})
|
||||
|
||||
(gitea/issue-create!
|
||||
gs-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"Crash in search"
|
||||
"the search crashes on banana input"
|
||||
{:created-at 10})
|
||||
(gitea/issue-comment!
|
||||
gs-forge
|
||||
"alice"
|
||||
"proj"
|
||||
1
|
||||
"bob"
|
||||
"reproduced with cherry too"
|
||||
{:at 11})
|
||||
(gitea/issue-create!
|
||||
gs-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"alice"
|
||||
"Docs update"
|
||||
"documentation needs love"
|
||||
{:created-at 12})
|
||||
(gitea/pr-create!
|
||||
gs-forge
|
||||
"alice"
|
||||
"proj"
|
||||
"bob"
|
||||
"Improve search ranking"
|
||||
"feat"
|
||||
"main"
|
||||
"tfidf ranking for results"
|
||||
{})
|
||||
|
||||
; ── corpus construction ──────────────────────────────────────────────
|
||||
|
||||
(define gs-corp-code (gitea/repo-docs gs-forge "alice" "proj" (list "code")))
|
||||
(define
|
||||
gs-corp-ip
|
||||
(gitea/repo-docs gs-forge "alice" "proj" (list "issue" "pr")))
|
||||
(define
|
||||
gs-corp-all
|
||||
(gitea/repo-docs gs-forge "alice" "proj" gitea/search-kinds-all))
|
||||
(define gs-corp-anon (gitea/visible-docs gs-forge nil gitea/search-kinds-all))
|
||||
(define
|
||||
gs-corp-own
|
||||
(gitea/visible-docs gs-forge "alice" gitea/search-kinds-all))
|
||||
|
||||
(gitea-search-test "code docs" (len gs-corp-code) 3)
|
||||
(gitea-search-test "issue+pr docs" (len gs-corp-ip) 3)
|
||||
(gitea-search-test "all docs" (len gs-corp-all) 6)
|
||||
(gitea-search-test
|
||||
"empty repo corpus"
|
||||
(gitea/code-docs gs-forge "alice" "empty")
|
||||
(list))
|
||||
(gitea-search-test
|
||||
"anon corpus excludes private"
|
||||
(len (filter (fn (d) (= (get d :repo) "alice/sec")) gs-corp-anon))
|
||||
0)
|
||||
(gitea-search-test
|
||||
"owner corpus includes private"
|
||||
(len (filter (fn (d) (= (get d :repo) "alice/sec")) gs-corp-own))
|
||||
1)
|
||||
|
||||
; ── ONE evaluation, thirteen queries over five corpora ───────────────
|
||||
|
||||
(define
|
||||
gs-R
|
||||
(gitea/search-multi
|
||||
(list gs-corp-code gs-corp-ip gs-corp-all gs-corp-anon gs-corp-own)
|
||||
(list
|
||||
{:n 20 :query "banana" :corpus 0}
|
||||
{:n 20 :query "forge" :corpus 0}
|
||||
{:n 20 :query "banana OR cherry" :corpus 0}
|
||||
{:n 20 :query "nosuchterm" :corpus 0}
|
||||
{:n 20 :query "banana" :corpus 1}
|
||||
{:n 20 :query "cherry" :corpus 1}
|
||||
{:n 20 :query "ranking" :corpus 1}
|
||||
{:n 20 :query "search AND banana" :corpus 1}
|
||||
{:n 20 :query "documentation" :corpus 1}
|
||||
{:n 20 :query "search" :corpus 2}
|
||||
{:n 2 :query "search" :corpus 2}
|
||||
{:n 20 :query "banana" :corpus 3}
|
||||
{:n 20 :query "banana" :corpus 4})))
|
||||
|
||||
(gitea-search-test
|
||||
"code: unique term"
|
||||
(nth gs-R 0)
|
||||
(list {:kind "code" :ref "src/main.sx"}))
|
||||
(gitea-search-test
|
||||
"code: common term hits all three"
|
||||
(len (nth gs-R 1))
|
||||
3)
|
||||
(gitea-search-test "code: OR query" (nth gs-R 2) (list {:kind "code" :ref "src/main.sx"}))
|
||||
(gitea-search-test "code: no hits" (nth gs-R 3) (list))
|
||||
|
||||
(gitea-search-test
|
||||
"issues: body term"
|
||||
(nth gs-R 4)
|
||||
(list {:kind "issue" :ref "1"}))
|
||||
(gitea-search-test
|
||||
"issues: comment text indexed"
|
||||
(nth gs-R 5)
|
||||
(list {:kind "issue" :ref "1"}))
|
||||
(gitea-search-test "prs: body term" (nth gs-R 6) (list {:kind "pr" :ref "3"}))
|
||||
(gitea-search-test
|
||||
"issues: AND query"
|
||||
(nth gs-R 7)
|
||||
(list {:kind "issue" :ref "1"}))
|
||||
(gitea-search-test
|
||||
"issues: title+body of second"
|
||||
(len (filter (fn (r) (= r {:kind "issue" :ref "2"})) (nth gs-R 8)))
|
||||
1)
|
||||
|
||||
(gitea-search-test
|
||||
"mixed kinds found"
|
||||
(len (nth gs-R 9))
|
||||
3)
|
||||
(gitea-search-test
|
||||
"take-n limits results"
|
||||
(len (nth gs-R 10))
|
||||
2)
|
||||
|
||||
(gitea-search-test
|
||||
"global anon excludes private"
|
||||
(len
|
||||
(filter (fn (r) (= (get r :repo) "alice/sec")) (nth gs-R 11)))
|
||||
0)
|
||||
(gitea-search-test
|
||||
"global anon finds public code+issue"
|
||||
(len (nth gs-R 11))
|
||||
2)
|
||||
(gitea-search-test
|
||||
"global owner sees private"
|
||||
(len
|
||||
(filter (fn (r) (= (get r :repo) "alice/sec")) (nth gs-R 12)))
|
||||
1)
|
||||
(gitea-search-test
|
||||
"global owner total"
|
||||
(len (nth gs-R 12))
|
||||
3)
|
||||
|
||||
; empty corpora short-circuit without an evaluation
|
||||
(gitea-search-test
|
||||
"empty corpus searches empty"
|
||||
(gitea/search-repo
|
||||
gs-forge
|
||||
"alice"
|
||||
"empty"
|
||||
"banana"
|
||||
gitea/search-kinds-all
|
||||
10)
|
||||
(list))
|
||||
|
||||
; ── web (one haskell evaluation per request) ─────────────────────────
|
||||
|
||||
(define gs-app (gitea/app gs-forge))
|
||||
(define gs-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
|
||||
(define
|
||||
gs-get
|
||||
(fn (target tok) (gs-app (dream-request "GET" target (gs-hdr tok) ""))))
|
||||
|
||||
(define gs-page (gs-get "/alice/proj/search?q=banana" nil))
|
||||
(gitea-search-test "search page 200" (dream-status gs-page) 200)
|
||||
(gitea-search-test
|
||||
"search page links the hit"
|
||||
(contains? (dream-resp-body gs-page) "src/main.sx")
|
||||
true)
|
||||
|
||||
(gitea-search-test
|
||||
"search page kind filter"
|
||||
(contains?
|
||||
(dream-resp-body (gs-get "/alice/proj/search?q=banana&kind=issue" nil))
|
||||
"issues/1")
|
||||
true)
|
||||
(gitea-search-test
|
||||
"search page missing q 400"
|
||||
(dream-status (gs-get "/alice/proj/search" nil))
|
||||
400)
|
||||
(gitea-search-test
|
||||
"private search anon 404"
|
||||
(dream-status (gs-get "/alice/sec/search?q=banana" nil))
|
||||
404)
|
||||
|
||||
(gitea-search-test
|
||||
"api repo search"
|
||||
(dream-json-parse
|
||||
(dream-resp-body
|
||||
(gs-get "/api/repos/alice/proj/search?q=cherry&kind=issue" nil)))
|
||||
(list {:kind "issue" :ref "1"}))
|
||||
(gitea-search-test
|
||||
"api repo search missing q 400"
|
||||
(dream-status (gs-get "/api/repos/alice/proj/search" nil))
|
||||
400)
|
||||
|
||||
(define gs-glob-anon (gs-get "/api/search?q=banana" nil))
|
||||
(gitea-search-test
|
||||
"api global anon count"
|
||||
(len (dream-json-parse (dream-resp-body gs-glob-anon)))
|
||||
2)
|
||||
(define gs-glob-own (gs-get "/api/search?q=banana" "tok-a"))
|
||||
(gitea-search-test
|
||||
"api global authed count"
|
||||
(len (dream-json-parse (dream-resp-body gs-glob-own)))
|
||||
3)
|
||||
490
lib/gitea/tests/wire.sx
Normal file
490
lib/gitea/tests/wire.sx
Normal file
@@ -0,0 +1,490 @@
|
||||
; lib/gitea/tests/wire.sx — Phase 3: pkt-line framing, object closure,
|
||||
; smart-HTTP endpoints (info/refs, upload-pack, receive-pack), and the
|
||||
; client (ls-remote/clone!/fetch!/push!) syncing two in-memory forges.
|
||||
|
||||
(define gitea-wire-pass 0)
|
||||
(define gitea-wire-fail 0)
|
||||
(define gitea-wire-fails (list))
|
||||
|
||||
(define
|
||||
gitea-wire-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! gitea-wire-pass (+ gitea-wire-pass 1))
|
||||
(begin
|
||||
(set! gitea-wire-fail (+ gitea-wire-fail 1))
|
||||
(set! gitea-wire-fails (append gitea-wire-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
|
||||
|
||||
; ── pkt-line framing ─────────────────────────────────────────────────
|
||||
|
||||
(gitea-wire-test "hex4 small" (gitea/hex4 5) "0005")
|
||||
(gitea-wire-test "hex4 max" (gitea/hex4 65535) "ffff")
|
||||
(gitea-wire-test "hex4-parse" (gitea/hex4-parse "001a") 26)
|
||||
(gitea-wire-test
|
||||
"hex4 round trip"
|
||||
(gitea/hex4-parse (gitea/hex4 4242))
|
||||
4242)
|
||||
(gitea-wire-test "pkt frames with length" (gitea/pkt "hi") "0006hi")
|
||||
(gitea-wire-test
|
||||
"pkt-sections round trip"
|
||||
(gitea/pkt-sections (gitea/pkt-render (list (list "a" "bc") (list "z"))))
|
||||
(list (list "a" "bc") (list "z")))
|
||||
(gitea-wire-test "pkt-sections empty" (gitea/pkt-sections "") (list))
|
||||
(gitea-wire-test
|
||||
"pkt-sections flush only"
|
||||
(gitea/pkt-sections "0000")
|
||||
(list (list)))
|
||||
(gitea-wire-test "pkt-fits? small" (gitea/pkt-fits? "x") true)
|
||||
|
||||
(define
|
||||
gw-big
|
||||
(reduce
|
||||
(fn (acc i) (str acc acc))
|
||||
"xxxxxxxxxx"
|
||||
(list
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
6
|
||||
7
|
||||
8
|
||||
9
|
||||
10
|
||||
11
|
||||
12
|
||||
13)))
|
||||
(gitea-wire-test "pkt-fits? huge payload" (gitea/pkt-fits? gw-big) false)
|
||||
|
||||
; ── forge A with history ─────────────────────────────────────────────
|
||||
|
||||
(define gw-db (persist/mem-backend))
|
||||
(define gw-forge (gitea/forge gw-db))
|
||||
(gitea/user-create! gw-forge "alice")
|
||||
(gitea/user-create! gw-forge "bob")
|
||||
(gitea/user-create! gw-forge "rope")
|
||||
(gitea/token-create! gw-forge "alice" "tok-a")
|
||||
(gitea/token-create! gw-forge "bob" "tok-b")
|
||||
(gitea/token-create! gw-forge "rope" "tok-r")
|
||||
(gitea/repo-create! gw-forge "alice" "lib" {})
|
||||
(gitea/collab-add! gw-forge "alice" "lib" "rope" "read")
|
||||
|
||||
(define gw-ga (gitea/repo-git gw-forge "alice" "lib"))
|
||||
(git/add! gw-ga "README.md" "hello\n")
|
||||
(git/add! gw-ga "src/a.txt" "alpha\n")
|
||||
(define gw-c1 (git/commit! gw-ga {:message "init" :time 1 :author "alice"}))
|
||||
(git/add! gw-ga "src/a.txt" "alpha2\n")
|
||||
(define gw-c2 (git/commit! gw-ga {:message "more" :time 2 :author "alice"}))
|
||||
(git/tag! gw-ga "v1" {:message "rel" :time 3})
|
||||
(define gw-tag-cid (git/tag-get gw-ga "v1"))
|
||||
(git/branch-create! gw-ga "dev" gw-c1)
|
||||
|
||||
; ── closure ──────────────────────────────────────────────────────────
|
||||
|
||||
; c1 = commit + root tree + src tree + 2 blobs
|
||||
(gitea-wire-test
|
||||
"closure of c1"
|
||||
(len (gitea/closure-list gw-ga (list gw-c1)))
|
||||
5)
|
||||
; c2 adds commit + new root + new src + new blob
|
||||
(gitea-wire-test
|
||||
"closure of c2"
|
||||
(len (gitea/closure-list gw-ga (list gw-c2)))
|
||||
9)
|
||||
(gitea-wire-test
|
||||
"pack-cids c2 given c1"
|
||||
(len (gitea/pack-cids gw-ga (list gw-c2) (list gw-c1)))
|
||||
4)
|
||||
(gitea-wire-test
|
||||
"pack includes tip commit"
|
||||
(contains? (gitea/pack-cids gw-ga (list gw-c2) (list gw-c1)) gw-c2)
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"pack excludes had commit"
|
||||
(contains? (gitea/pack-cids gw-ga (list gw-c2) (list gw-c1)) gw-c1)
|
||||
false)
|
||||
(gitea-wire-test
|
||||
"closure through tag object"
|
||||
(len (gitea/closure-list gw-ga (list gw-tag-cid)))
|
||||
10)
|
||||
(gitea-wire-test
|
||||
"closure-complete? tip"
|
||||
(gitea/closure-complete? gw-ga (list gw-c2))
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"closure-complete? missing cid"
|
||||
(gitea/closure-complete? gw-ga (list "sx1:doesnotexist"))
|
||||
false)
|
||||
(gitea-wire-test
|
||||
"obj-refs of commit"
|
||||
(len (gitea/obj-refs (git/read gw-ga gw-c2)))
|
||||
2)
|
||||
|
||||
; ── pack line verification ───────────────────────────────────────────
|
||||
|
||||
(gitea-wire-test
|
||||
"pack-line parses and verifies"
|
||||
(get (gitea/pack-line-parse (gitea/pack-line gw-ga gw-c1)) :cid)
|
||||
gw-c1)
|
||||
(gitea-wire-test
|
||||
"pack-line tamper detected"
|
||||
(get
|
||||
(gitea/pack-line-parse (str gw-c1 " " (serialize (git/blob "evil"))))
|
||||
:error)
|
||||
"cid-mismatch")
|
||||
(gitea-wire-test
|
||||
"unpack! rejects tampered pack"
|
||||
(get
|
||||
(gitea/unpack!
|
||||
gw-ga
|
||||
(list (str gw-c1 " " (serialize (git/blob "evil")))))
|
||||
:error)
|
||||
"cid-mismatch")
|
||||
|
||||
; ── server endpoints ─────────────────────────────────────────────────
|
||||
|
||||
(define gw-app (gitea/forge-app gw-forge))
|
||||
(define gw-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
|
||||
(define
|
||||
gw-get
|
||||
(fn (target tok) (gw-app (dream-request "GET" target (gw-hdr tok) ""))))
|
||||
(define
|
||||
gw-post
|
||||
(fn
|
||||
(target tok body)
|
||||
(gw-app (dream-request "POST" target (gw-hdr tok) body))))
|
||||
|
||||
(gitea-wire-test
|
||||
"info/refs 200"
|
||||
(dream-status (gw-get "/alice/lib/info/refs" nil))
|
||||
200)
|
||||
(gitea-wire-test
|
||||
"info/refs advertises main"
|
||||
(contains?
|
||||
(dream-resp-body (gw-get "/alice/lib/info/refs" nil))
|
||||
(str gw-c2 " heads/main"))
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"info/refs advertises dev"
|
||||
(contains?
|
||||
(dream-resp-body (gw-get "/alice/lib/info/refs" nil))
|
||||
(str gw-c1 " heads/dev"))
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"info/refs advertises tag"
|
||||
(contains?
|
||||
(dream-resp-body (gw-get "/alice/lib/info/refs" nil))
|
||||
(str gw-tag-cid " tags/v1"))
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"info/refs advertises HEAD"
|
||||
(contains?
|
||||
(dream-resp-body (gw-get "/alice/lib/info/refs" nil))
|
||||
"@ heads/main")
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"info/refs unknown repo 404"
|
||||
(dream-status (gw-get "/alice/none/info/refs" nil))
|
||||
404)
|
||||
|
||||
(define
|
||||
gw-up-body
|
||||
(fn
|
||||
(wants haves)
|
||||
(gitea/pkt-render
|
||||
(list
|
||||
(concat
|
||||
(map (fn (c) (str "want " c)) wants)
|
||||
(map (fn (c) (str "have " c)) haves))))))
|
||||
|
||||
(define
|
||||
gw-up-lines
|
||||
(fn
|
||||
(resp)
|
||||
(let
|
||||
((s (gitea/pkt-sections (dream-resp-body resp))))
|
||||
(if (empty? s) (list) (first s)))))
|
||||
|
||||
(gitea-wire-test
|
||||
"upload-pack full clone size"
|
||||
(len
|
||||
(gw-up-lines
|
||||
(gw-post
|
||||
"/alice/lib/git-upload-pack"
|
||||
nil
|
||||
(gw-up-body (list gw-c2) (list)))))
|
||||
9)
|
||||
(gitea-wire-test
|
||||
"upload-pack incremental size"
|
||||
(len
|
||||
(gw-up-lines
|
||||
(gw-post
|
||||
"/alice/lib/git-upload-pack"
|
||||
nil
|
||||
(gw-up-body (list gw-c2) (list gw-c1)))))
|
||||
4)
|
||||
(gitea-wire-test
|
||||
"upload-pack lines all verify"
|
||||
(len
|
||||
(filter
|
||||
(fn (l) (get (gitea/pack-line-parse l) :error))
|
||||
(gw-up-lines
|
||||
(gw-post
|
||||
"/alice/lib/git-upload-pack"
|
||||
nil
|
||||
(gw-up-body (list gw-c2) (list))))))
|
||||
0)
|
||||
|
||||
(gitea-wire-test
|
||||
"receive-pack anon 401"
|
||||
(dream-status
|
||||
(gw-post
|
||||
"/alice/lib/git-receive-pack"
|
||||
nil
|
||||
(gitea/pkt-render (list (list) (list)))))
|
||||
401)
|
||||
(gitea-wire-test
|
||||
"receive-pack read-only 403"
|
||||
(dream-status
|
||||
(gw-post
|
||||
"/alice/lib/git-receive-pack"
|
||||
"tok-r"
|
||||
(gitea/pkt-render (list (list) (list)))))
|
||||
403)
|
||||
|
||||
; private repo wire gating
|
||||
(gitea/repo-create! gw-forge "alice" "priv" {:visibility "private"})
|
||||
(define gw-gp (gitea/repo-git gw-forge "alice" "priv"))
|
||||
(git/add! gw-gp "p.txt" "private\n")
|
||||
(git/commit! gw-gp {:message "p" :time 1 :author "alice"})
|
||||
|
||||
(gitea-wire-test
|
||||
"private info/refs anon 404"
|
||||
(dream-status (gw-get "/alice/priv/info/refs" nil))
|
||||
404)
|
||||
(gitea-wire-test
|
||||
"private info/refs owner 200"
|
||||
(dream-status (gw-get "/alice/priv/info/refs" "tok-a"))
|
||||
200)
|
||||
(gitea-wire-test
|
||||
"private upload-pack anon 404"
|
||||
(dream-status
|
||||
(gw-post "/alice/priv/git-upload-pack" nil (gw-up-body (list) (list))))
|
||||
404)
|
||||
|
||||
; ── client: ls-remote ────────────────────────────────────────────────
|
||||
|
||||
(define gw-remote-anon (gitea/remote gw-app "alice" "lib" nil))
|
||||
(define gw-remote-bob (gitea/remote gw-app "alice" "lib" "tok-b"))
|
||||
(define gw-remote-rope (gitea/remote gw-app "alice" "lib" "tok-r"))
|
||||
|
||||
(define gw-ls (gitea/ls-remote gw-remote-anon))
|
||||
(gitea-wire-test "ls-remote head" (get gw-ls :head) "heads/main")
|
||||
(gitea-wire-test "ls-remote main" (get (get gw-ls :refs) "heads/main") gw-c2)
|
||||
(gitea-wire-test "ls-remote dev" (get (get gw-ls :refs) "heads/dev") gw-c1)
|
||||
(gitea-wire-test
|
||||
"ls-remote tag"
|
||||
(get (get gw-ls :refs) "tags/v1")
|
||||
gw-tag-cid)
|
||||
(gitea-wire-test
|
||||
"ls-remote unknown repo"
|
||||
(gitea/ls-remote (gitea/remote gw-app "alice" "none" nil))
|
||||
nil)
|
||||
|
||||
; ── client: clone into forge B ───────────────────────────────────────
|
||||
|
||||
(define gw-db2 (persist/mem-backend))
|
||||
(define gw-forge2 (gitea/forge gw-db2))
|
||||
(gitea/user-create! gw-forge2 "bob")
|
||||
|
||||
(define
|
||||
gw-clone
|
||||
(gitea/clone! gw-forge2 "bob" "lib" gw-remote-anon {}))
|
||||
(gitea-wire-test "clone returns record" (get gw-clone :owner) "bob")
|
||||
|
||||
(define gw-gb (gitea/repo-git gw-forge2 "bob" "lib"))
|
||||
(gitea-wire-test "clone main" (git/branch-get gw-gb "main") gw-c2)
|
||||
(gitea-wire-test "clone dev" (git/branch-get gw-gb "dev") gw-c1)
|
||||
(gitea-wire-test "clone tag" (git/tag-get gw-gb "v1") gw-tag-cid)
|
||||
(gitea-wire-test "clone HEAD" (git/head-target gw-gb) "heads/main")
|
||||
(gitea-wire-test
|
||||
"clone default branch recorded"
|
||||
(get (gitea/repo-get gw-forge2 "bob" "lib") :default-branch)
|
||||
"main")
|
||||
(gitea-wire-test "clone log" (git/log gw-gb gw-c2) (list gw-c2 gw-c1))
|
||||
(gitea-wire-test
|
||||
"clone blob content"
|
||||
(git/blob-data
|
||||
(git/read gw-gb (get (gitea/tree-at gw-gb gw-c2 "src/a.txt") :cid)))
|
||||
"alpha2\n")
|
||||
(gitea-wire-test
|
||||
"clone closure complete"
|
||||
(gitea/closure-complete? gw-gb (list gw-c2))
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"clone again conflicts"
|
||||
(get
|
||||
(gitea/clone! gw-forge2 "bob" "lib" gw-remote-anon {})
|
||||
:conflict)
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"clone unreachable remote errors"
|
||||
(get
|
||||
(gitea/clone!
|
||||
gw-forge2
|
||||
"bob"
|
||||
"ghost"
|
||||
(gitea/remote gw-app "alice" "none" nil)
|
||||
{})
|
||||
:error)
|
||||
404)
|
||||
(gitea-wire-test
|
||||
"failed clone leaves no repo"
|
||||
(gitea/repo-exists? gw-forge2 "bob" "ghost")
|
||||
false)
|
||||
|
||||
; ── client: fetch after upstream moves ───────────────────────────────
|
||||
|
||||
(git/add! gw-ga "src/c.txt" "gamma\n")
|
||||
(define gw-c3 (git/commit! gw-ga {:message "third" :time 4 :author "alice"}))
|
||||
|
||||
(define gw-fetch1 (gitea/fetch! gw-remote-anon gw-gb))
|
||||
(gitea-wire-test
|
||||
"fetch stores new objects"
|
||||
(> (get gw-fetch1 :stored) 0)
|
||||
true)
|
||||
(gitea-wire-test "fetch moves main" (git/branch-get gw-gb "main") gw-c3)
|
||||
(gitea-wire-test
|
||||
"fetch closure complete"
|
||||
(gitea/closure-complete? gw-gb (list gw-c3))
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"fetch up-to-date is no-op"
|
||||
(get (gitea/fetch! gw-remote-anon gw-gb) :stored)
|
||||
0)
|
||||
|
||||
; ── client: push ─────────────────────────────────────────────────────
|
||||
|
||||
; bob gets write on the upstream, commits locally, pushes
|
||||
(gitea/collab-add! gw-forge "alice" "lib" "bob" "write")
|
||||
(git/checkout! gw-gb "main")
|
||||
(git/add! gw-gb "src/b.txt" "beta\n")
|
||||
(define gw-c4 (git/commit! gw-gb {:message "from-bob" :time 5 :author "bob"}))
|
||||
|
||||
(gitea-wire-test
|
||||
"push ok"
|
||||
(get (gitea/push! gw-remote-bob gw-gb "heads/main") :ok)
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"push moved upstream main"
|
||||
(git/branch-get gw-ga "main")
|
||||
gw-c4)
|
||||
(gitea-wire-test
|
||||
"upstream has pushed objects"
|
||||
(gitea/closure-complete? gw-ga (list gw-c4))
|
||||
true)
|
||||
|
||||
; push auth: anon 401, read-only 403
|
||||
(gitea-wire-test
|
||||
"push anon rejected"
|
||||
(get (gitea/push! gw-remote-anon gw-gb "heads/main") :error)
|
||||
401)
|
||||
(gitea-wire-test
|
||||
"push read-only rejected"
|
||||
(get (gitea/push! gw-remote-rope gw-gb "heads/main") :error)
|
||||
403)
|
||||
|
||||
; non-fast-forward: upstream moves on while bob commits on the old tip
|
||||
(git/checkout! gw-ga "main")
|
||||
(git/add! gw-ga "src/d.txt" "delta\n")
|
||||
(define gw-c5 (git/commit! gw-ga {:message "upstream-moves" :time 6 :author "alice"}))
|
||||
|
||||
(git/add! gw-gb "src/e.txt" "eps\n")
|
||||
(define gw-c4b (git/commit! gw-gb {:message "bob-diverges" :time 7 :author "bob"}))
|
||||
|
||||
(gitea-wire-test
|
||||
"push non-fast-forward rejected"
|
||||
(get (gitea/push! gw-remote-bob gw-gb "heads/main") :ng)
|
||||
"non-fast-forward")
|
||||
(gitea-wire-test
|
||||
"upstream main unchanged after ng"
|
||||
(git/branch-get gw-ga "main")
|
||||
gw-c5)
|
||||
|
||||
; recover: fetch (mirror moves local main to upstream), rebuild, push
|
||||
(gitea/fetch! gw-remote-bob gw-gb)
|
||||
(gitea-wire-test
|
||||
"fetch after ng syncs main"
|
||||
(git/branch-get gw-gb "main")
|
||||
gw-c5)
|
||||
(git/checkout! gw-gb "main")
|
||||
(git/add! gw-gb "src/e.txt" "eps\n")
|
||||
(define gw-c6 (git/commit! gw-gb {:message "bob-rebased" :time 8 :author "bob"}))
|
||||
(gitea-wire-test
|
||||
"push after sync ok"
|
||||
(get (gitea/push! gw-remote-bob gw-gb "heads/main") :ok)
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"upstream at bob's rebased tip"
|
||||
(git/branch-get gw-ga "main")
|
||||
gw-c6)
|
||||
|
||||
; branch create / delete over the wire
|
||||
(git/branch-create! gw-gb "feat" gw-c6)
|
||||
(gitea-wire-test
|
||||
"push new branch"
|
||||
(get (gitea/push! gw-remote-bob gw-gb "heads/feat") :ok)
|
||||
true)
|
||||
(gitea-wire-test
|
||||
"upstream sees new branch"
|
||||
(git/branch-get gw-ga "feat")
|
||||
gw-c6)
|
||||
(gitea-wire-test
|
||||
"push delete branch"
|
||||
(get (gitea/push-delete! gw-remote-bob gw-gb "heads/feat") :ok)
|
||||
true)
|
||||
(gitea-wire-test "upstream branch deleted" (git/branch-get gw-ga "feat") nil)
|
||||
|
||||
; tag push
|
||||
(git/tag-lightweight! gw-gb "v2")
|
||||
(gitea-wire-test
|
||||
"push tag"
|
||||
(get (gitea/push! gw-remote-bob gw-gb "tags/v2") :ok)
|
||||
true)
|
||||
(gitea-wire-test "upstream sees tag" (git/tag-get gw-ga "v2") gw-c6)
|
||||
|
||||
; guard rails
|
||||
(gitea-wire-test
|
||||
"push unknown local ref"
|
||||
(get (gitea/push! gw-remote-bob gw-gb "heads/nope") :error)
|
||||
"no-such-local-ref")
|
||||
(gitea-wire-test
|
||||
"apply-cmd rejects non-shared ref"
|
||||
(gitea/apply-cmd! gw-ga {:name "bogus/x" :new gw-c1 :old "-"})
|
||||
"ng bogus/x invalid-ref")
|
||||
(gitea-wire-test
|
||||
"apply-cmd rejects missing objects"
|
||||
(gitea/apply-cmd! gw-ga {:name "heads/zzz" :new "sx1:missing" :old "-"})
|
||||
"ng heads/zzz missing-objects")
|
||||
|
||||
; private repo full round trip with credentials
|
||||
(define gw-remote-priv (gitea/remote gw-app "alice" "priv" "tok-a"))
|
||||
(gitea/user-create! gw-forge2 "alice")
|
||||
(define
|
||||
gw-pclone
|
||||
(gitea/clone! gw-forge2 "alice" "priv" gw-remote-priv {:visibility "private"}))
|
||||
(gitea-wire-test "private clone with token" (get gw-pclone :owner) "alice")
|
||||
(gitea-wire-test
|
||||
"private clone anon fails"
|
||||
(get
|
||||
(gitea/clone!
|
||||
gw-forge2
|
||||
"alice"
|
||||
"priv2"
|
||||
(gitea/remote gw-app "alice" "priv" nil)
|
||||
{})
|
||||
:error)
|
||||
404)
|
||||
520
lib/gitea/web.sx
Normal file
520
lib/gitea/web.sx
Normal file
@@ -0,0 +1,520 @@
|
||||
; lib/gitea/web.sx — sx-gitea Phases 1+2: browse views over dream, gated
|
||||
; by access control.
|
||||
;
|
||||
; Pure request -> response handlers: repo list, repo home, tree/blob/raw
|
||||
; browse at any ref (branch, tag, or cid), commit log, single-commit diff,
|
||||
; plus a JSON API for repo create/list/delete and collaborator management.
|
||||
;
|
||||
; Gating: every repo route requires "read" (a miss is a 404 — private
|
||||
; repos are indistinguishable from absent ones); mutations require the
|
||||
; caller to authenticate (bearer token) and hold the right role: create
|
||||
; needs the owner (or org admin), delete and collaborator management need
|
||||
; "admin". 401 = no credentials, 403 = authenticated but not allowed.
|
||||
;
|
||||
; Later modules (wire, issues, pr, activity, ...) extend the app by
|
||||
; appending a routes pack to gitea/route-packs at load time; gitea/app
|
||||
; serves them all, with every /api/* route hoisted ahead of the wildcard
|
||||
; /:owner/:name patterns so a pack can never be shadowed.
|
||||
;
|
||||
; Requires: lib/gitea/{repo,access}.sx, lib/dream/{types,router,middleware,
|
||||
; error,html,json,auth,api}.sx
|
||||
|
||||
; ── html scaffolding ─────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-page
|
||||
(fn
|
||||
(title body)
|
||||
(dream-html
|
||||
(str
|
||||
"<!doctype html><html><head><title>"
|
||||
(dream-escape title)
|
||||
"</title></head><body>"
|
||||
body
|
||||
"</body></html>"))))
|
||||
|
||||
(define
|
||||
gitea/w-repo-link
|
||||
(fn
|
||||
(full)
|
||||
(str "<li><a href=\"/" full "\">" (dream-escape full) "</a></li>")))
|
||||
|
||||
; ── auth helpers ─────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-user
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((tok (dream-bearer-token req)))
|
||||
(if (nil? tok) nil (gitea/token-user forge tok)))))
|
||||
|
||||
(define
|
||||
gitea/w-json-status
|
||||
(fn (status v) (dream-response status {:content-type "application/json"} (dream-json-encode v))))
|
||||
|
||||
(define
|
||||
gitea/w-unauthorized
|
||||
(fn () (gitea/w-json-status 401 {:error "unauthorized"})))
|
||||
(define
|
||||
gitea/w-forbidden
|
||||
(fn () (gitea/w-json-status 403 {:error "forbidden"})))
|
||||
|
||||
; can the requester read this repo? (false covers missing repos too)
|
||||
(define
|
||||
gitea/w-readable?
|
||||
(fn
|
||||
(forge req owner name)
|
||||
(gitea/can? forge (gitea/w-user forge req) "read" owner name)))
|
||||
|
||||
; ── pages ────────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-index
|
||||
(fn
|
||||
(forge req)
|
||||
(gitea/w-page
|
||||
"repositories"
|
||||
(str
|
||||
"<h1>Repositories</h1><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
gitea/w-repo-link
|
||||
(gitea/visible-repos forge (gitea/w-user forge req))))
|
||||
"</ul>"))))
|
||||
|
||||
(define
|
||||
gitea/w-branch-item
|
||||
(fn
|
||||
(owner name b)
|
||||
(str
|
||||
"<li><a href=\"/"
|
||||
owner
|
||||
"/"
|
||||
name
|
||||
"/tree/"
|
||||
b
|
||||
"\">"
|
||||
(dream-escape b)
|
||||
"</a></li>")))
|
||||
|
||||
(define
|
||||
gitea/w-repo-home
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((rec (gitea/repo-get forge owner name))
|
||||
(branches (git/branches (gitea/repo-git forge owner name))))
|
||||
(gitea/w-page
|
||||
(str owner "/" name)
|
||||
(str
|
||||
"<h1>"
|
||||
(dream-escape (str owner "/" name))
|
||||
"</h1>"
|
||||
"<p>"
|
||||
(dream-escape (or (get rec :description) ""))
|
||||
"</p>"
|
||||
"<p>visibility: "
|
||||
(dream-escape (get rec :visibility))
|
||||
"</p>"
|
||||
(if
|
||||
(empty? branches)
|
||||
"<p>empty repository</p>"
|
||||
(str
|
||||
"<h2>Branches</h2><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn (b) (gitea/w-branch-item owner name b))
|
||||
branches))
|
||||
"</ul>")))))))))
|
||||
|
||||
(define
|
||||
gitea/w-branches
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(gitea/w-page
|
||||
(str owner "/" name " branches")
|
||||
(str
|
||||
"<h1>Branches</h1><ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn (b) (gitea/w-branch-item owner name b))
|
||||
(git/branches (gitea/repo-git forge owner name))))
|
||||
"</ul>"))))))
|
||||
|
||||
; resolve the owner/name/ref/** of a browse request down to a tree entry;
|
||||
; nil on any miss (unreadable repo, bad ref, bad path)
|
||||
(define
|
||||
gitea/w-entry
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(ref (dream-param req "ref"))
|
||||
(path (or (dream-param req "**") "")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
nil
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(let
|
||||
((cid (gitea/resolve-ref grepo ref)))
|
||||
(if
|
||||
(nil? cid)
|
||||
nil
|
||||
(let
|
||||
((entry (gitea/tree-at grepo cid path)))
|
||||
(if (nil? entry) nil {:name name :path path :grepo grepo :entry entry :owner owner :ref ref})))))))))
|
||||
|
||||
(define
|
||||
gitea/w-entry-item
|
||||
(fn
|
||||
(hit n kind)
|
||||
(let
|
||||
((base (if (equal? kind "tree") "tree" "blob")))
|
||||
(let
|
||||
((sub (if (equal? (get hit :path) "") n (str (get hit :path) "/" n))))
|
||||
(str
|
||||
"<li class=\""
|
||||
kind
|
||||
"\"><a href=\"/"
|
||||
(get hit :owner)
|
||||
"/"
|
||||
(get hit :name)
|
||||
"/"
|
||||
base
|
||||
"/"
|
||||
(get hit :ref)
|
||||
"/"
|
||||
sub
|
||||
"\">"
|
||||
(dream-escape n)
|
||||
"</a></li>")))))
|
||||
|
||||
(define
|
||||
gitea/w-tree
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((hit (gitea/w-entry forge req)))
|
||||
(if
|
||||
(or (nil? hit) (not (equal? (get (get hit :entry) :kind) "tree")))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((tree (git/read (get hit :grepo) (get (get hit :entry) :cid))))
|
||||
(gitea/w-page
|
||||
(str (get hit :owner) "/" (get hit :name) ": /" (get hit :path))
|
||||
(str
|
||||
"<h1>"
|
||||
(dream-escape (str (get hit :owner) "/" (get hit :name)))
|
||||
"</h1>"
|
||||
"<h2>/"
|
||||
(dream-escape (get hit :path))
|
||||
"</h2>"
|
||||
"<ul>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn
|
||||
(n)
|
||||
(gitea/w-entry-item
|
||||
hit
|
||||
n
|
||||
(git/entry-kind (git/tree-entry-for tree n))))
|
||||
(git/tree-names tree)))
|
||||
"</ul>")))))))
|
||||
|
||||
(define
|
||||
gitea/w-blob
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((hit (gitea/w-entry forge req)))
|
||||
(if
|
||||
(or (nil? hit) (not (equal? (get (get hit :entry) :kind) "blob")))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((data (git/blob-data (git/read (get hit :grepo) (get (get hit :entry) :cid)))))
|
||||
(gitea/w-page
|
||||
(str (get hit :owner) "/" (get hit :name) ": " (get hit :path))
|
||||
(str
|
||||
"<h1>"
|
||||
(dream-escape (get hit :path))
|
||||
"</h1>"
|
||||
"<pre>"
|
||||
(dream-escape data)
|
||||
"</pre>")))))))
|
||||
|
||||
(define
|
||||
gitea/w-raw
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((hit (gitea/w-entry forge req)))
|
||||
(if
|
||||
(or (nil? hit) (not (equal? (get (get hit :entry) :kind) "blob")))
|
||||
(dream-not-found)
|
||||
(dream-text
|
||||
(git/blob-data
|
||||
(git/read (get hit :grepo) (get (get hit :entry) :cid))))))))
|
||||
|
||||
(define
|
||||
gitea/w-commit-item
|
||||
(fn
|
||||
(grepo owner name c)
|
||||
(let
|
||||
((obj (git/read grepo c)))
|
||||
(str
|
||||
"<li><a href=\"/"
|
||||
owner
|
||||
"/"
|
||||
name
|
||||
"/commit/"
|
||||
c
|
||||
"\">"
|
||||
(dream-escape (or (git/commit-message obj) ""))
|
||||
"</a> <code>"
|
||||
c
|
||||
"</code></li>"))))
|
||||
|
||||
(define
|
||||
gitea/w-commits
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(ref (dream-param req "ref")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(let
|
||||
((cid (gitea/resolve-ref grepo ref)))
|
||||
(if
|
||||
(nil? cid)
|
||||
(dream-not-found)
|
||||
(gitea/w-page
|
||||
(str owner "/" name " commits")
|
||||
(str
|
||||
"<h1>Commits</h1><ol>"
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn (c) (gitea/w-commit-item grepo owner name c))
|
||||
(git/log grepo cid)))
|
||||
"</ol>")))))))))
|
||||
|
||||
; single commit: message/author/parents, plus the diff against the first
|
||||
; parent (root commits list their files instead)
|
||||
(define
|
||||
gitea/w-commit
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(cidp (dream-param req "cid")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(let
|
||||
((obj (git/read grepo cidp)))
|
||||
(if
|
||||
(or (nil? obj) (not (git/commit? obj)))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((parents (git/commit-parents obj)))
|
||||
(let
|
||||
((detail (if (empty? parents) (str "<h3>Files</h3><ul>" (join "" (map (fn (p) (str "<li>" (dream-escape p) "</li>")) (artdag/sort-strings (keys (git/tree-flatten grepo (git/commit-tree obj)))))) "</ul>") (str "<pre>" (dream-escape (git/commit-diff-unified grepo (first parents) cidp)) "</pre>"))))
|
||||
(gitea/w-page
|
||||
(str "commit " cidp)
|
||||
(str
|
||||
"<h1>"
|
||||
(dream-escape (or (git/commit-message obj) ""))
|
||||
"</h1>"
|
||||
"<p>author: "
|
||||
(dream-escape (or (git/commit-author obj) ""))
|
||||
"</p>"
|
||||
"<p>cid: <code>"
|
||||
cidp
|
||||
"</code></p>"
|
||||
detail)))))))))))
|
||||
|
||||
; ── json api ─────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/w-api-repos
|
||||
(fn
|
||||
(forge req)
|
||||
(dream-json-value (gitea/visible-repos forge (gitea/w-user forge req)))))
|
||||
|
||||
(define
|
||||
gitea/w-api-repo-create
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(if
|
||||
(nil? user)
|
||||
(gitea/w-unauthorized)
|
||||
(let
|
||||
((body (dream-json-body req)))
|
||||
(let
|
||||
((owner (get body :owner)))
|
||||
(cond
|
||||
((not (gitea/owner-exists? forge owner))
|
||||
(gitea/w-json-status 400 {:error "no-such-owner"}))
|
||||
((not (gitea/create-allowed? forge user owner))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(let
|
||||
((res (gitea/repo-create! forge owner (get body :name) {:description (or (get body :description) "") :created-at (or (get body :created-at) 0) :visibility (or (get body :visibility) "public")})))
|
||||
(cond
|
||||
((get res :conflict)
|
||||
(gitea/w-json-status 409 {:error "exists"}))
|
||||
((get res :error)
|
||||
(gitea/w-json-status 400 {:error (get res :error)}))
|
||||
(else (gitea/w-json-status 201 {:name (get res :name) :owner (get res :owner) :visibility (get res :visibility)}))))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-repo-delete
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((not (gitea/can? forge user "read" owner name))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "admin" owner name))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(begin
|
||||
(gitea/repo-delete! forge owner name)
|
||||
(dream-json-value {:deleted true}))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-collab-put
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(cuser (dream-param req "user")))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((not (gitea/can? forge user "read" owner name))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "admin" owner name))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(let
|
||||
((role (or (get (dream-json-body req) :role) "read")))
|
||||
(let
|
||||
((res (gitea/collab-add! forge owner name cuser role)))
|
||||
(if
|
||||
(get res :error)
|
||||
(gitea/w-json-status 400 {:error (get res :error)})
|
||||
(dream-json-value {:role role :user cuser}))))))))))
|
||||
|
||||
(define
|
||||
gitea/w-api-collab-delete
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner"))
|
||||
(name (dream-param req "name"))
|
||||
(cuser (dream-param req "user")))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((not (gitea/can? forge user "read" owner name))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "admin" owner name))
|
||||
(gitea/w-forbidden))
|
||||
((gitea/collab-remove! forge owner name cuser)
|
||||
(dream-json-value {:deleted true}))
|
||||
(else (dream-not-found)))))))
|
||||
|
||||
; ── routes ───────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/routes
|
||||
(fn
|
||||
(forge)
|
||||
(list
|
||||
(dream-get "/" (fn (req) (gitea/w-index forge req)))
|
||||
(dream-get "/api/repos" (fn (req) (gitea/w-api-repos forge req)))
|
||||
(dream-post
|
||||
"/api/repos"
|
||||
(fn (req) (gitea/w-api-repo-create forge req)))
|
||||
(dream-delete
|
||||
"/api/repos/:owner/:name"
|
||||
(fn (req) (gitea/w-api-repo-delete forge req)))
|
||||
(dream-put
|
||||
"/api/repos/:owner/:name/collab/:user"
|
||||
(fn (req) (gitea/w-api-collab-put forge req)))
|
||||
(dream-delete
|
||||
"/api/repos/:owner/:name/collab/:user"
|
||||
(fn (req) (gitea/w-api-collab-delete forge req)))
|
||||
(dream-get "/:owner/:name" (fn (req) (gitea/w-repo-home forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/branches"
|
||||
(fn (req) (gitea/w-branches forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/commits/:ref"
|
||||
(fn (req) (gitea/w-commits forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/commit/:cid"
|
||||
(fn (req) (gitea/w-commit forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/tree/:ref/**"
|
||||
(fn (req) (gitea/w-tree forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/blob/:ref/**"
|
||||
(fn (req) (gitea/w-blob forge req)))
|
||||
(dream-get
|
||||
"/:owner/:name/raw/:ref/**"
|
||||
(fn (req) (gitea/w-raw forge req))))))
|
||||
|
||||
; extension point: wire/issues/pr/activity/... append their packs at load
|
||||
(define gitea/route-packs (list gitea/routes))
|
||||
|
||||
; every /api/* route (from any pack) dispatches before the wildcard
|
||||
; /:owner/:name patterns, so later packs can add API endpoints freely
|
||||
(define
|
||||
gitea/all-routes
|
||||
(fn
|
||||
(forge)
|
||||
(let
|
||||
((rs (reduce (fn (acc pack) (concat acc (pack forge))) (list) gitea/route-packs)))
|
||||
(concat
|
||||
(filter (fn (r) (starts-with? (dream-route-path r) "/api/")) rs)
|
||||
(filter
|
||||
(fn (r) (not (starts-with? (dream-route-path r) "/api/")))
|
||||
rs)))))
|
||||
|
||||
(define gitea/app (fn (forge) (dream-make-app (gitea/all-routes forge))))
|
||||
554
lib/gitea/wire.sx
Normal file
554
lib/gitea/wire.sx
Normal file
@@ -0,0 +1,554 @@
|
||||
; lib/gitea/wire.sx — sx-gitea Phase 3: smart-HTTP wire protocol.
|
||||
;
|
||||
; git-style smart HTTP over the NATIVE sx-git object model: pkt-line
|
||||
; framing (byte-compatible with git's framing), ref advertisement,
|
||||
; upload-pack (clone/fetch) and receive-pack (push). Objects travel as
|
||||
; "<cid> <serialized-sx>" pkt lines — the receiver re-derives the CID
|
||||
; from the bytes, so a pack can't lie about its contents. SHA-1/packfile
|
||||
; byte compat for stock git clients stays in lib/git/{export,import}.sx.
|
||||
;
|
||||
; Endpoints (registered on gitea/route-packs):
|
||||
; GET /:owner/:name/info/refs read-gated ref advertisement
|
||||
; POST /:owner/:name/git-upload-pack read-gated; wants/haves -> pack
|
||||
; POST /:owner/:name/git-receive-pack write-gated; commands+pack -> statuses
|
||||
;
|
||||
; A client (gitea/remote over any dream app fn) provides ls-remote /
|
||||
; clone! / fetch! / push! / push-delete! — two in-memory forges can sync
|
||||
; with no sockets anywhere.
|
||||
;
|
||||
; Limits: one object per pkt line => objects over ~64KB need side-band
|
||||
; chunking (future extension); gitea/pkt-fits? reports this.
|
||||
;
|
||||
; Requires: lib/gitea/{repo,access,web}.sx and their stacks, plus
|
||||
; sx-parse (spec/parser.sx on the OCaml server host).
|
||||
|
||||
; ── pkt-line framing ─────────────────────────────────────────────────
|
||||
|
||||
(define gitea/hex-chars "0123456789abcdef")
|
||||
|
||||
(define
|
||||
gitea/hex4
|
||||
(fn
|
||||
(n)
|
||||
(str
|
||||
(char-at gitea/hex-chars (mod (quotient n 4096) 16))
|
||||
(char-at gitea/hex-chars (mod (quotient n 256) 16))
|
||||
(char-at gitea/hex-chars (mod (quotient n 16) 16))
|
||||
(char-at gitea/hex-chars (mod n 16)))))
|
||||
|
||||
(define gitea/hex-val (fn (c) (index-of gitea/hex-chars (lower c))))
|
||||
|
||||
(define
|
||||
gitea/hex4-parse
|
||||
(fn
|
||||
(s)
|
||||
(+
|
||||
(* 4096 (gitea/hex-val (char-at s 0)))
|
||||
(* 256 (gitea/hex-val (char-at s 1)))
|
||||
(* 16 (gitea/hex-val (char-at s 2)))
|
||||
(gitea/hex-val (char-at s 3)))))
|
||||
|
||||
(define gitea/pkt-max 65531)
|
||||
(define gitea/pkt-fits? (fn (s) (<= (string-length s) gitea/pkt-max)))
|
||||
|
||||
(define
|
||||
gitea/pkt
|
||||
(fn
|
||||
(s)
|
||||
(if
|
||||
(gitea/pkt-fits? s)
|
||||
(str (gitea/hex4 (+ (string-length s) 4)) s)
|
||||
(error "gitea/pkt: payload exceeds pkt-line limit"))))
|
||||
|
||||
(define gitea/pkt-flush "0000")
|
||||
|
||||
; frame a list of line-lists, flush after each section
|
||||
(define
|
||||
gitea/pkt-render
|
||||
(fn
|
||||
(sections)
|
||||
(join
|
||||
""
|
||||
(map
|
||||
(fn (lines) (str (join "" (map gitea/pkt lines)) gitea/pkt-flush))
|
||||
sections))))
|
||||
|
||||
; parse framed data into sections (lists of lines) split on flush pkts
|
||||
(define
|
||||
gitea/pkt-sections-loop
|
||||
(fn
|
||||
(data i cur sections)
|
||||
(if
|
||||
(>= i (string-length data))
|
||||
(reverse (if (empty? cur) sections (cons (reverse cur) sections)))
|
||||
(let
|
||||
((n (gitea/hex4-parse (substr data i 4))))
|
||||
(if
|
||||
(= n 0)
|
||||
(gitea/pkt-sections-loop
|
||||
data
|
||||
(+ i 4)
|
||||
(list)
|
||||
(cons (reverse cur) sections))
|
||||
(gitea/pkt-sections-loop
|
||||
data
|
||||
(+ i n)
|
||||
(cons (substr data (+ i 4) (- n 4)) cur)
|
||||
sections))))))
|
||||
|
||||
(define
|
||||
gitea/pkt-sections
|
||||
(fn (data) (gitea/pkt-sections-loop data 0 (list) (list))))
|
||||
|
||||
; ── object closure ───────────────────────────────────────────────────
|
||||
|
||||
; cids an object references
|
||||
(define
|
||||
gitea/obj-refs
|
||||
(fn
|
||||
(obj)
|
||||
(cond
|
||||
((git/commit? obj)
|
||||
(cons (git/commit-tree obj) (git/commit-parents obj)))
|
||||
((git/tree? obj)
|
||||
(map
|
||||
(fn (n) (git/entry-cid (git/tree-entry-for obj n)))
|
||||
(git/tree-names obj)))
|
||||
((git/tag? obj) (list (git/tag-target obj)))
|
||||
(else (list)))))
|
||||
|
||||
; walk from pending cids; returns {:seen {cid true} :missing (cids)}
|
||||
(define
|
||||
gitea/closure-walk
|
||||
(fn
|
||||
(grepo pending seen missing)
|
||||
(if
|
||||
(empty? pending)
|
||||
{:seen seen :missing (reverse missing)}
|
||||
(let
|
||||
((cid (first pending)) (more (rest pending)))
|
||||
(if
|
||||
(get seen cid)
|
||||
(gitea/closure-walk grepo more seen missing)
|
||||
(let
|
||||
((obj (git/read grepo cid)))
|
||||
(if
|
||||
(nil? obj)
|
||||
(gitea/closure-walk grepo more seen (cons cid missing))
|
||||
(gitea/closure-walk
|
||||
grepo
|
||||
(concat (gitea/obj-refs obj) more)
|
||||
(assoc seen cid true)
|
||||
missing))))))))
|
||||
|
||||
(define
|
||||
gitea/closure
|
||||
(fn
|
||||
(grepo cids)
|
||||
(get (gitea/closure-walk grepo cids {} (list)) :seen)))
|
||||
|
||||
(define
|
||||
gitea/closure-list
|
||||
(fn (grepo cids) (artdag/sort-strings (keys (gitea/closure grepo cids)))))
|
||||
|
||||
(define
|
||||
gitea/closure-complete?
|
||||
(fn
|
||||
(grepo cids)
|
||||
(empty?
|
||||
(get (gitea/closure-walk grepo cids {} (list)) :missing))))
|
||||
|
||||
; objects needed to bring someone with `haves` up to `wants`
|
||||
(define
|
||||
gitea/pack-cids
|
||||
(fn
|
||||
(grepo wants haves)
|
||||
(let
|
||||
((have-set (gitea/closure grepo haves)))
|
||||
(filter
|
||||
(fn (c) (not (get have-set c)))
|
||||
(gitea/closure-list grepo wants)))))
|
||||
|
||||
; ── wire object encoding ─────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/pack-line
|
||||
(fn (grepo cid) (str cid " " (serialize (git/read grepo cid)))))
|
||||
|
||||
; parse "<cid> <sx>" and verify the cid matches the bytes
|
||||
(define
|
||||
gitea/pack-line-parse
|
||||
(fn
|
||||
(line)
|
||||
(let
|
||||
((sp (index-of line " ")))
|
||||
(if
|
||||
(< sp 0)
|
||||
{:error "malformed"}
|
||||
(let
|
||||
((cid (substr line 0 sp))
|
||||
(obj (first (sx-parse (substr line (+ sp 1))))))
|
||||
(if (= (git/cid obj) cid) {:obj obj :cid cid} {:error "cid-mismatch" :cid cid}))))))
|
||||
|
||||
; verify + store every pack line; => {:stored n} | {:error ...}
|
||||
(define
|
||||
gitea/unpack!
|
||||
(fn
|
||||
(grepo lines)
|
||||
(reduce
|
||||
(fn
|
||||
(acc line)
|
||||
(if
|
||||
(get acc :error)
|
||||
acc
|
||||
(let
|
||||
((p (gitea/pack-line-parse line)))
|
||||
(if
|
||||
(get p :error)
|
||||
p
|
||||
(begin (git/write grepo (get p :obj)) {:stored (+ (get acc :stored) 1)})))))
|
||||
{:stored 0}
|
||||
lines)))
|
||||
|
||||
; ── server: ref advertisement ────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/shared-ref?
|
||||
(fn (name) (or (starts-with? name "heads/") (starts-with? name "tags/"))))
|
||||
|
||||
(define
|
||||
gitea/wire-refs
|
||||
(fn
|
||||
(grepo)
|
||||
(filter (fn (n) (gitea/shared-ref? n)) (git/ref-names grepo))))
|
||||
|
||||
(define
|
||||
gitea/w-info-refs
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(let
|
||||
((head-line (let ((target (git/head-target grepo))) (if (nil? target) (list) (list (str "@ " target)))))
|
||||
(ref-lines
|
||||
(map
|
||||
(fn (n) (str (git/ref-get grepo n) " " n))
|
||||
(gitea/wire-refs grepo))))
|
||||
(dream-text
|
||||
(gitea/pkt-render (list (concat head-line ref-lines))))))))))
|
||||
|
||||
; ── server: upload-pack (clone/fetch) ────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/want-lines
|
||||
(fn
|
||||
(lines prefix)
|
||||
(map
|
||||
(fn (l) (substr l (string-length prefix)))
|
||||
(filter (fn (l) (starts-with? l prefix)) lines))))
|
||||
|
||||
(define
|
||||
gitea/w-upload-pack
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(if
|
||||
(not (gitea/w-readable? forge req owner name))
|
||||
(dream-not-found)
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name))
|
||||
(sections (gitea/pkt-sections (dream-body req))))
|
||||
(let
|
||||
((lines (if (empty? sections) (list) (first sections))))
|
||||
(let
|
||||
((wants (gitea/want-lines lines "want "))
|
||||
(haves (gitea/want-lines lines "have ")))
|
||||
(dream-text
|
||||
(gitea/pkt-render
|
||||
(list
|
||||
(map
|
||||
(fn (c) (gitea/pack-line grepo c))
|
||||
(gitea/pack-cids grepo wants haves))))))))))))
|
||||
|
||||
; ── server: receive-pack (push) ──────────────────────────────────────
|
||||
|
||||
(define gitea/zero-ref "-")
|
||||
|
||||
; "<old> <new> <refname>" — old/new are cids or "-"
|
||||
(define
|
||||
gitea/cmd-parse
|
||||
(fn
|
||||
(line)
|
||||
(let
|
||||
((a (index-of line " ")))
|
||||
(let ((b (index-of (substr line (+ a 1)) " "))) {:name (substr line (+ a 1 b 1)) :new (substr line (+ a 1) b) :old (substr line 0 a)}))))
|
||||
|
||||
; apply one ref command; => "ok <name>" | "ng <name> <reason>"
|
||||
(define
|
||||
gitea/apply-cmd!
|
||||
(fn
|
||||
(grepo cmd)
|
||||
(let
|
||||
((old (get cmd :old)) (new (get cmd :new)) (name (get cmd :name)))
|
||||
(cond
|
||||
((not (gitea/shared-ref? name)) (str "ng " name " invalid-ref"))
|
||||
((equal? new gitea/zero-ref)
|
||||
(if
|
||||
(=
|
||||
(git/ref-get grepo name)
|
||||
(if (equal? old gitea/zero-ref) nil old))
|
||||
(begin (git/ref-delete! grepo name) (str "ok " name))
|
||||
(str "ng " name " stale")))
|
||||
((not (gitea/closure-complete? grepo (list new)))
|
||||
(str "ng " name " missing-objects"))
|
||||
((and (not (equal? old gitea/zero-ref)) (starts-with? name "heads/") (not (git/is-ancestor? grepo old new)))
|
||||
(str "ng " name " non-fast-forward"))
|
||||
(else
|
||||
(let
|
||||
((res (git/ref-cas! grepo name (if (equal? old gitea/zero-ref) nil old) new)))
|
||||
(if
|
||||
(and (dict? res) (get res :conflict))
|
||||
(str "ng " name " stale")
|
||||
(str "ok " name))))))))
|
||||
|
||||
(define
|
||||
gitea/w-receive-pack
|
||||
(fn
|
||||
(forge req)
|
||||
(let
|
||||
((owner (dream-param req "owner")) (name (dream-param req "name")))
|
||||
(let
|
||||
((user (gitea/w-user forge req)))
|
||||
(cond
|
||||
((not (gitea/can? forge user "read" owner name))
|
||||
(dream-not-found))
|
||||
((nil? user) (gitea/w-unauthorized))
|
||||
((not (gitea/can? forge user "write" owner name))
|
||||
(gitea/w-forbidden))
|
||||
(else
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name))
|
||||
(sections (gitea/pkt-sections (dream-body req))))
|
||||
(let
|
||||
((cmds (if (empty? sections) (list) (first sections)))
|
||||
(objs
|
||||
(if
|
||||
(< (len sections) 2)
|
||||
(list)
|
||||
(nth sections 1))))
|
||||
(let
|
||||
((unpack (gitea/unpack! grepo objs)))
|
||||
(if
|
||||
(get unpack :error)
|
||||
(dream-text
|
||||
(gitea/pkt-render
|
||||
(list (list (str "unpack " (get unpack :error))))))
|
||||
(dream-text
|
||||
(gitea/pkt-render
|
||||
(list
|
||||
(cons
|
||||
"unpack ok"
|
||||
(map
|
||||
(fn
|
||||
(c)
|
||||
(gitea/apply-cmd! grepo (gitea/cmd-parse c)))
|
||||
cmds)))))))))))))))
|
||||
|
||||
; ── routes ───────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
gitea/wire-routes
|
||||
(fn
|
||||
(forge)
|
||||
(list
|
||||
(dream-get
|
||||
"/:owner/:name/info/refs"
|
||||
(fn (req) (gitea/w-info-refs forge req)))
|
||||
(dream-post
|
||||
"/:owner/:name/git-upload-pack"
|
||||
(fn (req) (gitea/w-upload-pack forge req)))
|
||||
(dream-post
|
||||
"/:owner/:name/git-receive-pack"
|
||||
(fn (req) (gitea/w-receive-pack forge req))))))
|
||||
|
||||
(set! gitea/route-packs (append gitea/route-packs (list gitea/wire-routes)))
|
||||
|
||||
; back-compat aliases from before the route-pack registry
|
||||
(define gitea/forge-routes gitea/all-routes)
|
||||
(define gitea/forge-app gitea/app)
|
||||
|
||||
; ── client ───────────────────────────────────────────────────────────
|
||||
; A remote is any dream app fn plus repo coordinates and a token — the
|
||||
; same code drives an in-memory forge or (later) a real HTTP transport.
|
||||
|
||||
(define gitea/remote (fn (app owner name token) {:name name :token token :owner owner :app app}))
|
||||
|
||||
(define
|
||||
gitea/remote-call
|
||||
(fn
|
||||
(remote method suffix body)
|
||||
((get remote :app)
|
||||
(dream-request
|
||||
method
|
||||
(str "/" (get remote :owner) "/" (get remote :name) suffix)
|
||||
(if (nil? (get remote :token)) {} {:authorization (str "Bearer " (get remote :token))})
|
||||
body))))
|
||||
|
||||
; => {:head "heads/..."|nil :refs {name cid}} | nil when unreachable
|
||||
(define
|
||||
gitea/ls-remote
|
||||
(fn
|
||||
(remote)
|
||||
(let
|
||||
((resp (gitea/remote-call remote "GET" "/info/refs" "")))
|
||||
(if
|
||||
(not (= (dream-status resp) 200))
|
||||
nil
|
||||
(let
|
||||
((lines (let ((s (gitea/pkt-sections (dream-resp-body resp)))) (if (empty? s) (list) (first s)))))
|
||||
(reduce
|
||||
(fn
|
||||
(acc l)
|
||||
(let
|
||||
((sp (index-of l " ")))
|
||||
(if
|
||||
(starts-with? l "@ ")
|
||||
(assoc acc :head (substr l 2))
|
||||
(assoc
|
||||
acc
|
||||
:refs (assoc
|
||||
(get acc :refs)
|
||||
(substr l (+ sp 1))
|
||||
(substr l 0 sp))))))
|
||||
{:refs {} :head nil}
|
||||
lines))))))
|
||||
|
||||
; fetch the closure of `wants` (minus `haves`) into grepo
|
||||
(define
|
||||
gitea/fetch-pack!
|
||||
(fn
|
||||
(remote grepo wants haves)
|
||||
(let
|
||||
((resp (gitea/remote-call remote "POST" "/git-upload-pack" (gitea/pkt-render (list (concat (map (fn (c) (str "want " c)) wants) (map (fn (c) (str "have " c)) haves)))))))
|
||||
(if
|
||||
(not (= (dream-status resp) 200))
|
||||
{:error (dream-status resp)}
|
||||
(let
|
||||
((s (gitea/pkt-sections (dream-resp-body resp))))
|
||||
(gitea/unpack! grepo (if (empty? s) (list) (first s))))))))
|
||||
|
||||
; mirror-style fetch: pull missing objects, retarget local heads/tags
|
||||
; => {:refs ... :stored n} | {:error status}
|
||||
(define
|
||||
gitea/fetch!
|
||||
(fn
|
||||
(remote grepo)
|
||||
(let
|
||||
((ls (gitea/ls-remote remote)))
|
||||
(if
|
||||
(nil? ls)
|
||||
{:error 404}
|
||||
(let
|
||||
((refs (get ls :refs)))
|
||||
(let
|
||||
((wants (filter (fn (c) (not (git/has? grepo c))) (map (fn (n) (get refs n)) (keys refs))))
|
||||
(haves
|
||||
(filter
|
||||
(fn (c) (not (nil? c)))
|
||||
(map
|
||||
(fn (b) (git/branch-get grepo b))
|
||||
(git/branches grepo)))))
|
||||
(let
|
||||
((res (if (empty? wants) {:stored 0} (gitea/fetch-pack! remote grepo wants haves))))
|
||||
(if
|
||||
(get res :error)
|
||||
res
|
||||
(begin
|
||||
(for-each
|
||||
(fn (n) (git/ref-set! grepo n (get refs n)))
|
||||
(keys refs))
|
||||
{:refs refs :stored (get res :stored)})))))))))
|
||||
|
||||
; clone a remote into this forge as owner/name; the repo is removed
|
||||
; again if the remote turns out to be unreachable
|
||||
(define
|
||||
gitea/clone!
|
||||
(fn
|
||||
(forge owner name remote opts)
|
||||
(let
|
||||
((rec (gitea/repo-create! forge owner name (or opts {}))))
|
||||
(if
|
||||
(or (get rec :error) (get rec :conflict))
|
||||
rec
|
||||
(let
|
||||
((grepo (gitea/repo-git forge owner name)))
|
||||
(let
|
||||
((res (gitea/fetch! remote grepo)))
|
||||
(if
|
||||
(get res :error)
|
||||
(begin (gitea/repo-delete! forge owner name) res)
|
||||
(let
|
||||
((head (get (gitea/ls-remote remote) :head)))
|
||||
(begin
|
||||
(if
|
||||
(and head (starts-with? head "heads/"))
|
||||
(let
|
||||
((branch (substr head (string-length "heads/"))))
|
||||
(begin
|
||||
(git/head-set! grepo branch)
|
||||
(gitea/repo-update!
|
||||
forge
|
||||
owner
|
||||
name
|
||||
(fn (r) (assoc r :default-branch branch)))
|
||||
nil))
|
||||
nil)
|
||||
rec)))))))))
|
||||
|
||||
; push one ref; => {:ok true} | {:ng reason} | {:error status}
|
||||
(define
|
||||
gitea/push-cmd!
|
||||
(fn
|
||||
(remote grepo refname new)
|
||||
(let
|
||||
((ls (gitea/ls-remote remote)))
|
||||
(if
|
||||
(nil? ls)
|
||||
{:error 404}
|
||||
(let
|
||||
((old (or (get (get ls :refs) refname) gitea/zero-ref)))
|
||||
(let
|
||||
((pack (if (equal? new gitea/zero-ref) (list) (gitea/pack-cids grepo (list new) (if (equal? old gitea/zero-ref) (list) (list old))))))
|
||||
(let
|
||||
((resp (gitea/remote-call remote "POST" "/git-receive-pack" (gitea/pkt-render (list (list (str old " " new " " refname)) (map (fn (c) (gitea/pack-line grepo c)) pack))))))
|
||||
(if
|
||||
(not (= (dream-status resp) 200))
|
||||
{:error (dream-status resp)}
|
||||
(let
|
||||
((lines (let ((s (gitea/pkt-sections (dream-resp-body resp)))) (if (empty? s) (list) (first s)))))
|
||||
(let
|
||||
((status (first (filter (fn (l) (or (starts-with? l "ok ") (starts-with? l "ng "))) lines))))
|
||||
(cond
|
||||
((nil? status) {:error "no-status"})
|
||||
((starts-with? status "ok ") {:ok true})
|
||||
(else {:ng (substr status (+ (index-of (substr status 3) " ") 4))}))))))))))))
|
||||
|
||||
(define
|
||||
gitea/push!
|
||||
(fn
|
||||
(remote grepo refname)
|
||||
(let
|
||||
((new (git/ref-get grepo refname)))
|
||||
(if (nil? new) {:error "no-such-local-ref"} (gitea/push-cmd! remote grepo refname new)))))
|
||||
|
||||
(define
|
||||
gitea/push-delete!
|
||||
(fn
|
||||
(remote grepo refname)
|
||||
(gitea/push-cmd! remote grepo refname gitea/zero-ref)))
|
||||
Reference in New Issue
Block a user