// Command dolt-git-hook is git.sr.ht's post-update-script, replacing the stock
// /usr/bin/git.sr.ht-update-hook in [git.sr.ht]post-update-script. git.sr.ht
// symlinks the configured script as all four repo hooks (pre-receive, update,
// post-update, post-receive); this binary is that script.
//
// It does two things:
//
// 1. Delegates every invocation to the stock hook, unchanged — same argv[0]
// (so the stock binary's os.Args[0] dispatch still fires), same stdin, env,
// working directory and exit code. Build submission, webhook delivery, ACL
// enforcement and the stock autocreate notice all keep working.
//
// 2. On the post-update stage only, provisions a companion Dolt database at
// ~owner/name via dolt.sr.ht's internal create endpoint, so a matching Dolt
// DB exists before the user's first `dolt push`, and prints a one-time
// terminal notice when it is first created. This step is strictly
// best-effort: it never changes the delegate's exit code and never fails a
// push (post-update runs after refs are already updated).
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)
// defaultDelegate is the stock git.sr.ht hook this wrapper wraps. The apk still
// installs it here; only the [git.sr.ht]post-update-script symlink target moves
// to this binary. Overridable via env for testing.
const defaultDelegate = "/usr/bin/git.sr.ht-update-hook"
// provisionTimeout bounds the internal create call so a slow or down dolt.sr.ht
// never adds more than this to a push.
const provisionTimeout = 5 * time.Second
func main() {
code := runDelegate()
// filepath.Base("hooks/post-update") == "post-update".
if filepath.Base(os.Args[0]) == "post-update" {
provisionDolt()
}
os.Exit(code)
}
// runDelegate execs the stock hook with this process's argv, stdio, env and
// working directory, and returns its exit code. If the delegate cannot be
// started at all (e.g. missing binary) it fails closed with a non-zero code:
// the stock hook is where ACL enforcement lives, so a push must not proceed
// without it.
func runDelegate() int {
delegate := os.Getenv("DOLT_GIT_HOOK_DELEGATE")
if delegate == "" {
delegate = defaultDelegate
}
cmd := exec.Command(delegate)
cmd.Args = os.Args // preserve argv[0] = "hooks/<stage>" for the stock dispatch
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
err := cmd.Run()
if err == nil {
return 0
}
var ee *exec.ExitError
if errors.As(err, &ee) {
return ee.ExitCode()
}
fmt.Fprintf(os.Stderr, "dolt-git-hook: cannot run %s: %v\n", delegate, err)
return 1
}
// pushContext is the subset of git.sr.ht's SRHT_PUSH_CTX we need: the repo's
// owner, name and visibility. git.sr.ht-shell sets this env var before exec'ing
// git-receive-pack, so it is inherited by every hook.
type pushContext struct {
Repo struct {
Name string `json:"name"`
OwnerName string `json:"owner_name"`
Visibility string `json:"visibility"`
} `json:"repo"`
}
// internalAuth mirrors core-go's client.InternalAuth wire shape. crypto.Encrypt
// seals it with the shared [sr.ht]network-key; dolt.sr.ht's internalAuthGuard
// decrypts and trusts it.
type internalAuth struct {
Name string `json:"name,omitempty"`
ClientID string `json:"client_id"`
NodeID string `json:"node_id"`
}
// provisionDolt asks dolt.sr.ht to create the companion database for the pushed
// repo. Every failure mode is swallowed (logged to stderr at most): this is a
// convenience, not part of the push contract.
func provisionDolt() {
// A bug here must never escape into the push; recover defensively (config
// loading log.Fatalf's are handled by only running post-update, where the
// exit code is already irrelevant to push success).
defer func() { _ = recover() }()
raw := os.Getenv("SRHT_PUSH_CTX")
if raw == "" {
return // push not routed through git.sr.ht-shell; nothing to do
}
var pc pushContext
if err := json.Unmarshal([]byte(raw), &pc); err != nil {
return
}
if pc.Repo.Name == "" || pc.Repo.OwnerName == "" {
return
}
conf := config.LoadConfig()
// crypto.InitCrypto log.Fatalf's (os.Exit) on a missing key, which recover
// cannot catch — check the keys ourselves first so a misconfigured instance
// degrades to a skipped companion, never a hard-exiting hook.
if _, ok := conf.Get("sr.ht", "network-key"); !ok {
fmt.Fprintln(os.Stderr, "dolt-git-hook: [sr.ht]network-key not set; skipping companion provisioning")
return
}
if _, ok := conf.Get("webhooks", "private-key"); !ok {
fmt.Fprintln(os.Stderr, "dolt-git-hook: [webhooks]private-key not set; skipping companion provisioning")
return
}
origin, ok := conf.Get("dolt.sr.ht", "internal-origin")
if !ok || origin == "" {
fmt.Fprintln(os.Stderr, "dolt-git-hook: [dolt.sr.ht]internal-origin not set; skipping companion provisioning")
return
}
crypto.InitCrypto(conf)
createCompanion(os.Stderr, origin, pc)
}
// createCompanion POSTs the internal create request for pc to origin, signing it
// with the shared network-key (crypto must already be initialized), and reports
// progress to out: the one-time notice on a fresh 201, silence on an existing
// 200, a warning on anything else. It is separated from provisionDolt so it can
// be integration-tested against an httptest server without config-file loading.
func createCompanion(out io.Writer, origin string, pc pushContext) {
reqBody := map[string]string{
"owner": pc.Repo.OwnerName,
"name": pc.Repo.Name,
}
if v := normalizeVisibility(pc.Repo.Visibility); v != "" {
reqBody["visibility"] = v
}
body, _ := json.Marshal(reqBody)
authBlob, _ := json.Marshal(internalAuth{
Name: pc.Repo.OwnerName,
ClientID: "git.sr.ht",
NodeID: "dolt-git-hook",
})
req, err := http.NewRequest("POST",
strings.TrimRight(origin, "/")+"/internal/repos", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Internal "+string(crypto.Encrypt(authBlob)))
client := &http.Client{Timeout: provisionTimeout}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(out, "dolt-git-hook: dolt.sr.ht unreachable: %v\n", err)
return
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated:
var r struct {
URL string `json:"url"`
}
_ = json.NewDecoder(resp.Body).Decode(&r)
printNotice(out, pc.Repo.OwnerName, pc.Repo.Name, r.URL)
case http.StatusOK:
// Companion already existed; stay quiet so only the first push announces.
default:
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
fmt.Fprintf(out, "dolt-git-hook: companion provisioning failed (%d): %s\n",
resp.StatusCode, strings.TrimSpace(string(msg)))
}
}
// normalizeVisibility maps a git.sr.ht visibility to the dolt.sr.ht enum,
// returning "" (let the endpoint default to PRIVATE) for anything unrecognized.
func normalizeVisibility(v string) string {
switch strings.ToUpper(strings.TrimSpace(v)) {
case "PUBLIC":
return "PUBLIC"
case "UNLISTED":
return "UNLISTED"
case "PRIVATE":
return "PRIVATE"
default:
return ""
}
}
// printNotice writes the one-time companion-created notice to out (os.Stderr in
// production), which git relays to the pushing client's terminal — the same
// stream and stage git.sr.ht uses for its own autocreate notice.
func printNotice(out io.Writer, owner, name, url string) {
if url == "" {
url = fmt.Sprintf("(~%s/%s)", owner, name)
}
fmt.Fprintf(out, "\n\t\033[93mNOTICE\033[0m\n"+
"\tA Dolt database companion has been created for ~%s/%s:\n\n"+
"\t dolt clone %s\n"+
"\t web: %s\n\n",
owner, name, url, url)
}