Skip to content

Commit 56807e0

Browse files
feat: fetch a force-pushed approval commit instead of dismissing blind
When a branch is rebased or force-pushed, the commit an approval points at stops being reachable from any local ref. ChangesSince then fails on git diff base...<approvalSHA>, and the approval lands in badApprovals with no ownership check at all. That dismissal is not "your files changed", it is "I could not tell, so I reset you". GitHub still serves the object. With fetch_orphaned_approval on, a ref which cannot be resolved locally is fetched from origin once and the diff retried, so the approval is judged on its diff rather than lost to a rewritten branch. Fail-safe throughout: if the fetch or the retry fails, the original diff error stays the cause and the approval is dismissed exactly as before. The fetch is bounded at a minute, since it is the only git call here that waits on a remote, and the ref is passed after a -- so that a ref beginning with a dash cannot be read as an option. git fetch accepts --upload-pack, which names a command to run.
1 parent 86cc504 commit 56807e0

5 files changed

Lines changed: 418 additions & 10 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ fetch_orphaned_approval = false
366366

367367
What counts as a change not worth re-reviewing is a judgement about a particular codebase, not something to inherit from a default. Two flags deserve extra thought before you name them. A change to a string literal or a rename can alter behavior without changing the shape of the code the approver reviewed, so retaining an approval across one is a stronger claim than the other categories. And `fetch_orphaned_approval` is the only flag in the section which reaches outside the checkout, so it adds network calls to a run.
368368

369+
`fetch_orphaned_approval` is the one flag which is not about the content of a change. A rebase or a force-push can leave the commit an approval points at unreachable from every local ref, and a diff which cannot be computed dismisses that approval without ever asking who owns what changed. With this flag on, a ref which cannot be resolved locally is fetched from `origin` once and the diff retried, so the approval is judged on its diff rather than lost to a rewritten branch. It is the only flag in the section which reaches outside the checkout: every other one reads the diff already in hand, while this one costs a fetch - bounded at one minute - on each distinct approval commit which cannot be resolved. A branch which has been force-pushed several times can therefore carry several of those, and a remote which does not answer turns each into a minute of waiting. That cost is the reason the flag stays off unless it is asked for, rather than following the umbrella.
370+
369371
#### Require Both Branch Reviewers (Ownership Handoffs)
370372

371373
The `require_both_branch_reviewers` feature enables self-service ownership transfers by requiring approval from codeowners defined in **BOTH** the base branch and the PR branch. This creates an AND relationship between ownership rules from both branches.

internal/app/app.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,11 @@ func (a *App) Run() (*OutputData, error) {
131131

132132
// Get the diff of the PR
133133
a.printDebug("Getting diff for %s...%s\n", diffContext.Base, diffContext.Head)
134-
gitDiff, err := git.NewDiff(diffContext)
134+
var diffOpts []git.DiffOption
135+
if conf.ApprovalRetention.FetchOrphanedApprovalEnabled() {
136+
diffOpts = append(diffOpts, git.WithFetchOrphanedRefs())
137+
}
138+
gitDiff, err := git.NewDiff(diffContext, diffOpts...)
135139
if err != nil {
136140
return &OutputData{}, fmt.Errorf("NewGitDiff Error: %v", err)
137141
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package app
2+
3+
import (
4+
"os/exec"
5+
"strings"
6+
"testing"
7+
)
8+
9+
const orphanBaseSource = `package service
10+
11+
func Gamma() int {
12+
return 3
13+
}
14+
`
15+
16+
const orphanApprovedSource = `package service
17+
18+
func Gamma() int {
19+
return 30
20+
}
21+
`
22+
23+
// The approved commit exists only on the remote, as a force-push leaves it, and the
24+
// head edits a file the reviewer does not own: the approval survives iff the diff does.
25+
func buildOrphanedApprovalRepo(t *testing.T, configBody string) (repoDir, baseSHA, headSHA, approvalSHA string) {
26+
t.Helper()
27+
repoDir = t.TempDir()
28+
initRepo(t, repoDir)
29+
30+
writeRepoFile(t, repoDir, ".codeowners", "service.go @owner\n")
31+
writeRepoFile(t, repoDir, "codeowners.toml", configBody)
32+
writeRepoFile(t, repoDir, "service.go", orphanBaseSource)
33+
writeRepoFile(t, repoDir, "notes.md", "first note\n")
34+
baseSHA = commitAll(t, repoDir, "base")
35+
36+
// Grown on the remote, where the local repository never sees it.
37+
originDir := t.TempDir()
38+
runGit(t, repoDir, "clone", "-q", repoDir, originDir)
39+
initRepo(t, originDir)
40+
runGit(t, originDir, "config", "uploadpack.allowAnySHA1InWant", "true")
41+
writeRepoFile(t, originDir, "service.go", orphanApprovedSource)
42+
approvalSHA = commitAll(t, originDir, "approved change")
43+
44+
writeRepoFile(t, repoDir, "service.go", orphanApprovedSource)
45+
writeRepoFile(t, repoDir, "notes.md", "first note\nsecond note\n")
46+
headSHA = commitAll(t, repoDir, "approved change plus an unowned edit")
47+
runGit(t, repoDir, "remote", "add", "origin", originDir)
48+
49+
return repoDir, baseSHA, headSHA, approvalSHA
50+
}
51+
52+
// The config file alone decides whether an approval whose commit exists only on the
53+
// remote is recovered or dismissed.
54+
func TestRunFetchesOrphanedApproval(t *testing.T) {
55+
const fetchOff = `disable_review_status_comments = true
56+
suppress_unowned_warning = true
57+
`
58+
const fetchOn = `disable_review_status_comments = true
59+
suppress_unowned_warning = true
60+
61+
[approval_retention]
62+
enabled = true
63+
fetch_orphaned_approval = true
64+
`
65+
66+
tt := []struct {
67+
name string
68+
config string
69+
expectDismissed bool
70+
}{
71+
{name: "fetch disabled, approval dismissed", config: fetchOff, expectDismissed: true},
72+
{name: "fetch enabled, approval recovered", config: fetchOn, expectDismissed: false},
73+
}
74+
75+
for _, tc := range tt {
76+
t.Run(tc.name, func(t *testing.T) {
77+
repoDir, baseSHA, headSHA, approvalSHA := buildOrphanedApprovalRepo(t, tc.config)
78+
79+
// Guard the premise: if the commit resolved locally the fetch would have
80+
// nothing to recover and both cases would pass for the wrong reason.
81+
cmd := exec.Command("git", "cat-file", "-e", approvalSHA)
82+
cmd.Dir = repoDir
83+
if err := cmd.Run(); err == nil {
84+
t.Fatalf("approval commit %s should not be present locally", approvalSHA)
85+
}
86+
87+
output, dismissed, warnings := runApp(t, repoDir, baseSHA, headSHA, approvalSHA)
88+
89+
if tc.expectDismissed {
90+
if len(dismissed) != 1 {
91+
t.Errorf("expected the approval to be dismissed, got %d dismissals", len(dismissed))
92+
}
93+
if !strings.Contains(warnings, "Error getting changes since") {
94+
t.Errorf("expected a warning about the unresolvable ref, got %q", warnings)
95+
}
96+
if output.Success {
97+
t.Error("expected the run to fail without the approval")
98+
}
99+
return
100+
}
101+
102+
if len(dismissed) != 0 {
103+
t.Errorf("expected the approval to survive, got %d dismissals: %s", len(dismissed), warnings)
104+
}
105+
if !output.Success {
106+
t.Errorf("expected the run to succeed, got %q (warnings: %s)", output.Message, warnings)
107+
}
108+
})
109+
}
110+
}

internal/git/diff.go

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,32 @@ package git
33
import (
44
"bufio"
55
"bytes"
6+
"context"
67
"crypto/sha256"
8+
"errors"
79
"fmt"
810
"os/exec"
911
"slices"
1012
"strings"
13+
"time"
1114

1215
"github.com/multimediallc/codeowners-plus/pkg/codeowners"
1316
"github.com/sourcegraph/go-diff/diff"
1417
)
1518

19+
// fetch is the only git call here that waits on a remote, so it alone needs a bound.
20+
const fetchTimeout = 60 * time.Second
21+
1622
// gitCommandExecutor defines the interface for executing git commands
1723
type gitCommandExecutor interface {
1824
execute(command string, args ...string) ([]byte, error)
1925
}
2026

27+
// timeoutExecutor is implemented only by executors that can bound a command.
28+
type timeoutExecutor interface {
29+
executeWithTimeout(timeout time.Duration, command string, args ...string) ([]byte, error)
30+
}
31+
2132
// realGitExecutor implements GitCommandExecutor using os/exec
2233
type realGitExecutor struct {
2334
dir string
@@ -33,25 +44,50 @@ func (e *realGitExecutor) execute(command string, args ...string) ([]byte, error
3344
return cmd.CombinedOutput()
3445
}
3546

47+
func (e *realGitExecutor) executeWithTimeout(timeout time.Duration, command string, args ...string) ([]byte, error) {
48+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
49+
defer cancel()
50+
cmd := exec.CommandContext(ctx, command, args...)
51+
cmd.Dir = e.dir
52+
output, err := cmd.CombinedOutput()
53+
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
54+
// The kill signal surfaces as a plain exit error, so name the real cause
55+
return output, fmt.Errorf("%s timed out after %s", command, timeout)
56+
}
57+
return output, err
58+
}
59+
3660
type Diff interface {
3761
AllChanges() []codeowners.DiffFile
3862
ChangesSince(ref string) ([]codeowners.DiffFile, error)
3963
Context() DiffContext
4064
}
4165

4266
type GitDiff struct {
43-
context DiffContext
44-
diff []*diff.FileDiff
45-
files []codeowners.DiffFile
46-
executor gitCommandExecutor
67+
context DiffContext
68+
diff []*diff.FileDiff
69+
files []codeowners.DiffFile
70+
executor gitCommandExecutor
71+
fetchOrphanedRefs bool
72+
}
73+
74+
// DiffOption configures optional GitDiff behavior.
75+
type DiffOption func(*GitDiff)
76+
77+
// WithFetchOrphanedRefs makes ChangesSince fetch a ref git cannot resolve locally
78+
// and retry once: a force-pushed approval commit is dismissed unreviewed otherwise.
79+
func WithFetchOrphanedRefs() DiffOption {
80+
return func(gd *GitDiff) {
81+
gd.fetchOrphanedRefs = true
82+
}
4783
}
4884

49-
func NewDiff(context DiffContext) (Diff, error) {
85+
func NewDiff(context DiffContext, opts ...DiffOption) (Diff, error) {
5086
executor := newRealGitExecutor(context.Dir)
51-
return NewDiffWithExecutor(context, executor)
87+
return NewDiffWithExecutor(context, executor, opts...)
5288
}
5389

54-
func NewDiffWithExecutor(context DiffContext, executor gitCommandExecutor) (Diff, error) {
90+
func NewDiffWithExecutor(context DiffContext, executor gitCommandExecutor, opts ...DiffOption) (Diff, error) {
5591
gitDiff, err := getGitDiff(context, executor)
5692
if err != nil {
5793
return nil, err
@@ -61,12 +97,16 @@ func NewDiffWithExecutor(context DiffContext, executor gitCommandExecutor) (Diff
6197
return nil, err
6298
}
6399

64-
return &GitDiff{
100+
gd := &GitDiff{
65101
context: context,
66102
diff: gitDiff,
67103
files: diffFiles,
68104
executor: executor,
69-
}, nil
105+
}
106+
for _, opt := range opts {
107+
opt(gd)
108+
}
109+
return gd, nil
70110
}
71111

72112
func (gd *GitDiff) AllChanges() []codeowners.DiffFile {
@@ -81,6 +121,17 @@ func (gd *GitDiff) ChangesSince(ref string) ([]codeowners.DiffFile, error) {
81121
IgnoreDirs: gd.context.IgnoreDirs,
82122
}
83123
olderDiff, err := getGitDiff(olderDiffContext, gd.executor)
124+
if err != nil && gd.fetchOrphanedRefs {
125+
// A force-push leaves the ref on no local branch, but the remote still
126+
// serves the object.
127+
if fetchErr := gd.fetchRef(ref); fetchErr != nil {
128+
err = fmt.Errorf("%w (fetching orphaned ref failed: %v)", err, fetchErr)
129+
} else if retryDiff, retryErr := getGitDiff(olderDiffContext, gd.executor); retryErr != nil {
130+
err = fmt.Errorf("%w (retry after fetching orphaned ref failed: %v)", err, retryErr)
131+
} else {
132+
olderDiff, err = retryDiff, nil
133+
}
134+
}
84135
if err != nil {
85136
return nil, fmt.Errorf("failed to get older diff: %w", err)
86137
}
@@ -95,6 +146,18 @@ func (gd *GitDiff) ChangesSince(ref string) ([]codeowners.DiffFile, error) {
95146
return diffFiles, nil
96147
}
97148

149+
// The `--` stops a ref beginning with a dash being read as an option: `git fetch`
150+
// accepts --upload-pack, which names a command to run.
151+
func (gd *GitDiff) fetchRef(ref string) error {
152+
args := []string{"fetch", "--no-tags", "origin", "--", ref}
153+
if executor, ok := gd.executor.(timeoutExecutor); ok {
154+
_, err := executor.executeWithTimeout(fetchTimeout, "git", args...)
155+
return err
156+
}
157+
_, err := gd.executor.execute("git", args...)
158+
return err
159+
}
160+
98161
func (gd *GitDiff) Context() DiffContext {
99162
return gd.context
100163
}

0 commit comments

Comments
 (0)