-
Notifications
You must be signed in to change notification settings - Fork 20
/
Copy pathbranch_delete.go
297 lines (261 loc) · 8.6 KB
/
branch_delete.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
package main
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"strings"
"github.com/charmbracelet/log"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/graph"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
)
type branchDeleteCmd struct {
Force bool `help:"Force deletion of the branch"`
Branches []string `arg:"" optional:"" help:"Names of the branches to delete" predictor:"branches"`
}
func (*branchDeleteCmd) Help() string {
return text.Dedent(`
The deleted branches and their commits are removed from the stack.
Branches above the deleted branches are rebased onto
the next branches available downstack.
A prompt will allow selecting the target branch if none are provided.
`)
}
func (cmd *branchDeleteCmd) Run(
ctx context.Context,
log *log.Logger,
view ui.View,
repo *git.Repository,
store *state.Store,
svc *spice.Service,
) error {
if len(cmd.Branches) == 0 {
// If a branch name is not given, prompt for one;
// assuming we're in interactive mode.
if !ui.Interactive(view) {
return fmt.Errorf("cannot proceed without branch name: %w", errNoPrompt)
}
currentBranch, err := repo.CurrentBranch(ctx)
if err != nil {
currentBranch = ""
}
branch, err := (&branchPrompt{
Disabled: func(b git.LocalBranch) bool {
return b.Name == store.Trunk()
},
Default: currentBranch,
Title: "Select a branch to delete",
}).Run(ctx, view, repo, store)
if err != nil {
return fmt.Errorf("select branch: %w", err)
}
cmd.Branches = []string{branch}
}
type branchInfo struct {
Name string
Tracked bool
Base string // base branch (may be unset if untracked)
Head git.Hash // head hash (set only if exists)
Exists bool
}
// name to branch info
branchesToDelete := make(map[string]*branchInfo, len(cmd.Branches))
for _, branch := range cmd.Branches {
base := store.Trunk()
tracked, exists := true, true
var head git.Hash
if b, err := svc.LookupBranch(ctx, branch); err != nil {
if delErr := new(spice.DeletedBranchError); errors.As(err, &delErr) {
exists = false
base = delErr.Base
log.Info("branch has already been deleted", "branch", branch)
} else if errors.Is(err, state.ErrNotExist) {
tracked = false
log.Debug("branch is not tracked", "error", err)
log.Info("branch is not tracked: deleting anyway", "branch", branch)
} else {
return fmt.Errorf("lookup branch %v: %w", branch, err)
}
} else {
head = b.Head
base = b.Base
must.NotBeBlankf(base, "base branch for %v must be set", branch)
must.NotBeBlankf(head.String(), "head commit for %v must be set", branch)
}
// Branch is untracked, but exists.
if exists && head == "" {
hash, err := repo.PeelToCommit(ctx, branch)
if err != nil {
return fmt.Errorf("peel to commit: %w", err)
}
head = hash
}
branchesToDelete[branch] = &branchInfo{
Name: branch,
Head: head,
Base: base,
Tracked: tracked,
Exists: exists,
}
}
// upstack restack changes the current branch.
// checkoutTarget specifiest he branch we'll check out after deletion.
// The logic for this is as follows:
//
// - if in detached HEAD state, use the current commit
// - if the current branch is not being deleted, use that
// - if the current branch is being deleted,
// - if there are multiple branches, use trunk
// - if there is only one branch, use its base
//
// TODO: Make an 'upstack restack' spice.Service method
// that won't leave us on the wrong branch.
var checkoutTarget string
if currentBranch, err := repo.CurrentBranch(ctx); err != nil {
if !errors.Is(err, git.ErrDetachedHead) {
return fmt.Errorf("get current branch: %w", err)
}
head, err := repo.PeelToCommit(ctx, "HEAD")
if err != nil {
return fmt.Errorf("peel to commit: %w", err)
}
// In detached HEAD state, use the current commit.
checkoutTarget = head.String()
} else {
checkoutTarget = currentBranch
// Current branch is being deleted.
// If there are multiple branches, use trunk.
if slices.Contains(cmd.Branches, currentBranch) {
// If current branch is being deleted,
// pick a different branch to check out.
if len(branchesToDelete) == 1 {
info, ok := branchesToDelete[currentBranch]
must.Bef(ok, "current branch %v not found in branches to delete", currentBranch)
checkoutTarget = info.Base
} else {
// Multiple branches are being deleted.
// Use trunk.
checkoutTarget = store.Trunk()
}
}
}
// Branches may have relationships with each other.
// Sort them in topological order: [close to trunk, ..., further from trunk].
topoBranches := graph.Toposort(slices.Sorted(maps.Keys(branchesToDelete)),
func(branch string) (string, bool) {
info := branchesToDelete[branch]
// Branches affect each other's deletion order
// only if they're based on each other.
_, ok := branchesToDelete[info.Base]
return info.Base, ok
})
// Actual deletion will happen in the reverse of that order,
// deleting branches based on other branches first.
slices.Reverse(topoBranches)
deleteOrder := make([]*branchInfo, len(topoBranches))
for i, name := range topoBranches {
deleteOrder[i] = branchesToDelete[name]
}
// For each branch under consideration,
// if it's a tracked branch, update the upstacks from it
// to point to its base, or the next branch downstack
// if the base is also being deleted.
for _, info := range deleteOrder {
branch := info.Name
if !info.Tracked {
continue
}
// Search through the bases to find one
// that isn't being deleted.
base := info.Base
baseInfo, deletingBase := branchesToDelete[base]
for base != store.Trunk() && deletingBase {
base = baseInfo.Base
baseInfo, deletingBase = branchesToDelete[base]
}
aboves, err := svc.ListAbove(ctx, branch)
if err != nil {
return fmt.Errorf("list above %v: %w", branch, err)
}
for _, above := range aboves {
if _, ok := branchesToDelete[above]; ok {
// This upstack is also being deleted. Skip.
continue
}
if err := svc.BranchOnto(ctx, &spice.BranchOntoRequest{
Branch: above,
Onto: base,
}); err != nil {
contCmd := []string{"branch", "delete"}
if cmd.Force {
contCmd = append(contCmd, "--force")
}
contCmd = append(contCmd, cmd.Branches...)
return svc.RebaseRescue(ctx, spice.RebaseRescueRequest{
Err: err,
Command: contCmd,
Branch: checkoutTarget,
Message: fmt.Sprintf("interrupted: %v: deleting branch", branch),
})
}
log.Infof("%v: moved upstack onto %v", above, base)
}
}
if err := repo.Checkout(ctx, checkoutTarget); err != nil {
return fmt.Errorf("checkout %v: %w", checkoutTarget, err)
}
branchTx := store.BeginBranchTx()
var untrackedNames []string
for _, b := range deleteOrder {
branch, head := b.Name, b.Head
exists, tracked, force := b.Exists, b.Tracked, cmd.Force
// If the branch exists, and is not reachable from HEAD,
// git will refuse to delete it.
// If we can prompt, ask the user to upgrade to a forceful deletion.
if exists && !force && ui.Interactive(view) && !repo.IsAncestor(ctx, head, "HEAD") {
log.Warnf("%v (%v) is not reachable from HEAD", branch, head.Short())
prompt := ui.NewConfirm().
WithTitlef("Delete %v anyway?", branch).
WithDescriptionf("%v has not been merged into HEAD. This may result in data loss.", branch).
WithValue(&force)
if err := ui.Run(view, prompt); err != nil {
return fmt.Errorf("run prompt: %w", err)
}
}
if exists {
opts := git.BranchDeleteOptions{Force: force}
if err := repo.DeleteBranch(ctx, branch, opts); err != nil {
// If the branch still exists,
// it's likely because it's not merged.
if _, peelErr := repo.PeelToCommit(ctx, branch); peelErr == nil {
log.Error("git refused to delete the branch", "err", err)
log.Error("try re-running with --force")
return errors.New("branch not deleted")
}
// If the branch doesn't exist,
// it may already have been deleted.
log.Warn("branch may already have been deleted", "err", err)
}
log.Infof("%v: deleted (was %v)", branch, head.Short())
}
if tracked {
if err := branchTx.Delete(ctx, branch); err != nil {
log.Warn("Unable to untrack branch", "branch", branch, "error", err)
log.Warn("Try manually untracking the branch with 'gs branch untrack'")
} else {
untrackedNames = append(untrackedNames, branch)
}
}
}
msg := fmt.Sprintf("delete: %v", strings.Join(untrackedNames, ", "))
if err := branchTx.Commit(ctx, msg); err != nil {
return fmt.Errorf("update state: %w", err)
}
return nil
}