-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstatus.go
More file actions
413 lines (357 loc) · 12.9 KB
/
status.go
File metadata and controls
413 lines (357 loc) · 12.9 KB
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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/javoire/stackinator/internal/git"
"github.com/javoire/stackinator/internal/github"
"github.com/javoire/stackinator/internal/spinner"
"github.com/javoire/stackinator/internal/stack"
"github.com/javoire/stackinator/internal/ui"
"github.com/spf13/cobra"
)
var (
noPR bool
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show the current stack structure",
Long: `Display the stack structure as a tree, showing:
- Branch hierarchy (parent → child relationships)
- Current branch (highlighted with *)
- PR status for each branch (if available)
This helps you visualize your stack and see which branches have PRs.`,
Example: ` # Show stack structure
stack status
# Show without PR info (faster)
stack status --no-pr
# Example output:
# main
# |
# feature-auth [PR #123: OPEN]
# |
# feature-auth-tests *`,
Run: func(cmd *cobra.Command, args []string) {
gitClient := git.NewGitClient()
repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin"))
githubClient := github.NewGitHubClient(repo)
if err := runStatus(gitClient, githubClient); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
statusCmd.Flags().BoolVar(&noPR, "no-pr", false, "Skip fetching PR information (faster)")
}
func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error {
var currentBranch string
var stackBranches []stack.StackBranch
var tree *stack.TreeNode
var allTreeBranches []string
// Start fetch in parallel with stack tree building (if not --no-pr)
var fetchWg sync.WaitGroup
var prCache map[string]*github.PRInfo
fetchDone := false
if !noPR {
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
// Fetch latest changes from origin (needed for sync issue detection)
_ = gitClient.Fetch()
fetchDone = true
}()
} else {
prCache = make(map[string]*github.PRInfo)
}
// Build the stack tree, then fetch PRs for tree branches only
if err := spinner.WrapWithAutoDelay("Loading stack...", 300*time.Millisecond, func() error {
// Get current branch
var err error
currentBranch, err = gitClient.GetCurrentBranch()
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
}
// Check if there are any stack branches
stackBranches, err = stack.GetStackBranches(gitClient)
if err != nil {
return fmt.Errorf("failed to get stack branches: %w", err)
}
if len(stackBranches) == 0 {
return nil // Handle this after the spinner
}
// Build stack tree for current branch only
tree, err = stack.BuildStackTreeForBranch(gitClient, currentBranch)
if err != nil {
return fmt.Errorf("failed to build stack tree: %w", err)
}
// If tree is nil, current branch is not in a stack
if tree == nil {
return nil // Will be handled after spinner
}
// Get ALL branch names in the tree (including intermediate branches without stackparent)
allTreeBranches = getAllBranchNamesFromTree(tree)
// Fetch PRs for stack branches only (parallel individual fetches)
if !noPR {
prCache = githubClient.GetPRsForBranches(allTreeBranches)
}
return nil
}); err != nil {
return err
}
if len(stackBranches) == 0 {
// Wait for fetch to complete before returning
fetchWg.Wait()
fmt.Println("No stack branches found.")
fmt.Printf("Current branch: %s\n", ui.Branch(currentBranch))
fmt.Printf("\nUse '%s' to create a new stack branch.\n", ui.Command("stack new <branch-name>"))
return nil
}
// If tree is nil, current branch is not part of any stack
// Check this BEFORE waiting for PR fetch to avoid long delays
if tree == nil {
baseBranch := stack.GetBaseBranch(gitClient)
// Don't offer to add the base branch to a stack - it can't have a parent
if currentBranch == baseBranch {
fmt.Printf("No stack found. You're on the base branch (%s).\n", ui.Branch(currentBranch))
fmt.Printf("\nUse '%s' to create a new stack branch.\n", ui.Command("stack new <branch-name>"))
return nil
}
fmt.Printf("Current branch '%s' is not part of a stack.\n\n", ui.Branch(currentBranch))
fmt.Printf("Add to stack with '%s' as parent? [Y/n] ", ui.Branch(baseBranch))
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read input: %w", err)
}
input = strings.TrimSpace(strings.ToLower(input))
if input == "" || input == "y" || input == "yes" {
// Set the stackparent config
configKey := fmt.Sprintf("branch.%s.stackparent", currentBranch)
if err := gitClient.SetConfig(configKey, baseBranch); err != nil {
return fmt.Errorf("failed to set stack parent: %w", err)
}
fmt.Println(ui.Success(fmt.Sprintf("Added '%s' to stack with parent '%s'", ui.Branch(currentBranch), ui.Branch(baseBranch))))
fmt.Println()
// Run status again to show the stack
return runStatus(gitClient, githubClient)
}
return nil
}
// Print the tree
fmt.Println()
printTree(gitClient, tree, "", true, currentBranch, prCache)
// Check for sync issues (skip if --no-pr)
if !noPR {
// Wait for git fetch to complete (needed for sync issue detection)
fetchWg.Wait()
// Filter stackBranches to only include branches in the current tree
branchSet := make(map[string]bool)
for _, name := range allTreeBranches {
branchSet[name] = true
}
var treeBranches []stack.StackBranch
for _, branch := range stackBranches {
if branchSet[branch.Name] {
treeBranches = append(treeBranches, branch)
}
}
var syncResult *syncIssuesResult
if err := spinner.WrapWithAutoDelayAndProgress("Checking for sync issues...", 300*time.Millisecond, func(progress spinner.ProgressFunc) error {
var err error
syncResult, err = detectSyncIssues(gitClient, treeBranches, prCache, progress, fetchDone)
return err
}); err != nil {
// Don't fail on detection errors, just skip the check
return nil
}
// Print the result after spinner is stopped
if syncResult != nil {
printSyncIssues(syncResult)
}
}
return nil
}
// getAllBranchNamesFromTree extracts all branch names from the tree
// (including intermediate branches that may not have stackparent config)
func getAllBranchNamesFromTree(node *stack.TreeNode) []string {
if node == nil {
return nil
}
var result []string
var traverse func(*stack.TreeNode)
traverse = func(n *stack.TreeNode) {
if n == nil {
return
}
// Add current branch name
result = append(result, n.Name)
// Traverse children
for _, child := range n.Children {
traverse(child)
}
}
traverse(node)
return result
}
func printTree(gitClient git.GitClient, node *stack.TreeNode, prefix string, isLast bool, currentBranch string, prCache map[string]*github.PRInfo) {
if node == nil {
return
}
// Flatten the tree into a vertical list
printTreeVertical(gitClient, node, currentBranch, prCache, false)
}
func printTreeVertical(gitClient git.GitClient, node *stack.TreeNode, currentBranch string, prCache map[string]*github.PRInfo, isChild bool) {
if node == nil {
return
}
// Determine the node marker (filled for current branch, hollow for others)
nodeMarker := ui.TreeNode()
if node.Name == currentBranch {
nodeMarker = ui.TreeNodeCurrent()
}
// Get PR info from cache
prInfo := ""
if node.Name != stack.GetBaseBranch(gitClient) {
if pr, exists := prCache[node.Name]; exists {
prInfo = fmt.Sprintf(" %s", ui.PRInfo(pr.URL, pr.State))
}
}
// Print connecting line if this is a child node
if isChild {
fmt.Printf("%s\n", ui.TreeLine())
}
// Print current node
fmt.Printf("%s %s%s\n", nodeMarker, ui.Branch(node.Name), prInfo)
// Print children vertically
for _, child := range node.Children {
printTreeVertical(gitClient, child, currentBranch, prCache, true)
}
}
// syncIssuesResult holds the result of detectSyncIssues
type syncIssuesResult struct {
issues []string
}
// detectSyncIssues checks if any branches are out of sync and returns the issues (doesn't print)
// If skipFetch is true, assumes git fetch was already called (to avoid redundant network calls)
func detectSyncIssues(gitClient git.GitClient, stackBranches []stack.StackBranch, prCache map[string]*github.PRInfo, progress spinner.ProgressFunc, skipFetch bool) (*syncIssuesResult, error) {
var issues []string
// Fetch once upfront to ensure we have latest remote refs (unless already done)
if !skipFetch {
progress("Fetching latest changes...")
if verbose {
fmt.Println("Fetching latest changes from origin...")
}
_ = gitClient.Fetch()
}
if verbose {
fmt.Printf("Checking %d branch(es) for sync issues...\n", len(stackBranches))
}
baseBranch := stack.GetBaseBranch(gitClient)
// Check each stack branch for sync issues
for i, branch := range stackBranches {
progress(fmt.Sprintf("Checking branch %d/%d (%s)...", i+1, len(stackBranches), branch.Name))
if verbose {
fmt.Printf("\n[%d/%d] Checking '%s' (parent: %s)\n", i+1, len(stackBranches), branch.Name, branch.Parent)
}
// Skip branches with merged PRs - they don't need any sync action
if pr, exists := prCache[branch.Name]; exists && pr.State == "MERGED" {
if verbose {
fmt.Printf(" Skipping (PR is merged)\n")
}
continue
}
// Check if PR base matches the configured parent (if PR exists)
if pr, exists := prCache[branch.Name]; exists {
if verbose {
fmt.Printf(" Found PR #%d (base: %s, state: %s)\n", pr.Number, pr.Base, pr.State)
}
if pr.Base != branch.Parent {
if verbose {
fmt.Printf(" ✗ PR base (%s) doesn't match configured parent (%s)\n", pr.Base, branch.Parent)
}
issues = append(issues, fmt.Sprintf(" - Branch '%s' PR base (%s) doesn't match parent (%s)", ui.Branch(branch.Name), ui.Branch(pr.Base), ui.Branch(branch.Parent)))
} else if verbose {
fmt.Printf(" ✓ PR base matches configured parent\n")
}
} else {
// No PR found — check if branch appears merged via git history
if verbose {
fmt.Printf(" No PR found, checking git history for merge...\n")
}
merged, err := gitClient.IsAncestor(branch.Name, "origin/"+baseBranch)
if err == nil && merged {
if verbose {
fmt.Printf(" ✓ Branch appears merged into %s via git history\n", baseBranch)
}
issues = append(issues, fmt.Sprintf(" - Branch '%s' appears merged into %s (run '%s' to clean up)", ui.Branch(branch.Name), ui.Branch(baseBranch), ui.Command("stack prune")))
continue // Skip other checks for merged branches
} else if err != nil && verbose {
fmt.Printf(" ⚠ Could not check if branch is ancestor: %v\n", err)
} else if verbose {
fmt.Printf(" No PR found for this branch\n")
}
}
// Check if branch is behind its parent (needs rebase) - always check this regardless of PR
if verbose {
fmt.Printf(" Checking if branch is behind parent %s...\n", branch.Parent)
}
behind, err := gitClient.IsCommitsBehind(branch.Name, branch.Parent)
if err == nil && behind {
if verbose {
fmt.Printf(" ✗ Branch is behind %s (needs rebase)\n", branch.Parent)
}
issues = append(issues, fmt.Sprintf(" - Branch '%s' is behind %s (needs rebase)", ui.Branch(branch.Name), ui.Branch(branch.Parent)))
} else if err == nil && verbose {
fmt.Printf(" ✓ Branch is up to date with %s\n", branch.Parent)
} else if err != nil && verbose {
fmt.Printf(" ⚠ Could not check if branch is behind: %v\n", err)
}
// Check if local branch differs from remote (needs push)
if gitClient.RemoteBranchExists(branch.Name) {
if verbose {
fmt.Printf(" Checking if local branch differs from origin/%s...\n", branch.Name)
}
localHash, localErr := gitClient.GetCommitHash(branch.Name)
remoteHash, remoteErr := gitClient.GetCommitHash("origin/" + branch.Name)
if localErr == nil && remoteErr == nil && localHash != remoteHash {
if verbose {
fmt.Printf(" ✗ Local branch differs from origin/%s (needs push)\n", branch.Name)
}
issues = append(issues, fmt.Sprintf(" - Branch '%s' differs from origin (needs push)", ui.Branch(branch.Name)))
} else if localErr == nil && remoteErr == nil && verbose {
fmt.Printf(" ✓ Local branch matches origin/%s\n", branch.Name)
} else if verbose {
if localErr != nil {
fmt.Printf(" ⚠ Could not get local commit hash: %v\n", localErr)
}
if remoteErr != nil {
fmt.Printf(" ⚠ Could not get remote commit hash: %v\n", remoteErr)
}
}
} else if verbose {
fmt.Printf(" ℹ No remote branch origin/%s found\n", branch.Name)
}
}
return &syncIssuesResult{
issues: issues,
}, nil
}
// printSyncIssues prints the sync issues result
func printSyncIssues(result *syncIssuesResult) {
if len(result.issues) > 0 {
fmt.Println()
fmt.Println(ui.Warning("Stack out of sync detected:"))
for _, issue := range result.issues {
fmt.Println(issue)
}
fmt.Println()
fmt.Printf("Run '%s' to rebase branches and update PR bases.\n", ui.Command("stack sync"))
} else {
fmt.Println()
fmt.Println(ui.Success("Stack is perfectly synced! All branches are up to date."))
}
}