--- /dev/null
+#!/usr/bin/gawk -bf
+#
+# update.awk - storage backend for the patchbot backport review page
+#
+# Stores the human review edits (verdict overrides and notes) made on the
+# patchbot HTML page into a per-branch file kept in a dedicated git
+# repository, so that they are durable and shared between reviewers.
+#
+# This script is exec'd by a tiny "update.cgi" shell wrapper which is the
+# only web-exposed piece and holds the deployment-specific configuration:
+#
+# #!/bin/sh
+# exec /path/to/update.awk -r /path/to/repo
+#
+# The wrapper lives next to the generated HTML pages (thttpd must have the
+# "**.cgi" pattern enabled so that it is executed, not served). This script,
+# the repository and its .git must all stay OUTSIDE the HTTP document root.
+# The repository is a plain git working tree with a configured committer
+# identity, containing one file per major branch (e.g. "3.5") with one line
+# per touched commit:
+#
+# <commit_id> [state <n|u|w|y>] [notes "<escaped text>"]
+#
+# Commit ids are lowercase hex of any length; matching is symmetric-prefix
+# (two ids designate the same commit iff one is a prefix of the other) and
+# stops on the first match. Notes are double-quoted with '\' escaping '"'
+# and '\'; no stored line may ever contain a newline. Malformed fields or
+# lines are silently ignored, never fatal, and lines that are not being
+# modified are preserved byte-for-byte (admin hand-edits are legal).
+#
+# Requests (regular CGI environment):
+# - POST update.cgi?branch=3.5 with a line-oriented body:
+# <cid> state <n|u|w|y|revert>
+# <cid> notes <text to append>
+# "state" overrides the verdict, "revert" (aliases "same", "unchanged")
+# removes the override so the bot's verdict applies again, "notes"
+# appends to the commit's notes (capped to 500 chars per push). Broken
+# directives are dropped, the survivors are applied under the lock and
+# the result is committed to git. Neither directive carries a base/old
+# value: state is last-write-wins and notes are append-only, which is
+# what keeps concurrent edits conflict-free.
+# - GET update.cgi?branch=3.5 returns the overlay as a JSON array
+# (not implemented yet).
+#
+# Requires GNU awk (PROCINFO, systime); the -b flag in the shebang makes all
+# string operations byte-based regardless of the locale, which the escaping
+# and the caps depend on. A few points deserve attention:
+# - external commands (git, mkdir, mv, kill) go through /bin/sh, so
+# everything interpolated into a command is shell-quoted with q();
+# - NUL bytes in inputs are not reliably preserved by awk; they can only
+# occur in malformed requests, which are tolerated, not honoured;
+# - writing through a redirection whose target cannot be opened is a
+# fatal error in awk, terminating us without even a response, so the
+# writes into the (stealable) lock dir are arranged to be immune: the
+# pid goes through the shell and the temp file is opened the instant
+# the lock is acquired, its descriptor surviving a later theft.
+#
+# The script always exits zero once a response has been emitted, including
+# its own error responses, while a fatal awk error exits non-zero without
+# any output: a supervising wrapper can thus safely map any non-zero
+# termination to a generic error response.
+#
+# Fully testable from a shell without any HTTP server:
+#
+# printf '%s\n' 'deadbeef1234 state y' 'deadbeef1234 notes checked' > body
+# REQUEST_METHOD=POST QUERY_STRING=branch=3.5 \
+# CONTENT_LENGTH=$(stat -c %s body) ./update.awk -r /path/to/repo < body
+
+BEGIN {
+ MAX_CID_LEN = 40 # bound on a commit id (full SHA-1)
+ MAX_NOTE_LEN = 500 # cap on a single pushed note addition
+ MAX_BRANCH_LEN = 15 # bound on the branch name
+ MAX_BODY_LEN = 1048576 # bound on a POST body
+ NOTE_SEP = "; " # separator between coalesced notes
+ LOCK_RETRIES = 100 # lock attempts before reporting busy
+ LOCK_SLEEP = 0.05 # sleep between two attempts (~5s total)
+ LOCK_STALE_AGE = 60 # age backstop when the pid file is unusable
+
+ # byte value of every possible character, for escaping decisions
+ for (i = 0; i < 256; i++)
+ ORD[sprintf("%c", i)] = i
+
+ # Arguments: -r <repo>. Note that when invoked through the shebang,
+ # gawk itself consumes the leading "-r" (its --re-interval flag, a
+ # no-op nowadays), and the repository path then reaches us as the
+ # first bare operand: accept both forms.
+ repo = ""
+ for (i = 1; i < ARGC; i++) {
+ if (ARGV[i] == "-r" && i + 1 < ARGC) {
+ repo = ARGV[i + 1]
+ ARGV[i] = ARGV[i + 1] = ""
+ i++
+ }
+ else if (ARGV[i] != "" && repo == "") {
+ repo = ARGV[i]
+ ARGV[i] = ""
+ }
+ else if (ARGV[i] != "") {
+ print "Usage: update.awk -r /path/to/repo" > "/dev/stderr"
+ die("500 Internal Server Error", "server misconfigured")
+ }
+ }
+
+ if (repo == "" || system("test -d " q(repo)) != 0)
+ die("500 Internal Server Error", "server misconfigured")
+
+ lock_path = repo "/lock"
+ lock_pid = lock_path "/pid"
+ lock_tmp = lock_path "/tmp"
+
+ branch = get_branch()
+
+ if (ENVIRON["REQUEST_METHOD"] == "POST")
+ handle_post()
+ else if (ENVIRON["REQUEST_METHOD"] == "GET")
+ die("501 Not Implemented", "GET not implemented yet")
+ else
+ die("405 Method Not Allowed", "unsupported method")
+ exit 0
+}
+
+# shell-quotes <s> so it can be safely interpolated into a command line:
+# every external command goes through /bin/sh, this is the only protection.
+function q(s)
+{
+ gsub(/'/, "'\\\\''", s)
+ return "'" s "'"
+}
+
+# prints a complete CGI error response and exits, releasing the lock if it
+# was held. Also logs to stderr (ends up in the web server's error log).
+function die(status, msg)
+{
+ if (lock_held)
+ lock_release()
+ print "update.awk: " msg " (" status ")" > "/dev/stderr"
+ printf "Status: %s\r\nContent-Type: text/plain\r\n\r\n%s\n", status, msg
+ exit 0
+}
+
+# Extracts and validates the "branch" parameter from QUERY_STRING (used for
+# both GET and POST). The strict digits-dot-digits pattern is the path
+# traversal guard: the branch is the only request-controlled component of
+# the storage file path and nothing else may ever reach the path building.
+function get_branch( n, i, p, v)
+{
+ n = split(ENVIRON["QUERY_STRING"], p, "&")
+ v = ""
+ for (i = 1; i <= n; i++) {
+ if (substr(p[i], 1, 7) == "branch=") {
+ v = substr(p[i], 8)
+ break
+ }
+ }
+ if (v == "" || length(v) > MAX_BRANCH_LEN || v !~ /^[0-9]+\.[0-9]+$/)
+ die("400 Bad Request", "missing or invalid branch")
+ return v
+}
+
+# Symmetric-prefix commit id match: two ids designate the same commit iff
+# one is a prefix of the other. The caller scans in file order and stops on
+# the first match; providing enough digits to stay unambiguous is the
+# writer's responsibility (12 recommended). A too-short collision merely
+# lands on the wrong line and is admin-fixable by editing the file.
+function cid_match(a, b)
+{
+ return index(a, b) == 1 || index(b, a) == 1
+}
+
+# returns the leading commit id of a storage line (lowercase hex followed by
+# a blank or the end of line), or "" if none parses
+function line_cid(line, c, nxt)
+{
+ sub(/^[ \t]+/, "", line)
+ if (!match(line, /^[0-9a-f]+/))
+ return ""
+ c = substr(line, 1, RLENGTH)
+ nxt = substr(line, RLENGTH + 1, 1)
+ if (length(c) > MAX_CID_LEN || (nxt != "" && nxt != " " && nxt != "\t"))
+ return ""
+ return c
+}
+
+# Parses storage line <line> into P_cid/P_state/P_notes/P_has_notes. Returns
+# 1 if a valid commit id was found (the entry is usable), 0 otherwise. Any
+# broken or unknown field is silently dropped, never fatal, so that one bad
+# hand-edit cannot break the whole file and future format additions don't
+# trip older code.
+function parse_line(line, p, v, nxt, out, i, n, c, closed)
+{
+ P_cid = ""; P_state = ""; P_notes = ""; P_has_notes = 0
+
+ P_cid = line_cid(line)
+ if (P_cid == "")
+ return 0
+ sub(/^[ \t]+/, "", line)
+ p = substr(line, length(P_cid) + 1)
+
+ while (p != "") {
+ sub(/^[ \t]+/, "", p)
+ if (p == "")
+ break
+
+ if (match(p, /^state[ \t]+/)) {
+ v = substr(p, RLENGTH + 1, 1)
+ nxt = substr(p, RLENGTH + 2, 1)
+ if (v ~ /^[nuwy]$/ && (nxt == "" || nxt == " " || nxt == "\t")) {
+ P_state = v
+ p = substr(p, RLENGTH + 2)
+ continue
+ }
+ # unknown state value: drop the field
+ }
+ else if (match(p, /^notes[ \t]+"/)) {
+ out = ""; closed = 0
+ i = RLENGTH + 1
+ n = length(p)
+ while (i <= n) {
+ c = substr(p, i, 1)
+ if (c == "\"") {
+ closed = 1
+ i++
+ break
+ }
+ if (c == "\\" && i < n) {
+ i++
+ c = substr(p, i, 1)
+ }
+ i++
+ if (c == "\r")
+ continue
+ if (ORD[c] < 32 || ORD[c] == 127)
+ c = " "
+ out = out c
+ }
+ if (closed) {
+ P_notes = out
+ P_has_notes = 1
+ p = substr(p, i)
+ continue
+ }
+ # unterminated quote: drop the field and what follows,
+ # it cannot be delimited
+ break
+ }
+ # unknown or broken field: skip one token and try again
+ sub(/^[^ \t]+/, "", p)
+ }
+ return 1
+}
+
+# formats an entry back into a storage line. Notes are quoted with '\'
+# escaping '"' and '\'; as a hard invariant, no control char (and especially
+# no newline) may ever be emitted inside a line, or the one-line-per-commit
+# format breaks, so anything unexpected is defensively turned into a space.
+function fmt_entry(cid, state, notes, has_notes, line, out, i, n, c)
+{
+ line = cid
+ if (state != "")
+ line = line " state " state
+ if (has_notes) {
+ out = ""
+ n = length(notes)
+ for (i = 1; i <= n; i++) {
+ c = substr(notes, i, 1)
+ if (c == "\r")
+ continue
+ if (c == "\"" || c == "\\")
+ out = out "\\" c
+ else if (ORD[c] < 32 || ORD[c] == 127)
+ out = out " "
+ else
+ out = out c
+ }
+ line = line " notes \"" out "\""
+ }
+ return line
+}
+
+# Sanitises a pushed note: CR is dropped, any other control char becomes a
+# space (nothing may ever introduce a newline into a stored line), then the
+# text is trimmed and capped to <cap> bytes on a UTF-8 character boundary.
+function sanitize_note(s, cap, b)
+{
+ gsub(/\r/, "", s)
+ gsub(/[\x01-\x1f\x7f]/, " ", s)
+ sub(/^ +/, "", s)
+ sub(/ +$/, "", s)
+ if (length(s) > cap) {
+ # never cut in the middle of a UTF-8 sequence: back off while
+ # the first dropped byte is a continuation byte (0x80-0xBF)
+ b = ORD[substr(s, cap + 1, 1)]
+ while (cap > 0 && b >= 128 && b < 192) {
+ cap--
+ b = ORD[substr(s, cap + 1, 1)]
+ }
+ s = substr(s, 1, cap)
+ sub(/ +$/, "", s)
+ }
+ return s
+}
+
+# reads the POST body from stdin according to CONTENT_LENGTH
+function read_body( cl, body, line, got)
+{
+ if (ENVIRON["CONTENT_LENGTH"] !~ /^[0-9]+$/ || \
+ ENVIRON["CONTENT_LENGTH"] + 0 > MAX_BODY_LEN)
+ die("400 Bad Request", "missing or invalid content length")
+ cl = ENVIRON["CONTENT_LENGTH"] + 0
+
+ body = ""; got = 0
+ while (got < cl && (getline line < "/dev/stdin") > 0) {
+ got += length(line) + length(RT)
+ body = body line "\n"
+ }
+ if (got < cl)
+ die("400 Bad Request", "truncated body")
+ return body
+}
+
+# Parses the POST body into the d_* directive arrays. Broken directives are
+# dropped, never fatal: a non-hex or over-long cid, an unknown verb or state
+# value, or an empty note simply skip that line, and the survivors are still
+# applied. Returns the number of valid directives.
+function parse_directives(body, nb, n, i, line, cid, rest, v, txt, h)
+{
+ nb = 0
+ n = split(body, BL, "\n")
+ for (i = 1; i <= n; i++) {
+ line = BL[i]
+ sub(/\r$/, "", line)
+ sub(/^[ \t]+/, "", line)
+
+ # the commit id is stored verbatim as sent by the client
+ # (whatever length the page carries), only lowercased. The
+ # length bound is the only enforcement.
+ if (!match(line, /^[0-9a-fA-F]+[ \t]/))
+ continue
+ cid = tolower(substr(line, 1, RLENGTH - 1))
+ if (length(cid) > MAX_CID_LEN)
+ continue
+ rest = substr(line, RLENGTH + 1)
+ sub(/^[ \t]+/, "", rest)
+
+ if (match(rest, /^state[ \t]+/)) {
+ v = substr(rest, RLENGTH + 1)
+ sub(/[ \t]+$/, "", v)
+ if (v ~ /^[nuwy]$/) {
+ nb++
+ d_type[nb] = "state"; d_cid[nb] = cid; d_state[nb] = v
+ }
+ else if (v ~ /^(revert|same|unchanged)$/) {
+ nb++
+ d_type[nb] = "revert"; d_cid[nb] = cid
+ }
+ # else: unknown value or trailing junk, drop
+ }
+ else if (match(rest, /^notes[ \t]/)) {
+ txt = sanitize_note(substr(rest, RLENGTH + 1), MAX_NOTE_LEN)
+ if (txt == "")
+ continue
+ nb++
+ d_type[nb] = "notes"; d_cid[nb] = cid; d_note[nb] = txt
+ }
+ }
+ return nb
+}
+
+# loads the branch file into the L_* line arrays; a missing file is an empty
+# one (first write will create it). Lines are kept verbatim; only the
+# leading commit id is parsed here, for matching.
+function load_file(fname, line)
+{
+ nb_lines = 0
+ while ((getline line < fname) > 0) {
+ nb_lines++
+ L_raw[nb_lines] = line
+ L_cid[nb_lines] = line_cid(line)
+ L_touched[nb_lines] = 0
+ }
+ close(fname)
+}
+
+# Applies directive <di>: the target line is looked up by prefix-match,
+# first match wins, scanning the file lines then the new entries; a miss
+# creates a new entry (except for a revert, which then has nothing to
+# remove). A line reduced to neither state nor notes is dropped at write
+# time.
+function apply_directive(di, i, li, ni)
+{
+ li = 0; ni = 0
+ for (i = 1; i <= nb_lines; i++) {
+ if (L_cid[i] == "" || !cid_match(L_cid[i], d_cid[di]))
+ continue
+ li = i
+ if (!L_touched[i]) {
+ parse_line(L_raw[i])
+ L_state[i] = P_state
+ L_notes[i] = P_notes
+ L_has[i] = P_has_notes
+ L_touched[i] = 1
+ }
+ break
+ }
+ if (!li) {
+ for (i = 1; i <= nb_new; i++) {
+ if (cid_match(N_cid[i], d_cid[di])) {
+ ni = i
+ break
+ }
+ }
+ }
+
+
+ if (!li && !ni) {
+ if (d_type[di] == "revert")
+ return # nothing stored for this commit anyway
+ nb_new++
+ N_cid[nb_new] = d_cid[di]
+ N_state[nb_new] = ""; N_notes[nb_new] = ""; N_has[nb_new] = 0
+ ni = nb_new
+ }
+
+ if (d_type[di] == "state") {
+ if (li) L_state[li] = d_state[di]; else N_state[ni] = d_state[di]
+ }
+ else if (d_type[di] == "revert") {
+ if (li) L_state[li] = ""; else N_state[ni] = ""
+ }
+ else if (d_type[di] == "notes") {
+ if (li) {
+ L_notes[li] = L_has[li] ? L_notes[li] NOTE_SEP d_note[di] : d_note[di]
+ L_has[li] = 1
+ }
+ else {
+ N_notes[ni] = N_has[ni] ? N_notes[ni] NOTE_SEP d_note[di] : d_note[di]
+ N_has[ni] = 1
+ }
+ }
+}
+
+# Serialises all writers around the branch files. The lock is a directory
+# (mkdir is atomic) at an obvious fixed place, <repo>/lock, which also hosts
+# the temp file so that the final rename stays on one filesystem. The
+# holder's PID is stored inside; a dead holder is the real staleness signal
+# (a live but slow one, e.g. during git gc, must never be evicted
+# mid-commit), with a loose age backstop only for when no PID can be read.
+#
+# NOTE: PID-based takeover is a valid liveness signal only because all
+# writers are local children of the same host (thttpd CGI processes). If
+# another writer path is ever added (cron job, over-SSH update, push step
+# touching this repo), the liveness check stops meaning "the holder is
+# alive" and this takeover silently stops protecting the file.
+#
+# Returns 0 on success, -1 when the lock could not be obtained (busy).
+function lock_acquire( i, pid, stale, mt, priv, cmd, p2)
+{
+ for (i = 0; i < LOCK_RETRIES; i++) {
+ if (system("mkdir " q(lock_path) " 2>/dev/null") == 0) {
+ # The pid is written via the shell: a takeover based on
+ # a stale decision may steal this fresh lock before the
+ # pid lands, and a plain print into the vanished dir
+ # would be a fatal awk error killing us without even a
+ # response, while a command failure is just a lost
+ # acquisition to retry.
+ if (system("echo " PROCINFO["pid"] " > " q(lock_pid) " 2>/dev/null") != 0)
+ continue
+ lock_held = 1
+ return 0
+ }
+
+ # The lock is held: check whether the holder is still alive.
+ # All writers run under the same UID, so failing to signal it,
+ # even with EPERM, means it is dead and its pid was recycled
+ # by a foreign process; a wrongful eviction would anyway be
+ # absorbed by the takeover verification and the victim's
+ # retry. The pid file must be closed even when
+ # the read fails (e.g. caught empty before the holder flushed
+ # it): gawk keeps input files open and cached by path, and a
+ # cached descriptor would keep returning the content of a
+ # previous lock's deleted pid file, making a live holder look
+ # dead and letting its lock be stolen in the middle of a write.
+ pid = ""; stale = 0
+ getline pid < lock_pid
+ close(lock_pid)
+ if (pid "" == PROCINFO["pid"] "")
+ stale = 1 # our own lock, orphaned by a foiled takeover
+ else if (pid ~ /^[0-9]+$/ && pid + 0 > 0)
+ stale = (system("kill -0 " pid " 2>/dev/null") != 0)
+ else {
+ cmd = "stat -c %Y " q(lock_path) " 2>/dev/null"
+ mt = ""
+ cmd | getline mt
+ close(cmd)
+ stale = (mt ~ /^[0-9]+$/ && systime() - mt > LOCK_STALE_AGE)
+ }
+
+ if (stale) {
+ # Atomic takeover: rename() has exactly one winner
+ # (mv -T refuses an existing target), which owns the
+ # recovery and discards the stale dir under its
+ # private name; a loser re-enters acquisition. Never
+ # rmdir-then-mkdir, that would race two adopters.
+ #
+ # The rename is atomic but the staleness decision was
+ # not: between reading the dead holder's pid and the
+ # rename, the lock may have been released and
+ # re-acquired by a live writer, in which case we just
+ # stole a live lock. So verify: only discard the
+ # stolen dir if it still carries the pid we judged
+ # dead, otherwise put it back untouched (its temp file
+ # is still inside, the victim never notices anything).
+ priv = repo "/lock.stale." PROCINFO["pid"] "." i
+ if (system("mv -T " q(lock_path) " " q(priv) " 2>/dev/null") == 0) {
+ p2 = ""
+ getline p2 < (priv "/pid")
+ close(priv "/pid")
+ if (p2 "" == pid "")
+ system("rm -f " q(priv "/pid") " " q(priv "/tmp") \
+ "; rmdir " q(priv) " 2>/dev/null")
+ else
+ # the give-back may fail if the path was
+ # re-created in between; the private dir is
+ # then left over for the admin, it cannot
+ # be restored safely
+ system("mv -T " q(priv) " " q(lock_path) " 2>/dev/null")
+ }
+ continue
+ }
+ system("sleep " LOCK_SLEEP)
+ }
+ return -1
+}
+
+# Releases the lock, but only after checking that it is still ours: after a
+# takeover interleaving gone wrong, the path may carry someone else's live
+# lock, which must not be dismantled; ours is then a private stale dir that
+# the next writers will reclaim. Also called from die().
+function lock_release( p)
+{
+ p = ""
+ getline p < lock_pid
+ close(lock_pid)
+ if (p "" == PROCINFO["pid"] "")
+ system("rm -f " q(lock_pid) " " q(lock_tmp) "; rmdir " q(lock_path) " 2>/dev/null")
+ lock_held = 0
+}
+
+# the POST handler: parse directives, and if any survives, apply them to the
+# branch file under the lock, atomically replace it and commit it to git.
+function handle_post( body, i, fname, attempt, renamed)
+{
+ body = read_body()
+ nb_dirs = parse_directives(body)
+ if (nb_dirs == 0) {
+ # nothing valid remains: complete no-op, no lock taken
+ printf "Content-Type: text/plain\r\n\r\nOK 0 directives applied\n"
+ return
+ }
+
+ # The whole locked cycle may have to be redone: if our lock (and the
+ # temp file inside it) is stolen by a takeover which mistook us for
+ # dead, the final rename fails while nothing was applied to the file
+ # yet, so it is always safe to start over from a fresh read.
+ fname = repo "/" branch
+ renamed = 0
+ for (attempt = 0; attempt < 5 && !renamed; attempt++) {
+ if (lock_acquire() < 0)
+ die("503 Service Unavailable", "busy, retry")
+
+ # Open the temp file right away: from here on its descriptor
+ # keeps working even if the lock gets stolen (the final rename
+ # will fail cleanly and be retried). Opening it later would
+ # widen the window where a theft makes the redirection open
+ # fail, which is a fatal error in awk.
+ printf "" > lock_tmp
+
+ # the file may contain admin hand-edits, possibly not even committed
+ # yet: read it as it is, preserve every line we're not touching, and
+ # commit whatever ends up in the tree.
+ delete L_raw; delete L_cid; delete L_touched
+ delete L_state; delete L_notes; delete L_has
+ delete N_cid; delete N_state; delete N_notes; delete N_has
+ load_file(fname)
+
+ nb_new = 0
+ for (i = 1; i <= nb_dirs; i++)
+ apply_directive(i)
+
+ # Complete the temp file and atomically rename() it over the
+ # branch file, so that the latter is always a complete valid
+ # file, even across a crash.
+ for (i = 1; i <= nb_lines; i++) {
+ if (!L_touched[i])
+ print L_raw[i] > lock_tmp
+ else if (L_state[i] != "" || L_has[i])
+ print fmt_entry(L_cid[i], L_state[i], L_notes[i], L_has[i]) > lock_tmp
+ # else: line reduced to nothing, dropped
+ }
+ for (i = 1; i <= nb_new; i++) {
+ if (N_state[i] != "" || N_has[i])
+ print fmt_entry(N_cid[i], N_state[i], N_notes[i], N_has[i]) > lock_tmp
+ }
+ close(lock_tmp)
+ system("sync -d " q(lock_tmp) " 2>/dev/null")
+ if (system("mv -T " q(lock_tmp) " " q(fname) " 2>/dev/null") != 0) {
+ # our lock was stolen and the temp file went with it:
+ # what now sits at the lock path belongs to someone
+ # else, leave it alone and redo the whole cycle from a
+ # fresh read
+ lock_held = 0
+ renamed = 0
+ continue
+ }
+ renamed = 1
+
+ # Commit failures (e.g. missing committer identity) are logged but
+ # not fatal: the tree stays valid-but-uncommitted and the next writer
+ # folds it into its own commit. Never checkout/reset here, it would
+ # eat an admin's uncommitted hand-edit. Git's output is redirected to
+ # stderr so that it cannot corrupt the CGI response.
+ if (system("git -C " q(repo) " add -- " q(branch) " 1>&2") != 0 || \
+ system("git -C " q(repo) " commit -q -m " q("update " branch) " 1>&2") != 0)
+ print "update.awk: git commit failed in " repo " (will retry on next write)" > "/dev/stderr"
+
+ lock_release()
+ }
+ if (!renamed)
+ die("500 Internal Server Error", "cannot replace branch file")
+
+ # echo the resulting line(s) after the status, mostly for debugging
+ printf "Content-Type: text/plain\r\n\r\n"
+ printf "OK %d directive%s applied\n", nb_dirs, nb_dirs == 1 ? "" : "s"
+ for (i = 1; i <= nb_lines; i++) {
+ if (!L_touched[i])
+ continue
+ if (L_state[i] != "" || L_has[i])
+ print fmt_entry(L_cid[i], L_state[i], L_notes[i], L_has[i])
+ else
+ print L_cid[i] " removed"
+ }
+ for (i = 1; i <= nb_new; i++) {
+ if (N_state[i] != "" || N_has[i])
+ print fmt_entry(N_cid[i], N_state[i], N_notes[i], N_has[i])
+ }
+}