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.

Resolve a change request's head on the remote, not the clone

6755b7515c2e3e404e8325eabc0b1e0b74ab901f · Keenan Nemetz · 9d ago · parent 26c9be33

Resolve a change request's head on the remote, not the clone

`change new --head <branch>` resolved the branch in the local clone. A
clone that was behind recorded the old commit, and when that commit was
already in the base the request contained nothing — which is how #117
merged nothing, closed its issue, and left the work on a side branch.

- A branch's head is resolved with `git ls-remote` against the remote the
  metadata write pushes to, so the recorded commit is the forge's tip. If
  local and remote differ, the remote tip is recorded and stderr says so;
  a local-only branch is recorded with a note; `--no-push` may fall back
  to the local ref when the remote cannot be reached.
- A request whose head is already in the base (the same commit, or an
  ancestor) is refused before anything is written, for `change new` and
  `change update --head`. The base is resolved on the remote too: a
  feature worktree commits on its local base branch, so the local base can
  be the head itself and would call every request empty.
- The tag/commit path is unchanged: it still pins the request to that
  commit with no branch.

Tests cover a branch that moved after the clone, a local-only branch, a
head equal to the base, a head that is an ancestor of the base, and the
`change update --head` refusal. The head-resolution note is documented in
docs/change-requests.md.

client/head.go +127

@@ -0,0 +1,127 @@
1 package client
2
3 // Resolving the head of a change request.
4 //
5 // A request names the commit it reviews and, when that commit is a branch's tip,
6 // the branch it follows. Both must be what the forge sees, not what this clone
7 // last fetched: a clone that is behind would otherwise record a commit the
8 // branch has already moved past, and a request for a commit the base already has
9 // would review nothing at all.
10
11 import (
12 "bytes"
13 "fmt"
14 "os"
15 "os/exec"
16 "strings"
17 )
18
19 // Head is the commit a request follows and the branch, when it has one.
20 type Head struct {
21 SHA string
22 // Branch is the branch the request follows. It is empty when the head is a
23 // tag or a bare commit, which pins the request to that commit.
24 Branch string
25 // Stale reports that the branch exists locally and on the remote at
26 // different commits, and the remote tip is the one recorded.
27 Stale bool
28 // LocalOnly reports that the name is a branch with no remote counterpart, so
29 // the request points at the local commit until the branch is pushed.
30 LocalOnly bool
31 }
32
33 // ResolveHead turns a branch, tag or commit into the full id a request will
34 // follow. A branch is resolved on the remote the metadata write pushes to, so a
35 // stale clone cannot record an old tip. allowOffline lets a --no-push caller use
36 // the local ref when the remote cannot be reached; a caller that will push
37 // should not guess.
38 func ResolveHead(b *Branch, rev string, allowOffline bool) (Head, error) {
39 rev = strings.TrimSpace(rev)
40 if rev == "" {
41 rev = "HEAD"
42 }
43 name := strings.TrimPrefix(rev, "refs/heads/")
44 // "HEAD" passes check-ref-format as a branch name, which would send a detached
45 // HEAD through the branch path; it is a revision, not a branch.
46 if name != "" && name != "HEAD" && b.Repo.ValidBranch(name) {
47 local, _ := b.Repo.RevParse("refs/heads/" + name)
48 remote, ok, err := RemoteBranchTip(b, name)
49 switch {
50 case err != nil && !allowOffline:
51 return Head{}, fmt.Errorf("cannot check %s on %s: %w", name, b.Remote, err)
52 case err != nil:
53 if local != "" {
54 return Head{SHA: local, Branch: name, LocalOnly: true}, nil
55 }
56 // No local branch either: let the revision resolve below, or fail there.
57 case ok:
58 return Head{SHA: remote, Branch: name, Stale: local != "" && local != remote}, nil
59 case local != "":
60 return Head{SHA: local, Branch: name, LocalOnly: true}, nil
61 }
62 }
63 out, err := b.Repo.RevParseFull(rev)
64 if err != nil || out == "" {
65 return Head{}, fmt.Errorf("%q does not name a commit", rev)
66 }
67 return Head{SHA: out}, nil
68 }
69
70 // RemoteBranchTip reads a branch's tip from the remote without fetching it, so
71 // the answer is the forge's current view. A missing branch is ("", false, nil);
72 // an unreachable remote is an error, because "not found" and "could not ask" are
73 // different answers.
74 func RemoteBranchTip(b *Branch, name string) (string, bool, error) {
75 cmd := exec.Command("git", "ls-remote", "--heads", b.Remote, "refs/heads/"+name)
76 cmd.Dir = b.Repo.Dir
77 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
78 var out, errb bytes.Buffer
79 cmd.Stdout, cmd.Stderr = &out, &errb
80 if err := cmd.Run(); err != nil {
81 return "", false, fmt.Errorf("ls-remote %s: %w: %s", b.Remote, err, strings.TrimSpace(errb.String()))
82 }
83 fields := strings.Fields(out.String())
84 if len(fields) != 2 || fields[1] != "refs/heads/"+name || fields[0] == "" {
85 return "", false, nil
86 }
87 return fields[0], true, nil
88 }
89
90 // NothingToReview reports whether head is already contained in base, which makes
91 // a request for it an empty review.
92 //
93 // The base is the remote's branch, not this clone's: a feature worktree usually
94 // commits on its local base branch, so the local ref can be the head itself and
95 // would call every request empty. Only when the remote cannot answer does the
96 // last fetched tracking ref, then the local branch, stand in. The ancestry check
97 // is skipped for a tip this clone does not have; equality still holds.
98 func NothingToReview(b *Branch, base, head string) bool {
99 base = strings.TrimPrefix(strings.TrimSpace(base), "refs/heads/")
100 if base == "" || head == "" {
101 return false
102 }
103 var tips []string
104 if tip, ok, err := RemoteBranchTip(b, base); err == nil && ok {
105 tips = append(tips, tip)
106 } else {
107 if !IsRemoteURL(b.Remote) {
108 if tip, err := b.Repo.RevParse("refs/remotes/" + b.Remote + "/" + base); err == nil && tip != "" {
109 tips = append(tips, tip)
110 }
111 }
112 if tip, err := b.Repo.RevParse("refs/heads/" + base); err == nil && tip != "" {
113 tips = append(tips, tip)
114 }
115 }
116 for _, tip := range tips {
117 if tip == head {
118 return true
119 }
120 }
121 for _, tip := range tips {
122 if in, err := b.Repo.IsAncestor(head, tip); err == nil && in {
123 return true
124 }
125 }
126 return false
127 }