1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
package main

import (
	"bufio"
	"database/sql"
	"errors"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

// runPostReceiveHook implements a git post-receive hook: it reads the pushed
// ref updates from stdin (one "<old> <new> <ref>" line each) and, for every
// update to the project's declared branch, re-ingests the branch tree into
// the SQLite database. Installed once, generically, via core.hooksPath in the
// source-forge user's ~/.gitconfig (see nixos-module.nix) rather than
// symlinked per repo — so it applies to every bare repo under the state
// directory, present or future, with no per-project Nix configuration.
//
// Because there is no per-repo Nix config, both the project name and the
// published branch are derived rather than passed as flags:
//   - the project name is the basename of the repo (<name>.git → <name>);
//   - the published branch is read from the project's row in the database,
//     which must have been created ahead of time by `project add` (see
//     project.go). A repo pushed to without a declared project is rejected —
//     see hookProject below — so a push can never silently publish the wrong
//     branch, or publish an entirely undeclared repo.
//
// Pushes to any other ref (including other branches of a declared project)
// are ignored. The heavy lifting (archive → ingest, bundle, generation flip)
// is exactly the same code path as the `ingest` subcommand, via ingestTar.
func runPostReceiveHook(args []string) error {
	fs := flag.NewFlagSet("git-post-receive-hook", flag.ContinueOnError)
	dbPath := fs.String("db", "", "path to the SQLite database (required)")
	// git sets GIT_DIR when it runs a hook, so default to it; fall back to the
	// current directory (a bare repo's own root) when unset. For push hooks
	// git guarantees the current directory IS the repo, so cwd is always a
	// correct fallback even when GIT_DIR is relative (see resolveGitDir).
	gitDir := fs.String("git-dir", defaultGitDir(), "path to the bare git repo (defaults to $GIT_DIR)")
	// Only used to print where the push landed (see below). Optional, because
	// the hook is perfectly functional without knowing the public URL — and
	// nothing else here needs the serve side's configuration.
	baseURL := fs.String("base-url", "", "public base URL of the site (to print the published URL; optional)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *dbPath == "" {
		return errors.New("--db is required")
	}
	if *gitDir == "" {
		return errors.New("--git-dir is required (or set $GIT_DIR, as git does for hooks)")
	}

	absGitDir, err := resolveGitDir(*gitDir)
	if err != nil {
		return err
	}
	project := hookProjectName(absGitDir)

	// A short-lived handle just for the declared-branch lookup: ingestTar
	// (called per matching ref below) opens its own handle, and holding two
	// pools open concurrently for the whole hook invocation would needlessly
	// compete for SQLite's single write lock during a potentially slow ingest.
	db, err := openDB(*dbPath)
	if err != nil {
		return err
	}
	branch, ok, err := declaredBranch(db, project)
	db.Close()
	if err != nil {
		return err
	}
	if !ok {
		return fmt.Errorf(
			"project %q is not declared; run:\n"+
				"  source-forge project add --db %s --repos %s --project %s --branch <branch>\n"+
				"before pushing to it",
			project, *dbPath, filepath.Dir(absGitDir), project)
	}

	wantRef := "refs/heads/" + branch

	scanner := bufio.NewScanner(os.Stdin)
	var ingested bool
	for scanner.Scan() {
		fields := strings.Fields(scanner.Text())
		if len(fields) != 3 {
			continue // not a "<old> <new> <ref>" line; ignore defensively
		}
		newSHA, ref := fields[1], fields[2]
		if ref != wantRef {
			continue // a push to some other branch/tag: not published
		}
		if strings.Trim(newSHA, "0") == "" {
			continue // a branch deletion: nothing to ingest
		}
		// Re-assert HEAD before every ingest: `project add` sets it once at
		// declaration time, but this makes the repo self-healing (e.g. after a
		// `set-branch`, or if HEAD was ever hand-edited) and guarantees
		// gitBundle's `HEAD` argument (ingest.go) always resolves.
		if err := runGit(absGitDir, "symbolic-ref", "HEAD", wantRef); err != nil {
			return err
		}
		if err := ingestBranch(*dbPath, project, branch, absGitDir); err != nil {
			return err
		}
		ingested = true
	}
	if err := scanner.Err(); err != nil {
		return fmt.Errorf("read ref updates: %w", err)
	}
	if !ingested {
		fmt.Fprintf(os.Stderr, "source-forge: no update to %s, nothing to ingest\n", wantRef)
		return nil
	}
	// Tell the pusher where the push landed. git relays a hook's stderr back
	// over the wire (prefixed with "remote: "), so this is the one moment the
	// person pushing is actually looking — hence a full, clickable URL rather
	// than just the project name. Skipped when --base-url was not given, since
	// a guessed URL would be worse than none.
	if url := projectURL(*baseURL, project); url != "" {
		fmt.Fprintf(os.Stderr, "source-forge: published at %s\n", url)
	}
	return nil
}

// resolveGitDir returns dir as an absolute path. git commonly sets $GIT_DIR to
// the relative "." for push hooks rather than an absolute path; that is
// resolved via the current directory, which git guarantees equals $GIT_DIR for
// push hooks (pre-receive, update, post-receive, post-update,
// push-to-checkout — see git help githooks), making os.Getwd() a safe
// fallback rather than a guess.
func resolveGitDir(dir string) (string, error) {
	abs, err := filepath.Abs(dir)
	if err != nil {
		return "", fmt.Errorf("resolve --git-dir %q: %w", dir, err)
	}
	return filepath.Clean(abs), nil
}

// hookProjectName derives the project name from a bare repo's absolute path:
// the basename with a trailing ".git" trimmed, matching how `project add`
// names the repo it creates (see project.go).
func hookProjectName(absGitDir string) string {
	return strings.TrimSuffix(filepath.Base(absGitDir), ".git")
}

// declaredBranch looks up the branch a project is declared to publish. ok is
// false when the project has no row at all (an undeclared repo, or a typo in
// the repo's directory name) — the caller rejects the push in that case.
func declaredBranch(db *sql.DB, project string) (branch string, ok bool, err error) {
	err = db.QueryRow(`SELECT branch FROM project WHERE name = ?`, project).Scan(&branch)
	if err == sql.ErrNoRows {
		return "", false, nil
	}
	if err != nil {
		return "", false, fmt.Errorf("look up declared branch for %q: %w", project, err)
	}
	return branch, true, nil
}

// ingestBranch archives branch out of the bare repo and feeds the tar stream
// straight into ingestTar, so no temporary file is needed. git archive writes
// to a pipe that ingestTar consumes concurrently.
func ingestBranch(dbPath, project, branch, gitDir string) error {
	cmd := exec.Command("git", "--git-dir="+gitDir, "archive", branch)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return fmt.Errorf("git archive pipe: %w", err)
	}
	cmd.Stderr = os.Stderr
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("start git archive (%s %s): %w", gitDir, branch, err)
	}

	ingestErr := ingestTar(stdout, ingestOptions{
		dbPath:  dbPath,
		project: project,
		branch:  branch,
		gitDir:  gitDir,
	})

	// Always Wait to reap the child and surface an archive failure, but let an
	// ingest error take precedence since it is the more specific cause.
	waitErr := cmd.Wait()
	if ingestErr != nil {
		return ingestErr
	}
	if waitErr != nil {
		return fmt.Errorf("git archive (%s %s): %w", gitDir, branch, waitErr)
	}
	return nil
}

// defaultGitDir returns $GIT_DIR (which git exports when invoking hooks), or ""
// when unset so the flag simply has no default.
func defaultGitDir() string { return os.Getenv("GIT_DIR") }