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.

Land a change request on the server instead of by pushing

38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 13d ago · parent 536f08b9

Land a change request on the server instead of by pushing

A change request could only be landed by merging locally and pushing the
base branch: the web page was a review surface with no way to finish, and
nothing recorded that the request had landed. `state: merged` was already
accepted by the request reader and rendered by the activity feed, but
nothing ever wrote it, so a merged request and an abandoned one looked
identical.

The gate that decides a landing lives in the pre-receive hook, and the
hook only sees pushes. A merge performed inside the server moves the ref
without the hook, so a server-side merge either re-runs that rule or
becomes the easiest way to land an unapproved change. It now re-runs it:
the approval and check rule moved to forge/landing, and both the hook and
the new landing operation call the same Check.

Landing is fast-forward when the base is an ancestor of the reviewed head,
so the commit that passed CI is the commit that lands; otherwise a
two-parent merge commit, which is the only other shape the gate accepts.
Conflicts are refused with the conflicting paths, never resolved. The ref
moves by compare-and-swap, so a concurrent push loses cleanly and the
landing retries; the request is marked merged after the ref moves, which
makes a retry after a crash finish the marking instead of failing.

The request now records merged_head, merged_by and merged_at, and the
reader refuses a merged request that names no commit, and refuses those
fields on a request that is not merged. "merged" is no longer settable
through the generic state writer: it has to travel with the commit.

The browser gets a Merge button that preflights the same gate, so it only
appears when the landing would succeed, and the page shows what landed,
who landed it, and when. `vole change merge` follows in its own change:
the CLI has no access to the ACL or the run queue, so it needs a verb on
the SSH channel, and that transport is worth reviewing on its own.

Closes the landing half of #01M5XF4XWHVMMCQEYENA8X32GK.

core/git/merge.go +14−3

@@ -28,22 +28,33 @@ var treeIDRE = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`)
28 28 // output: a real merge prints the resulting tree's id first, and a refusal prints
29 29 // a message instead.
30 30 func (r *Repo) MergeConflicts(base, head string) ([]string, error) {
31 _, conflicts, err := r.MergeTree(base, head)
32 return conflicts, err
33 }
34
35 // MergeTree performs the same in-memory merge and also returns the id of the tree
36 // it built, which is what a real merge commit needs. Asking for it is how a
37 // landing operation merges without a worktree: the tree is in the object store,
38 // so a commit over it costs nothing even when the merge is thrown away because the
39 // ref moved in the meantime.
40 func (r *Repo) MergeTree(base, head string) (string, []string, error) {
31 41 out, errs, err := r.run(nil, "merge-tree", "--write-tree", "--name-only", "--no-messages", base, head)
32 42 text := string(out)
33 43 if err != nil {
34 44 var ee *exec.ExitError
35 45 if !errors.As(err, &ee) || ee.ExitCode() != 1 {
36 return nil, fmt.Errorf("merge-tree %s %s: %w: %s", base, head, err, errs)
46 return "", nil, fmt.Errorf("merge-tree %s %s: %w: %s", base, head, err, errs)
37 47 }
38 48 if !treeIDAhead(text) {
39 49 reason := strings.TrimSpace(text)
40 50 if reason == "" {
41 51 reason = errs
42 52 }
43 return nil, fmt.Errorf("cannot merge %s into %s: %s", head, base, reason)
53 return "", nil, fmt.Errorf("cannot merge %s into %s: %s", head, base, reason)
44 54 }
45 55 }
46 return parseMergeTree(text), nil
56 tree, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
57 return strings.TrimSpace(tree), parseMergeTree(text), nil
47 58 }
48 59
49 60 // treeIDAhead reports whether the output starts with a tree id rather than a