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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"text/tabwriter"
)

// runProject dispatches the `project` subcommand's own sub-subcommands: add,
// set-branch, list. add/set-branch are the only way to declare a project or
// change its branch — see the package comment on projectNameRE for why that
// matters.
//
// A project's description is deliberately NOT among these: it is declared by
// the tree itself, in the root .source-forge (see meta.go), and applied on
// every ingest. There was once a `set-description` here, which set it by hand
// on the server; that made the published description invisible from the
// repository and impossible to review, and left the two mechanisms racing —
// whichever ran last won. The file is now the single source of truth.
func runProject(args []string) error {
	if len(args) < 1 {
		return errors.New("usage: source-forge project {add|set-branch|list} ...")
	}
	cmd := args[0]
	rest := args[1:]
	switch cmd {
	case "add":
		return runProjectAdd(rest)
	case "set-branch":
		return runProjectSetBranch(rest)
	case "list":
		return runProjectList(rest)
	case "set-description":
		return errors.New("project set-description has been removed: set `description = <html>' " +
			"in the repository's root .source-forge instead, and push (see source-forge(1), FILES)")
	default:
		return fmt.Errorf("project: unknown subcommand %q (want add, set-branch, or list)", cmd)
	}
}

// projectNameRE constrains project names to what is safe to use as both a
// SQLite key and a bare-repo directory basename (<name>.git) directly under
// the state directory: the post-receive hook derives the project name back
// out of the repo path (see hookProjectName in hook.go), so the name must
// round-trip through a filesystem path with no ambiguity — no slashes, no
// leading dot, no "..".
var projectNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

func validateProjectName(name string) error {
	if name == "" {
		return errors.New("project name must not be empty")
	}
	if !projectNameRE.MatchString(name) {
		return fmt.Errorf("project name %q: must start with a letter or digit and "+
			"contain only letters, digits, '.', '_', '-'", name)
	}
	if name == "." || name == ".." || strings.Contains(name, "..") {
		return fmt.Errorf("project name %q: not allowed", name)
	}
	return nil
}

// projectURL builds the public URL of a project's root page, for the
// human-facing hints printed by `project add` and by the post-receive hook
// (which is where a pusher actually sees it). Returns "" when no base URL is
// configured, so callers can simply omit the hint rather than print a guess.
//
// No escaping is needed: validateProjectName restricts names to characters
// that are already safe in a URL path segment.
func projectURL(baseURL, project string) string {
	baseURL = strings.TrimRight(baseURL, "/")
	if baseURL == "" {
		return ""
	}
	return baseURL + "/" + project + "/"
}

// runProjectAdd declares a new project: it creates the bare repo at
// <repos>/<project>.git (failing if one already exists), points its HEAD at
// the published branch, and inserts the project row at generation 0 — a
// project with no content yet, but already declared, so the post-receive
// hook (which refuses to ingest into an undeclared repo, see hook.go) accepts
// the first push. See serveProjectIndex/serveDir in serve.go for how
// generation 0 is displayed as "pending".
func runProjectAdd(args []string) error {
	fs := flag.NewFlagSet("project add", flag.ContinueOnError)
	dbPath := fs.String("db", "", "path to the SQLite database (required)")
	repos := fs.String("repos", "", "state directory holding the bare repos (required)")
	project := fs.String("project", "", "project name (required)")
	branch := fs.String("branch", "canon", "branch that will be published on push")
	baseURL := fs.String("base-url", "", "public base URL of the site (to print where the project will appear; optional)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *dbPath == "" || *repos == "" || *project == "" {
		return errors.New("--db, --repos and --project are required")
	}
	if err := validateProjectName(*project); err != nil {
		return err
	}
	if *branch == "" {
		return errors.New("--branch must not be empty")
	}

	repoPath := filepath.Join(*repos, *project+".git")
	if _, err := os.Stat(repoPath); err == nil {
		return fmt.Errorf("%s already exists", repoPath)
	} else if !os.IsNotExist(err) {
		return fmt.Errorf("stat %s: %w", repoPath, err)
	}

	db, err := openDB(*dbPath)
	if err != nil {
		return err
	}
	defer db.Close()

	// Fail before touching the filesystem if the project is already declared,
	// so a mistyped --db pointing at the wrong database cannot leave an
	// orphaned repo with no row (the hook would then reject every push to it).
	var exists int
	if err := db.QueryRow(`SELECT COUNT(*) FROM project WHERE name = ?`, *project).Scan(&exists); err != nil {
		return fmt.Errorf("check existing project: %w", err)
	}
	if exists > 0 {
		return fmt.Errorf("project %q is already declared in the database", *project)
	}

	if err := runGit("", "init", "--bare", repoPath); err != nil {
		return err
	}
	if err := runGit(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+*branch); err != nil {
		return err
	}

	if _, err := db.Exec(
		`INSERT INTO project (name, branch, head_generation) VALUES (?, ?, 0)`,
		*project, *branch,
	); err != nil {
		return fmt.Errorf("insert project row: %w", err)
	}

	fmt.Fprintf(os.Stderr, "source-forge: declared project %q (branch %q) at %s\n",
		*project, *branch, repoPath)
	fmt.Fprintf(os.Stderr, "source-forge: push to it to publish, e.g.:\n"+
		"  git push <remote-for-%s> %s\n", repoPath, *branch)
	if url := projectURL(*baseURL, *project); url != "" {
		fmt.Fprintf(os.Stderr, "source-forge: it will then appear at %s\n", url)
	}
	return nil
}

// runProjectSetBranch changes which branch a declared project publishes. It
// only updates the database row; the next push to that branch re-ingests as
// usual. It also re-points the repo's HEAD, since the hook re-asserts it on
// every ingest but a never-pushed branch switch should still leave HEAD sane
// for `git clone`/`git bundle` in the meantime.
func runProjectSetBranch(args []string) error {
	fs := flag.NewFlagSet("project set-branch", flag.ContinueOnError)
	dbPath := fs.String("db", "", "path to the SQLite database (required)")
	repos := fs.String("repos", "", "state directory holding the bare repos (optional; re-points HEAD if given)")
	project := fs.String("project", "", "project name (required)")
	branch := fs.String("branch", "", "new branch to publish (required)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *dbPath == "" || *project == "" || *branch == "" {
		return errors.New("--db, --project and --branch are required")
	}

	db, err := openDB(*dbPath)
	if err != nil {
		return err
	}
	defer db.Close()

	res, err := db.Exec(`UPDATE project SET branch = ? WHERE name = ?`, *branch, *project)
	if err != nil {
		return fmt.Errorf("update branch: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("update branch: %w", err)
	}
	if n == 0 {
		return fmt.Errorf("project %q is not declared", *project)
	}

	if *repos != "" {
		repoPath := filepath.Join(*repos, *project+".git")
		if err := runGit(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+*branch); err != nil {
			return err
		}
	}

	fmt.Fprintf(os.Stderr, "source-forge: project %q now publishes branch %q\n", *project, *branch)
	return nil
}

// runProjectList prints every declared project, its published branch, and
// whether it has ever been ingested (generation 0 = pending, see serve.go).
func runProjectList(args []string) error {
	fs := flag.NewFlagSet("project list", flag.ContinueOnError)
	dbPath := fs.String("db", "", "path to the SQLite database (required)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *dbPath == "" {
		return errors.New("--db is required")
	}

	db, err := openDB(*dbPath)
	if err != nil {
		return err
	}
	defer db.Close()

	rows, err := db.Query(`SELECT name, branch, head_generation, description FROM project ORDER BY name`)
	if err != nil {
		return fmt.Errorf("query projects: %w", err)
	}
	defer rows.Close()

	tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
	fmt.Fprintln(tw, "PROJECT\tBRANCH\tSTATUS\tDESCRIPTION")
	var n int
	for rows.Next() {
		var name, branch, description string
		var gen int64
		if err := rows.Scan(&name, &branch, &gen, &description); err != nil {
			return fmt.Errorf("scan project row: %w", err)
		}
		status := fmt.Sprintf("generation %d", gen)
		if gen == 0 {
			status = "pending (never pushed)"
		}
		descStatus := "none"
		if strings.TrimSpace(description) != "" {
			descStatus = fmt.Sprintf("%d bytes", len(description))
		}
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", name, branch, status, descStatus)
		n++
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate projects: %w", err)
	}
	tw.Flush()
	if n == 0 {
		fmt.Println("(no projects declared)")
	}
	return nil
}

// runGit runs a git command, optionally with --git-dir=dir (dir == "" omits
// it, for `git init` which takes the target as a positional argument
// instead), surfacing stderr on failure.
func runGit(dir string, args ...string) error {
	var full []string
	if dir != "" {
		full = append(full, "--git-dir="+dir)
	}
	full = append(full, args...)
	cmd := exec.Command("git", full...)
	var errBuf strings.Builder
	cmd.Stderr = &errBuf
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("git %s: %w: %s", strings.Join(full, " "), err, errBuf.String())
	}
	return nil
}