Skip to content

Commit 7144d88

Browse files
committed
politely enable rerere and cache decline
1 parent 7bb5ca2 commit 7144d88

9 files changed

Lines changed: 183 additions & 8 deletions

File tree

cmd/init.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func runInit(cfg *config.Config, opts *initOptions) error {
5858
trunk := opts.base
5959

6060
// Enable git rerere so conflict resolutions are remembered.
61-
_ = git.EnableRerere()
61+
ensureRerere(cfg)
6262

6363
if trunk == "" {
6464
trunk, err = git.DefaultBranch()

cmd/init_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,16 @@ func TestInit_PrefixStoredInStack(t *testing.T) {
140140
}
141141
}
142142

143-
func TestInit_EnablesRerere(t *testing.T) {
143+
func TestInit_RerereAlreadyEnabled(t *testing.T) {
144144
gitDir := t.TempDir()
145-
rerereCalled := false
145+
enableRerereCalled := false
146146
restore := git.SetOps(&git.MockOps{
147147
GitDirFn: func() (string, error) { return gitDir, nil },
148148
DefaultBranchFn: func() (string, error) { return "main", nil },
149149
CurrentBranchFn: func() (string, error) { return "main", nil },
150+
IsRerereEnabledFn: func() (bool, error) { return true, nil },
150151
EnableRerereFn: func() error {
151-
rerereCalled = true
152+
enableRerereCalled = true
152153
return nil
153154
},
154155
})
@@ -158,8 +159,8 @@ func TestInit_EnablesRerere(t *testing.T) {
158159
runInit(cfg, &initOptions{branches: []string{"b1"}})
159160
collectOutput(cfg, outR, errR)
160161

161-
if !rerereCalled {
162-
t.Error("expected EnableRerere to be called")
162+
if enableRerereCalled {
163+
t.Error("EnableRerere should not be called when rerere is already enabled")
163164
}
164165
}
165166

cmd/rebase.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
8888
currentBranch := result.CurrentBranch
8989

9090
// Enable git rerere so conflict resolutions are remembered.
91-
_ = git.EnableRerere()
91+
ensureRerere(cfg)
9292

9393
// Resolve remote for fetch and trunk comparison
9494
remote, err := pickRemote(cfg, currentBranch)

cmd/sync.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func runSync(cfg *config.Config, _ *syncOptions) error {
5858

5959
// --- Step 1: Fetch ---
6060
// Enable git rerere so conflict resolutions are remembered.
61-
_ = git.EnableRerere()
61+
ensureRerere(cfg)
6262

6363
if err := git.Fetch(remote); err != nil {
6464
cfg.Warningf("Failed to fetch %s: %v", remote, err)

cmd/utils.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,35 @@ func activeBranchNames(s *stack.Stack) []string {
212212
}
213213
return names
214214
}
215+
216+
// ensureRerere checks whether git rerere is enabled and, if not, prompts the
217+
// user for permission before enabling it. If the user previously declined,
218+
// the prompt is suppressed. In non-interactive sessions the function is a
219+
// no-op so commands can still run in CI/scripting.
220+
func ensureRerere(cfg *config.Config) {
221+
enabled, err := git.IsRerereEnabled()
222+
if err != nil || enabled {
223+
return
224+
}
225+
226+
declined, _ := git.IsRerereDeclined()
227+
if declined {
228+
return
229+
}
230+
231+
if !cfg.IsInteractive() {
232+
return
233+
}
234+
235+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
236+
ok, err := p.Confirm("Enable git rerere to remember conflict resolutions?", true)
237+
if err != nil {
238+
return
239+
}
240+
241+
if ok {
242+
_ = git.EnableRerere()
243+
} else {
244+
_ = git.SaveRerereDeclined()
245+
}
246+
}

cmd/utils_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/github/gh-stack/internal/config"
7+
"github.com/github/gh-stack/internal/git"
8+
)
9+
10+
func TestEnsureRerere_SkipsWhenAlreadyEnabled(t *testing.T) {
11+
enableCalled := false
12+
restore := git.SetOps(&git.MockOps{
13+
IsRerereEnabledFn: func() (bool, error) { return true, nil },
14+
EnableRerereFn: func() error {
15+
enableCalled = true
16+
return nil
17+
},
18+
})
19+
defer restore()
20+
21+
cfg, outR, errR := config.NewTestConfig()
22+
ensureRerere(cfg)
23+
collectOutput(cfg, outR, errR)
24+
25+
if enableCalled {
26+
t.Error("EnableRerere should not be called when already enabled")
27+
}
28+
}
29+
30+
func TestEnsureRerere_SkipsWhenDeclined(t *testing.T) {
31+
enableCalled := false
32+
restore := git.SetOps(&git.MockOps{
33+
IsRerereEnabledFn: func() (bool, error) { return false, nil },
34+
IsRerereDeclinedFn: func() (bool, error) { return true, nil },
35+
EnableRerereFn: func() error {
36+
enableCalled = true
37+
return nil
38+
},
39+
})
40+
defer restore()
41+
42+
cfg, outR, errR := config.NewTestConfig()
43+
ensureRerere(cfg)
44+
collectOutput(cfg, outR, errR)
45+
46+
if enableCalled {
47+
t.Error("EnableRerere should not be called when user previously declined")
48+
}
49+
}
50+
51+
func TestEnsureRerere_SkipsWhenNonInteractive(t *testing.T) {
52+
enableCalled := false
53+
declinedSaved := false
54+
restore := git.SetOps(&git.MockOps{
55+
IsRerereEnabledFn: func() (bool, error) { return false, nil },
56+
IsRerereDeclinedFn: func() (bool, error) { return false, nil },
57+
EnableRerereFn: func() error {
58+
enableCalled = true
59+
return nil
60+
},
61+
SaveRerereDeclinedFn: func() error {
62+
declinedSaved = true
63+
return nil
64+
},
65+
})
66+
defer restore()
67+
68+
// NewTestConfig is non-interactive (pipes, not a TTY).
69+
cfg, outR, errR := config.NewTestConfig()
70+
ensureRerere(cfg)
71+
collectOutput(cfg, outR, errR)
72+
73+
if enableCalled {
74+
t.Error("EnableRerere should not be called in non-interactive mode")
75+
}
76+
if declinedSaved {
77+
t.Error("SaveRerereDeclined should not be called in non-interactive mode")
78+
}
79+
}

internal/git/git.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ func EnableRerere() error {
149149
return ops.EnableRerere()
150150
}
151151

152+
// IsRerereEnabled returns whether rerere.enabled is set to "true" in git config.
153+
func IsRerereEnabled() (bool, error) {
154+
return ops.IsRerereEnabled()
155+
}
156+
157+
// IsRerereDeclined returns whether the user previously declined the rerere prompt.
158+
func IsRerereDeclined() (bool, error) {
159+
return ops.IsRerereDeclined()
160+
}
161+
162+
// SaveRerereDeclined records that the user declined the rerere prompt.
163+
func SaveRerereDeclined() error {
164+
return ops.SaveRerereDeclined()
165+
}
166+
152167
// RebaseOnto rebases a branch using the three-argument form:
153168
//
154169
// git rebase --onto <newBase> <oldBase> <branch>

internal/git/gitops.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type Ops interface {
2727
ResolveRemote(branch string) (string, error)
2828
Rebase(base string) error
2929
EnableRerere() error
30+
IsRerereEnabled() (bool, error)
31+
IsRerereDeclined() (bool, error)
32+
SaveRerereDeclined() error
3033
RebaseOnto(newBase, oldBase, branch string) error
3134
RebaseContinue() error
3235
RebaseAbort() error
@@ -172,6 +175,27 @@ func (d *defaultOps) EnableRerere() error {
172175
return runSilent("config", "rerere.autoupdate", "true")
173176
}
174177

178+
func (d *defaultOps) IsRerereEnabled() (bool, error) {
179+
out, err := run("config", "--get", "rerere.enabled")
180+
if err != nil {
181+
// Missing key — not enabled.
182+
return false, nil
183+
}
184+
return strings.EqualFold(strings.TrimSpace(out), "true"), nil
185+
}
186+
187+
func (d *defaultOps) IsRerereDeclined() (bool, error) {
188+
out, err := run("config", "--get", "gh-stack.rerere-declined")
189+
if err != nil {
190+
return false, nil
191+
}
192+
return strings.EqualFold(strings.TrimSpace(out), "true"), nil
193+
}
194+
195+
func (d *defaultOps) SaveRerereDeclined() error {
196+
return runSilent("config", "gh-stack.rerere-declined", "true")
197+
}
198+
175199
func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string) error {
176200
err := runSilent("rebase", "--onto", newBase, oldBase, branch)
177201
if err == nil {

internal/git/mock_ops.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ type MockOps struct {
1515
ResolveRemoteFn func(string) (string, error)
1616
RebaseFn func(string) error
1717
EnableRerereFn func() error
18+
IsRerereEnabledFn func() (bool, error)
19+
IsRerereDeclinedFn func() (bool, error)
20+
SaveRerereDeclinedFn func() error
1821
RebaseOntoFn func(string, string, string) error
1922
RebaseContinueFn func() error
2023
RebaseAbortFn func() error
@@ -121,6 +124,27 @@ func (m *MockOps) EnableRerere() error {
121124
return nil
122125
}
123126

127+
func (m *MockOps) IsRerereEnabled() (bool, error) {
128+
if m.IsRerereEnabledFn != nil {
129+
return m.IsRerereEnabledFn()
130+
}
131+
return false, nil
132+
}
133+
134+
func (m *MockOps) IsRerereDeclined() (bool, error) {
135+
if m.IsRerereDeclinedFn != nil {
136+
return m.IsRerereDeclinedFn()
137+
}
138+
return false, nil
139+
}
140+
141+
func (m *MockOps) SaveRerereDeclined() error {
142+
if m.SaveRerereDeclinedFn != nil {
143+
return m.SaveRerereDeclinedFn()
144+
}
145+
return nil
146+
}
147+
124148
func (m *MockOps) RebaseOnto(newBase, oldBase, branch string) error {
125149
if m.RebaseOntoFn != nil {
126150
return m.RebaseOntoFn(newBase, oldBase, branch)

0 commit comments

Comments
 (0)