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) { | |