Skip to content

Commit 3e9198c

Browse files
ccoVeilleCodeWithEmad
authored andcommitted
chore: refactor code to use a context
add context handling via signal.NotifyContext Also, introduce a scanner for reading input that respects the context.
1 parent a1887d7 commit 3e9198c

File tree

2 files changed

+122
-25
lines changed

2 files changed

+122
-25
lines changed

main.go

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package main
22

33
import (
4-
"bufio"
4+
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"os"
89
"os/exec"
10+
"os/signal"
911
"strings"
1012
"time"
1113

@@ -54,23 +56,25 @@ type CommitComparison struct {
5456
BehindBy int `json:"behind_by"`
5557
}
5658

57-
func showSpinner(done chan bool) {
59+
func showSpinner(ctx context.Context, done chan bool) {
5860
spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
5961
i := 0
6062
for {
6163
select {
64+
case <-ctx.Done():
65+
fmt.Print("\r") // Clear the spinner
66+
return
6267
case <-done:
6368
fmt.Print("\r") // Clear the spinner
6469
return
65-
default:
70+
case <-time.After(100 * time.Millisecond):
6671
fmt.Printf("\r%s Fetching forks", spinner[i])
6772
i = (i + 1) % len(spinner)
68-
time.Sleep(100 * time.Millisecond)
6973
}
7074
}
7175
}
7276

73-
func getReposWithOpenPRs() (map[string][]PullRequestInfo, error) {
77+
func getReposWithOpenPRs(ctx context.Context) (map[string][]PullRequestInfo, error) {
7478
// GraphQL query to get all open PRs
7579
query := `
7680
query {
@@ -89,7 +93,7 @@ func getReposWithOpenPRs() (map[string][]PullRequestInfo, error) {
8993
}
9094
`
9195

92-
cmd := exec.Command("gh", "api", "graphql", "-f", fmt.Sprintf("query=%s", query))
96+
cmd := exec.CommandContext(ctx, "gh", "api", "graphql", "-f", fmt.Sprintf("query=%s", query))
9397
out, err := cmd.CombinedOutput()
9498
if err != nil {
9599
return nil, fmt.Errorf("error fetching open PRs: %v\nOutput: %s", err, string(out))
@@ -130,7 +134,7 @@ func getReposWithOpenPRs() (map[string][]PullRequestInfo, error) {
130134
return reposWithPRs, nil
131135
}
132136

133-
func getForks() ([]Repo, error) {
137+
func getForks(ctx context.Context) ([]Repo, error) {
134138
// GraphQL query to get all forks with pagination
135139
query := `
136140
query($after: String) {
@@ -179,7 +183,7 @@ func getForks() ([]Repo, error) {
179183
if cursor != "" {
180184
args = append(args, "-f", fmt.Sprintf("after=%s", cursor))
181185
}
182-
cmd := exec.Command("gh", args...)
186+
cmd := exec.CommandContext(ctx, "gh", args...)
183187
out, err := cmd.CombinedOutput()
184188
if err != nil {
185189
return nil, fmt.Errorf("error fetching forks: %v\nOutput: %s", err, string(out))
@@ -218,13 +222,13 @@ func getForks() ([]Repo, error) {
218222
return forks, nil
219223
}
220224

221-
func getCommitComparison(fork Repo) (*CommitComparison, error) {
225+
func getCommitComparison(ctx context.Context, fork Repo) (*CommitComparison, error) {
222226
if fork.Parent.NameWithOwner == "" || fork.Parent.DefaultBranchRef.Name == "" || fork.DefaultBranchRef.Name == "" {
223227
return nil, fmt.Errorf("missing required repository information")
224228
}
225229

226230
// Use gh api to get the comparison between the fork and its parent
227-
cmd := exec.Command("gh", "api",
231+
cmd := exec.CommandContext(ctx, "gh", "api",
228232
fmt.Sprintf("repos/%s/compare/%s...%s:%s",
229233
fork.Parent.NameWithOwner,
230234
fork.Parent.DefaultBranchRef.Name,
@@ -251,7 +255,9 @@ var rootCmd = &cobra.Command{
251255
Long: `A CLI tool to help you clean up your GitHub forks.
252256
It shows you all your forks, highlighting those that haven't been updated recently
253257
and allows you to delete them if they don't have any open pull requests.`,
254-
RunE: cleanupForks,
258+
RunE: cleanupForks,
259+
SilenceUsage: true, // Don't show usage on error
260+
SilenceErrors: true, // Disable cobra error handling. Errors are handled in the main function, we skip some of them
255261
}
256262

257263
func init() {
@@ -260,23 +266,26 @@ func init() {
260266
}
261267

262268
func cleanupForks(cmd *cobra.Command, args []string) error {
269+
// retrieve the context set in the main function
270+
ctx := cmd.Context()
271+
263272
// Start spinner
264273
done := make(chan bool)
265-
go showSpinner(done)
274+
go showSpinner(ctx, done)
266275

267276
// Get flags
268277
force, _ := cmd.Flags().GetBool("force")
269278
skipConfirmation, _ := cmd.Flags().GetBool("skip-confirmation")
270279

271280
// Get all repos with open PRs
272281
color.New(color.FgBlue).Println("Fetching repositories with open pull requests...")
273-
reposWithPRs, err := getReposWithOpenPRs()
282+
reposWithPRs, err := getReposWithOpenPRs(ctx)
274283
if err != nil {
275284
return err
276285
}
277286

278287
// Fetch all forks using GraphQL
279-
forks, err := getForks()
288+
forks, err := getForks(ctx)
280289
if err != nil {
281290
return err
282291
}
@@ -290,7 +299,7 @@ func cleanupForks(cmd *cobra.Command, args []string) error {
290299
}
291300

292301
color.New(color.FgCyan, color.Bold).Printf("📦 Found %d forks\n", len(forks))
293-
scanner := bufio.NewScanner(os.Stdin)
302+
scanner := newScanner(os.Stdin)
294303
for _, fork := range forks {
295304
fmt.Print("\n")
296305
color.New(color.FgGreen, color.Bold).Printf("📂 Repository: %s\n", fork.NameWithOwner)
@@ -300,7 +309,7 @@ func cleanupForks(cmd *cobra.Command, args []string) error {
300309
}
301310

302311
// Show commit comparison information
303-
if comparison, err := getCommitComparison(fork); err == nil {
312+
if comparison, err := getCommitComparison(ctx, fork); err == nil {
304313
if comparison.AheadBy > 0 || comparison.BehindBy > 0 {
305314
color.New(color.FgBlue).Printf(" 📊 Commits: %d ahead, %d behind\n",
306315
comparison.AheadBy,
@@ -320,18 +329,27 @@ func cleanupForks(cmd *cobra.Command, args []string) error {
320329
color.New(color.FgYellow).Printf(" 📅 Last updated: %s\n", fork.UpdatedAt)
321330
if !force {
322331
color.New(color.FgMagenta).Print("❔ Delete this repository? (y/n/o to open in browser, default n): ")
323-
scanner.Scan()
332+
err := scanner.Read(ctx)
333+
if err != nil {
334+
return fmt.Errorf("error reading input: %v", err)
335+
}
336+
324337
answer := strings.ToLower(strings.TrimSpace(scanner.Text()))
325338

326339
if answer == "o" {
327340
repoURL := fmt.Sprintf("https://github.com/%s", fork.NameWithOwner)
328-
openCmd := exec.Command("xdg-open", repoURL)
341+
openCmd := exec.CommandContext(ctx, "xdg-open", repoURL)
329342
if err := openCmd.Run(); err != nil {
343+
// this is a non-fatal error, just print a message
344+
// no need to stop the program
330345
fmt.Fprintf(os.Stderr, "Error opening URL: %v\n", err)
331346
}
332347
// Ask again after opening the URL
333348
color.New(color.FgMagenta).Print("❔ Delete this repository? (y/n, default n): ")
334-
scanner.Scan()
349+
err := scanner.Read(ctx)
350+
if err != nil {
351+
return fmt.Errorf("error reading input: %v", err)
352+
}
335353
answer = strings.ToLower(strings.TrimSpace(scanner.Text()))
336354
}
337355

@@ -343,7 +361,10 @@ func cleanupForks(cmd *cobra.Command, args []string) error {
343361
// Double confirm if there are open PRs and skip-confirmation is not set
344362
if _, hasPRs := reposWithPRs[fork.NameWithOwner]; hasPRs && !skipConfirmation {
345363
color.New(color.FgRed, color.Bold).Print("❗ This fork has open PRs. Are you ABSOLUTELY sure you want to delete it? (yes/N): ")
346-
scanner.Scan()
364+
err := scanner.Read(ctx)
365+
if err != nil {
366+
return fmt.Errorf("error reading input: %v", err)
367+
}
347368
confirm := strings.ToLower(strings.TrimSpace(scanner.Text()))
348369
if confirm != "yes" {
349370
color.New(color.FgBlue).Printf("⏭️ Skipping %s...\n", fork.NameWithOwner)
@@ -353,7 +374,7 @@ func cleanupForks(cmd *cobra.Command, args []string) error {
353374
}
354375

355376
color.New(color.FgRed).Printf("🗑️ Deleting %s...\n", fork.NameWithOwner)
356-
deleteCmd := exec.Command("gh", "repo", "delete", fork.NameWithOwner, "--yes")
377+
deleteCmd := exec.CommandContext(ctx, "gh", "repo", "delete", fork.NameWithOwner, "--yes")
357378
if err := deleteCmd.Run(); err != nil {
358379
fmt.Fprintf(os.Stderr, "Error deleting %s: %v\n", fork.NameWithOwner, err)
359380
} else {
@@ -366,9 +387,31 @@ func cleanupForks(cmd *cobra.Command, args []string) error {
366387
return nil
367388
}
368389

369-
func main() {
370-
if err := rootCmd.Execute(); err != nil {
371-
fmt.Println(err)
372-
os.Exit(1)
390+
// run executes the root command and handles context cancellation.
391+
//
392+
// It returns an exit code based on the command execution result.
393+
// If the command is canceled (e.g., by Ctrl+C), it returns 130.
394+
// If an error occurs, it prints the error to stderr and returns 1.
395+
// Otherwise, it returns 0 for a successful execution.
396+
func run(ctx context.Context) int {
397+
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
398+
defer cancel()
399+
400+
err := rootCmd.ExecuteContext(ctx)
401+
if err != nil {
402+
if errors.Is(ctx.Err(), context.Canceled) {
403+
// handle the CTRL+C case silently
404+
return 130 // classic exit code for a SIGINT (Ctrl+C) termination
405+
}
406+
407+
fmt.Fprintln(os.Stderr, err)
408+
return 1 // return a non-zero exit code for any other error
373409
}
410+
411+
return 0 // success
412+
}
413+
414+
func main() {
415+
ctx := context.Background()
416+
os.Exit(run(ctx))
374417
}

scanner.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
)
9+
10+
// scanner is a wrapper around [bufio.Scanner] that allows reading input with context cancellation.
11+
type scanner struct {
12+
*bufio.Scanner
13+
}
14+
15+
func newScanner(r io.Reader) *scanner {
16+
return &scanner{
17+
Scanner: bufio.NewScanner(r),
18+
}
19+
}
20+
21+
// Read reads input from the scanner, respecting the provided context.
22+
// It returns an error if the context is canceled or if there is an error scanning the input.
23+
func (s *scanner) Read(ctx context.Context) error {
24+
scanned := make(chan struct{})
25+
errChan := make(chan error)
26+
defer func() {
27+
// clean up channels when done
28+
close(errChan)
29+
close(scanned)
30+
}()
31+
32+
// because bufio.Scanner does not support context cancellation directly,
33+
// we run the scanning in a goroutine and use channels to communicate completion or errors.
34+
go func() {
35+
s.Scan()
36+
err := s.Err()
37+
if err != nil {
38+
errChan <- fmt.Errorf("error scanning input: %v", err)
39+
return
40+
}
41+
// this will signal that scanning is done
42+
scanned <- struct{}{}
43+
}()
44+
45+
select {
46+
case <-ctx.Done():
47+
return ctx.Err()
48+
case err := <-errChan:
49+
return err
50+
case <-scanned:
51+
// Successfully scanned input
52+
return nil
53+
}
54+
}

0 commit comments

Comments
 (0)