diff --git a/cmd_check.go b/cmd_check.go new file mode 100644 index 00000000..9d573198 --- /dev/null +++ b/cmd_check.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "strings" + "sync" + + "github.com/x-motemen/ghq/cmdutil" + "github.com/urfave/cli/v2" +) + +func doCheck(c *cli.Context) error { + var ( + w = c.App.Writer + vcsBackend = c.String("vcs") + ) + + var ( + repos []*LocalRepository + mu sync.Mutex + ) + if err := walkLocalRepositories(vcsBackend, func(repo *LocalRepository) { + mu.Lock() + defer mu.Unlock() + repos = append(repos, repo) + }); err != nil { + return fmt.Errorf("failed to get repositories: %w", err) + } + + for _, repo := range repos { + out, err := cmdutil.RunAndGetOutput("git", "-C", repo.FullPath, "status", "--porcelain") + if err != nil { + // Handle cases where the directory is not a git repository + continue + } + stashes, _ := cmdutil.RunAndGetOutput("git", "-C", repo.FullPath, "stash", "list") + + if strings.TrimSpace(out) == "" && strings.TrimSpace(stashes) == "" { + continue + } + + fmt.Fprintln(w, repo.RelPath) + if strings.TrimSpace(out) != "" { + fmt.Fprintln(w, " Uncommitted changes:") + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + fmt.Fprintln(w, " "+line) + } + } + if strings.TrimSpace(stashes) != "" { + fmt.Fprintln(w, " Stashes:") + for _, line := range strings.Split(strings.TrimSpace(stashes), "\n") { + fmt.Fprintln(w, " "+line) + } + } + } + + return nil +} diff --git a/cmd_check_test.go b/cmd_check_test.go new file mode 100644 index 00000000..e0f608f0 --- /dev/null +++ b/cmd_check_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/x-motemen/ghq/cmdutil" +) + +func TestCommandCheck(t *testing.T) { + tmpdir := newTempDir(t) + t.Setenv("GHQ_ROOT", tmpdir) + + // Create a clean repository + cleanRepoPath := filepath.Join(tmpdir, "github.com", "user", "clean") + os.MkdirAll(cleanRepoPath, 0755) + cmdutil.Run("git", "-C", cleanRepoPath, "init") + cmdutil.Run("git", "-C", cleanRepoPath, "commit", "--allow-empty", "-m", "initial commit") + + // Create a repository with uncommitted changes + dirtyRepoPath := filepath.Join(tmpdir, "github.com", "user", "dirty") + os.MkdirAll(dirtyRepoPath, 0755) + cmdutil.Run("git", "-C", dirtyRepoPath, "init") + os.WriteFile(filepath.Join(dirtyRepoPath, "file.txt"), []byte("hello"), 0644) + + // Create a repository with a stash + stashedRepoPath := filepath.Join(tmpdir, "github.com", "user", "stashed") + os.MkdirAll(stashedRepoPath, 0755) + cmdutil.Run("git", "-C", stashedRepoPath, "init") + os.WriteFile(filepath.Join(stashedRepoPath, "file.txt"), []byte("hello"), 0644) + cmdutil.Run("git", "-C", stashedRepoPath, "add", "file.txt") + cmdutil.Run("git", "-C", stashedRepoPath, "stash") + + out, _, err := capture(func() { + newApp().Run([]string{"ghq", "check"}) + }) + + if err != nil { + t.Errorf("error should be nil, but: %v", err) + } + + if strings.Contains(out, "clean") { + t.Errorf("clean repository should not be listed") + } + + if !strings.Contains(out, "dirty") { + t.Errorf("dirty repository should be listed") + } + + if !strings.Contains(out, "stashed") { + t.Errorf("stashed repository should be listed") + } +} diff --git a/cmdutil/run.go b/cmdutil/run.go index 3656d78d..bd1ef2f2 100644 --- a/cmdutil/run.go +++ b/cmdutil/run.go @@ -48,6 +48,16 @@ func RunInDirSilently(dir, command string, args ...string) error { return RunCommand(cmd, true) } +// RunAndGetOutput runs the command and returns its output +func RunAndGetOutput(command string, args ...string) (string, error) { + cmd := exec.Command(command, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return "", &RunError{cmd, err} + } + return string(output), nil +} + // RunFunc for the type command execution type RunFunc func(*exec.Cmd) error diff --git a/commands.go b/commands.go index ba5f58a3..e0329acc 100644 --- a/commands.go +++ b/commands.go @@ -14,12 +14,13 @@ var commands = []*cli.Command{ commandRm, commandRoot, commandCreate, + commandCheck, } var commandGet = &cli.Command{ - Name: "get", + Name: "get", Aliases: []string{"clone"}, - Usage: "Clone/sync with a remote repository", + Usage: "Clone/sync with a remote repository", Description: ` Clone a repository under ghq root directory. If the repository is already cloned to local, nothing will happen unless '-u' ('--update') @@ -40,7 +41,7 @@ var commandGet = &cli.Command{ &cli.BoolFlag{Name: "parallel", Aliases: []string{"P"}, Usage: "Import parallelly"}, &cli.BoolFlag{Name: "bare", Usage: "Do a bare clone"}, &cli.StringFlag{ - Name: "partial", + Name: "partial", Usage: "Do a partial clone. Can specify either \"blobless\" or \"treeless\"", Action: func(ctx *cli.Context, v string) error { expected := []string{"blobless", "treeless"} @@ -53,7 +54,7 @@ var commandGet = &cli.Command{ } var commandList = &cli.Command{ - Name: "list", + Name: "list", Usage: "List local repositories", Description: ` List locally cloned repositories. If a query argument is given, only @@ -71,9 +72,15 @@ var commandList = &cli.Command{ }, } +var commandCheck = &cli.Command{ + Name: "check", + Usage: "Check for uncommitted changes, stashes, and untracked files", + Action: doCheck, +} + var commandRm = &cli.Command{ - Name: "rm", - Usage: "Remove local repository", + Name: "rm", + Usage: "Remove local repository", Action: doRm, Flags: []cli.Flag{ &cli.BoolFlag{Name: "dry-run", Usage: "Do not remove actually"}, @@ -81,8 +88,8 @@ var commandRm = &cli.Command{ } var commandRoot = &cli.Command{ - Name: "root", - Usage: "Show repositories' root", + Name: "root", + Usage: "Show repositories' root", Action: doRoot, Flags: []cli.Flag{ &cli.BoolFlag{Name: "all", Usage: "Show all roots"}, @@ -90,8 +97,8 @@ var commandRoot = &cli.Command{ } var commandCreate = &cli.Command{ - Name: "create", - Usage: "Create a new repository", + Name: "create", + Usage: "Create a new repository", Action: doCreate, Flags: []cli.Flag{ &cli.StringFlag{Name: "vcs", Usage: "Specify `vcs` backend explicitly"}, @@ -100,16 +107,17 @@ var commandCreate = &cli.Command{ } type commandDoc struct { - Parent string - Arguments string + Parent string + Arguments string } var commandDocs = map[string]commandDoc{ - "get": {"", "[-u] [-p] [--shallow] [--vcs ] [--look] [--silent] [--branch ] [--no-recursive] [--bare] [--partial blobless|treeless] ||/|//"}, - "list": {"", "[-p] [-e] []"}, + "get": {"", "[-u] [-p] [--shallow] [--vcs ] [--look] [--silent] [--branch ] [--no-recursive] [--bare] [--partial blobless|treeless] ||/|//"}, + "list": {"", "[-p] [-e] []"}, + "check": {"", ""}, "create": {"", "|/|//"}, - "rm": {"", "|/|//"}, - "root": {"", "[-all]"}, + "rm": {"", "|/|//"}, + "root": {"", "[-all]"}, } // Makes template conditionals to generate per-command documents.