11package main
22
33import (
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\n Output: %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\n Output: %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.
252256It shows you all your forks, highlighting those that haven't been updated recently
253257and 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
257263func init () {
@@ -260,23 +266,26 @@ func init() {
260266}
261267
262268func 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}
0 commit comments