Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 3: change requests, with approvals bound to the commit they reviewed

1eeca4e67a65d71217d51b1c41632330225b351a · Keenan Nemetz · 17d ago · parent 92b37587

Phase 3: change requests, with approvals bound to the commit they reviewed

Review is now a git artifact. A request names a base branch and one head commit;
an approval names that same commit, so a branch that moves after approval is not
approved any more. Because `git push` is the merge, the gate lives in pre-receive
and there is no authenticated write surface to build.

  requests/<ulid>/meta.json          title, author, base, head, state
  requests/<ulid>/description.md
  requests/<ulid>/approvals/<user>.json   base + head + created

Two push shapes are accepted for a protected branch: the new tip is the approved
head, or the new tip is a merge whose parents are the branch tip and the approved
head. "The approved head is an ancestor of the new tip" is refused on purpose —
accepting it would let anyone append unapproved commits and land them together
with an approved one. Admins keep their bypass; `vole acl approvals <repo> <n>`
sets the count, and 0 restores admin-only.

Three integrity holes found while building it, each now covered by a test:

- An approval file names a principal, but nothing tied that name to the pusher, so
  any writer could commit approvals/<someone-else>.json. The hook now requires a
  newly added approval to be named after the SSH identity. That check initially
  skipped the push that *creates* the branch, where there is no old commit to diff
  against — which let whoever created the branch seed any approval they liked.
  Fixed to inspect the whole tree on a first push.
- Approvals were bound to the head but not the branch, so rewriting a request's
  base repointed an approval at somewhere the approver never looked.
- Self-approval was refused at the CLI but not in validation, so a hand-written
  tree could carry one.

Also relaxes the CLI to accept options after arguments, as git does. Refusing
`vole request approve <id> --as keenan` is a local dialect, not git's behaviour,
and `git commit -m msg path` is the shape people already type. Exit 129, no
bundling and `--`/`--end-of-options` are unchanged.

Verified end to end on a real instance with two keys: a writer's direct push to a
protected main is refused with a message naming the exact commit; the same push
still fails with a request open but unapproved; self-approval fails; an admin
approval lets it through; main advances; the web shows the approval as current and
the request as already merged; the audit log records the merge with request id and
approver.

Known rough edge: a merged request stays open until someone closes it. Marking it
merged automatically needs the server to write metadata during a push, which the
design deliberately does not do. Phase 4 CI is the natural place to close it.

internal/gitx/gitx.go +67

@@ -338,6 +338,73 @@ func (r *Repo) Log(rev string, n int) ([]Commit, error) {
338 338 return parseCommits(out), nil
339 339 }
340 340
341 // RevParseFull resolves a revision to a full commit id. It refuses ambiguous
342 // short ids rather than picking one.
343 func (r *Repo) RevParseFull(rev string) (string, error) {
344 out, errs, err := r.run(nil, "rev-parse", "--verify", "--quiet", "--end-of-options", rev+"^{commit}")
345 if err != nil {
346 return "", fmt.Errorf("rev-parse %s: %w: %s", rev, err, errs)
347 }
348 sha := strings.TrimSpace(string(out))
349 if len(sha) != 40 {
350 return "", fmt.Errorf("%q is not a commit", rev)
351 }
352 return sha, nil
353 }
354
355 // CurrentBranch returns the checked-out branch name, or "" when detached.
356 func (r *Repo) CurrentBranch() (string, error) {
357 out, _, err := r.run(nil, "symbolic-ref", "--short", "-q", "HEAD")
358 if err != nil {
359 return "", nil // detached or unborn: nothing is checked out by name
360 }
361 return strings.TrimSpace(string(out)), nil
362 }
363
364 // DiffNameStatus lists the paths a branch changes relative to from, using the
365 // merge base so unrelated history on from does not show up as changes.
366 func (r *Repo) DiffNameStatus(from, to string) ([]string, error) {
367 out, errs, err := r.run(nil, "diff", "--name-status", from+"..."+to)
368 if err != nil {
369 return nil, fmt.Errorf("diff %s...%s: %w: %s", from, to, err, errs)
370 }
371 var lines []string
372 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
373 if strings.TrimSpace(line) != "" {
374 lines = append(lines, strings.Replace(line, "\t", " ", -1))
375 }
376 }
377 return lines, nil
378 }
379
380 // AddedPaths lists the paths added between two commits.
381 func (r *Repo) AddedPaths(old, new string) ([]string, error) {
382 out, errs, err := r.run(nil, "diff-tree", "-r", "--no-commit-id", "--diff-filter=A", "--name-only", old, new)
383 if err != nil {
384 return nil, fmt.Errorf("diff-tree %s..%s: %w: %s", old, new, err, errs)
385 }
386 var paths []string
387 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
388 if line != "" {
389 paths = append(paths, line)
390 }
391 }
392 return paths, nil
393 }
394
395 // CommitParents returns a commit's parents, in order.
396 func (r *Repo) CommitParents(sha string) ([]string, error) {
397 out, errs, err := r.run(nil, "rev-list", "--parents", "-n", "1", sha)
398 if err != nil {
399 return nil, fmt.Errorf("rev-list --parents %s: %w: %s", sha, err, errs)
400 }
401 f := strings.Fields(strings.TrimSpace(string(out)))
402 if len(f) == 0 {
403 return nil, fmt.Errorf("no such commit %s", sha)
404 }
405 return f[1:], nil
406 }
407
341 408 // IsAncestor reports whether ancestor is reachable from descendant. Used to tell
342 409 // a fast-forward push from a rewrite.
343 410 func (r *Repo) IsAncestor(ancestor, descendant string) (bool, error) {