// 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/" 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) }