diff --git a/cmd/config.go b/cmd/config.go index 8d1fcf0..5bb649a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -241,7 +241,7 @@ func runConfigGet() error { return err } - if configOutputJSON { + if configOutputJSON || outputFormat() == "json" { // JSON output to stdout for machine consumption output := configJSONOutput{ Role: cfg.Role, @@ -279,7 +279,8 @@ func init() { configSetCmd.AddCommand(configSetPromptTemplateCmd) configSetCmd.AddCommand(configSetEnableEmojiCmd) - configGetCmd.Flags().BoolVar(&configOutputJSON, "json", false, "Output configuration in JSON format") + configGetCmd.Flags().BoolVar(&configOutputJSON, "json", false, "Output in JSON format (deprecated: use -o json)") + _ = configGetCmd.Flags().MarkDeprecated("json", "use -o json instead") configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configGetCmd) diff --git a/cmd/output.go b/cmd/output.go index 34d8ff4..2eda257 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -1,6 +1,8 @@ package cmd import ( + "encoding/json" + "fmt" "io" "os" ) @@ -22,3 +24,32 @@ func outWriter() io.Writer { func errWriter() io.Writer { return errWriterFunc() } + +type outputFormatFlag struct { + value string +} + +func (f *outputFormatFlag) String() string { return f.value } +func (f *outputFormatFlag) Set(s string) error { + if s != "text" && s != "json" { + return fmt.Errorf("must be text or json") + } + f.value = s + return nil +} +func (f *outputFormatFlag) Type() string { return "string" } + +var outputFlag = &outputFormatFlag{value: "text"} + +func outputFormat() string { + return outputFlag.value +} + +func printJSON(w io.Writer, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + _, err = w.Write(append(data, '\n')) + return err +} diff --git a/cmd/output_test.go b/cmd/output_test.go new file mode 100644 index 0000000..61bba5a --- /dev/null +++ b/cmd/output_test.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/samzong/gmc/internal/worktree" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOutputFormatFlag_RejectsInvalid(t *testing.T) { + f := &outputFormatFlag{value: "text"} + err := f.Set("xml") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be text or json") + assert.Equal(t, "text", f.String()) +} + +func TestOutputFormatFlag_AcceptsValid(t *testing.T) { + f := &outputFormatFlag{value: "text"} + require.NoError(t, f.Set("json")) + assert.Equal(t, "json", f.String()) + require.NoError(t, f.Set("text")) + assert.Equal(t, "text", f.String()) +} + +func TestOutputFormatFlag_Type(t *testing.T) { + f := &outputFormatFlag{value: "text"} + assert.Equal(t, "string", f.Type()) +} + +func TestPrintJSON_RoundTrip(t *testing.T) { + type sample struct { + Name string `json:"name"` + OK bool `json:"ok"` + } + + var buf bytes.Buffer + err := printJSON(&buf, sample{Name: "test", OK: true}) + require.NoError(t, err) + + var got sample + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "test", got.Name) + assert.True(t, got.OK) +} + +func TestPrintJSON_EmptySlice(t *testing.T) { + var buf bytes.Buffer + err := printJSON(&buf, []string{}) + require.NoError(t, err) + assert.Equal(t, "[]\n", buf.String()) +} + +func TestPrintJSON_NilSlice(t *testing.T) { + var buf bytes.Buffer + err := printJSON(&buf, []string(nil)) + require.NoError(t, err) + assert.Equal(t, "null\n", buf.String()) +} + +func withOutputFormat(t *testing.T, format string) { + t.Helper() + old := outputFlag.value + outputFlag.value = format + t.Cleanup(func() { outputFlag.value = old }) +} + +func withWriters(t *testing.T, out, errw io.Writer) { + t.Helper() + oldOut := outWriterFunc + oldErr := errWriterFunc + outWriterFunc = func() io.Writer { return out } + errWriterFunc = func() io.Writer { return errw } + t.Cleanup(func() { + outWriterFunc = oldOut + errWriterFunc = oldErr + }) +} + +func TestRunWorktreeList_JSON(t *testing.T) { + repoDir := initCmdTestRepo(t) + linkedWt := filepath.Join(t.TempDir(), "feature-wt") + runGitCmd(t, repoDir, "worktree", "add", "-b", "feature/json-test", linkedWt, "main") + + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + var out bytes.Buffer + withWriters(t, &out, io.Discard) + withOutputFormat(t, "json") + + client := worktree.NewClient(worktree.Options{}) + err = runWorktreeList(client) + require.NoError(t, err) + + var items []WorktreeJSON + require.NoError(t, json.Unmarshal(out.Bytes(), &items)) + assert.GreaterOrEqual(t, len(items), 1) + + var found bool + for _, item := range items { + if item.Branch == "feature/json-test" { + found = true + assert.NotEmpty(t, item.Commit, "JSON commit should be full hash") + assert.True(t, len(item.Commit) >= 40, "commit should be full hash, got %q", item.Commit) + assert.NotEmpty(t, item.Path) + assert.NotEmpty(t, item.Name) + break + } + } + assert.True(t, found, "expected feature/json-test in JSON output") +} + +func TestRunWorktreeDefault_JSON(t *testing.T) { + repoDir := initCmdTestRepo(t) + linkedWt := filepath.Join(t.TempDir(), "feature-wt") + runGitCmd(t, repoDir, "worktree", "add", "-b", "feature/default-json", linkedWt, "main") + + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + var out bytes.Buffer + withWriters(t, &out, io.Discard) + withOutputFormat(t, "json") + + client := worktree.NewClient(worktree.Options{}) + cmd := &cobra.Command{Use: "wt"} + err = runWorktreeDefault(client, cmd) + require.NoError(t, err) + + var items []WorktreeJSON + require.NoError(t, json.Unmarshal(out.Bytes(), &items)) + assert.GreaterOrEqual(t, len(items), 1) +} + +func TestRunWorktreeList_TextUnchanged(t *testing.T) { + repoDir := initCmdTestRepo(t) + linkedWt := filepath.Join(t.TempDir(), "feature-wt") + runGitCmd(t, repoDir, "worktree", "add", "-b", "feature/text-check", linkedWt, "main") + + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + var out bytes.Buffer + withWriters(t, &out, io.Discard) + withOutputFormat(t, "text") + + client := worktree.NewClient(worktree.Options{}) + err = runWorktreeList(client) + require.NoError(t, err) + + output := out.String() + assert.Contains(t, output, "NAME") + assert.Contains(t, output, "BRANCH") + assert.Contains(t, output, "feature/text-check") + assert.NotContains(t, output, `"name"`) +} + +func TestResolveWorktreeStatus(t *testing.T) { + repoDir := initCmdTestRepo(t) + + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + client := worktree.NewClient(worktree.Options{}) + root := getDisplayRoot(client) + + info := worktree.Info{Path: repoDir, Branch: "main", IsBare: true} + assert.Equal(t, "bare", resolveWorktreeStatus(client, root, info)) + + info = worktree.Info{Path: "/tmp/.claude/worktrees/foo", Branch: "feat"} + assert.Equal(t, "agent", resolveWorktreeStatus(client, root, info)) +} diff --git a/cmd/root.go b/cmd/root.go index 751d521..26ecb96 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,6 +59,8 @@ func init() { rootCmd.PersistentFlags().StringVar( &cfgFile, "config", "", "Config file (default: $XDG_CONFIG_HOME/gmc/config.yaml)") rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug output") + rootCmd.PersistentFlags().VarP(outputFlag, "output", "o", "Output format: text or json") + _ = rootCmd.RegisterFlagCompletionFunc("output", completeOutputFormat) rootCmd.Flags().BoolP("version", "V", false, "version for gmc") rootCmd.Flags().BoolVar(&noVerify, "no-verify", false, "Skip pre-commit hooks") @@ -225,6 +227,10 @@ func generateStdinMessage( return formattedMessage, nil } +func completeOutputFormat(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"text", "json"}, cobra.ShellCompDirectiveNoFileComp +} + func ensureConfiguredAndGetConfig( cfg *config.Config, in io.Reader, diff --git a/cmd/tag.go b/cmd/tag.go index 4eb3a1a..1558a84 100644 --- a/cmd/tag.go +++ b/cmd/tag.go @@ -52,6 +52,12 @@ func init() { rootCmd.AddCommand(tagCmd) } +type TagJSON struct { + Current string `json:"current"` + Suggested string `json:"suggested"` + Commits []string `json:"commits"` +} + func runTagCommand() error { gitClient := git.NewClient(git.Options{Verbose: verbose}) llmClient := llm.NewClient(llm.Options{Timeout: time.Duration(timeoutSeconds) * time.Second}) @@ -61,6 +67,9 @@ func runTagCommand() error { return err } if len(commits) == 0 { + if outputFormat() == "json" { + return printJSON(outWriter(), TagJSON{Current: lastTag}) + } printNoCommitsSinceLastTag(lastTag) return nil } @@ -70,12 +79,27 @@ func runTagCommand() error { return err } - printCommitSummary(displayTag, commits) + if outputFormat() != "json" { + printCommitSummary(displayTag, commits) + } finalVersion, finalReason, source, err := pickTagSuggestion(baseVersion, commits, llmClient) if err != nil { return err } + + if outputFormat() == "json" { + commitMsgs := make([]string, len(commits)) + for i, c := range commits { + commitMsgs[i] = c.Message + } + return printJSON(outWriter(), TagJSON{ + Current: lastTag, + Suggested: finalVersion.String(), + Commits: commitMsgs, + }) + } + fmt.Fprintf(outWriter(), "Suggested version (%s): %s\n", source, finalVersion.String()) if strings.TrimSpace(finalReason) != "" { fmt.Fprintf(outWriter(), "Reason: %s\n", finalReason) diff --git a/cmd/worktree.go b/cmd/worktree.go index 92bdc60..f050dad 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -223,19 +223,29 @@ func init() { rootCmd.AddCommand(wtCmd) } +type WorktreeJSON struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Commit string `json:"commit"` + Status string `json:"status"` +} + func runWorktreeDefault(wtClient *worktree.Client, _ *cobra.Command) error { worktrees, err := wtClient.List() if err != nil { return err } - // Filter out bare worktrees filtered := filterBareWorktrees(worktrees) + if outputFormat() == "json" { + return printWorktreeJSON(wtClient, filtered) + } + fmt.Fprintln(outWriter(), "Current Worktrees:") printWorktreeTable(wtClient, filtered) - // Show current location cwd, err := os.Getwd() if err == nil { for _, wt := range filtered { @@ -308,9 +318,12 @@ func runWorktreeList(wtClient *worktree.Client) error { return err } - // Filter out bare worktrees filtered := filterBareWorktrees(worktrees) + if outputFormat() == "json" { + return printWorktreeJSON(wtClient, filtered) + } + if len(filtered) == 0 { fmt.Fprintln(outWriter(), "No worktrees found.") return nil @@ -412,6 +425,17 @@ func displayWorktreeName(displayRoot string, wtPath string) string { return rel } +func resolveWorktreeStatus(wtClient *worktree.Client, root string, wt worktree.Info) string { + switch { + case wt.IsBare: + return "bare" + case isExternalWorktree(root, wt.Path), isAgentWorktree(wt.Path): + return "agent" + default: + return wtClient.GetWorktreeStatus(wt.Path) + } +} + func printWorktreeTable(wtClient *worktree.Client, worktrees []worktree.Info) { if len(worktrees) == 0 { return @@ -419,7 +443,6 @@ func printWorktreeTable(wtClient *worktree.Client, worktrees []worktree.Info) { root := getDisplayRoot(wtClient) - // Calculate column widths maxName := len("Name") maxBranch := len("Branch") for _, wt := range worktrees { @@ -432,30 +455,38 @@ func printWorktreeTable(wtClient *worktree.Client, worktrees []worktree.Info) { } } - // Add padding maxName += 2 maxBranch += 2 - // Print header fmt.Fprintf(outWriter(), "%-*s %-*s %-8s %s\n", maxName, "NAME", maxBranch, "BRANCH", "COMMIT", "STATUS") - // Print rows for _, wt := range worktrees { name := displayWorktreeName(root, wt.Path) shortCommit := stringsutil.ShortHash(wt.Commit, 7, "") - - status := wtClient.GetWorktreeStatus(wt.Path) - switch { - case wt.IsBare: - status = "bare" - case isExternalWorktree(root, wt.Path), isAgentWorktree(wt.Path): - status = "agent" - } - + status := resolveWorktreeStatus(wtClient, root, wt) fmt.Fprintf(outWriter(), "%-*s %-*s %-8s %s\n", maxName, name, maxBranch, wt.Branch, shortCommit, status) } } +func buildWorktreeJSON(wtClient *worktree.Client, worktrees []worktree.Info) []WorktreeJSON { + root := getDisplayRoot(wtClient) + result := make([]WorktreeJSON, 0, len(worktrees)) + for _, wt := range worktrees { + result = append(result, WorktreeJSON{ + Name: displayWorktreeName(root, wt.Path), + Path: wt.Path, + Branch: wt.Branch, + Commit: wt.Commit, + Status: resolveWorktreeStatus(wtClient, root, wt), + }) + } + return result +} + +func printWorktreeJSON(wtClient *worktree.Client, worktrees []worktree.Info) error { + return printJSON(outWriter(), buildWorktreeJSON(wtClient, worktrees)) +} + func runWorktreeDup(wtClient *worktree.Client, args []string) error { opts := worktree.DupOptions{ BaseBranch: wtBaseBranch, diff --git a/cmd/worktree_prune.go b/cmd/worktree_prune.go index 9c203b7..3179a37 100644 --- a/cmd/worktree_prune.go +++ b/cmd/worktree_prune.go @@ -24,13 +24,39 @@ By default it removes both the worktree directory and the local branch.`, }, } +type PruneJSON struct { + Name string `json:"name"` + Branch string `json:"branch"` + Status string `json:"status"` + Action string `json:"action"` +} + func runWorktreePrune(wtClient *worktree.Client) error { opts := worktree.PruneOptions{ BaseBranch: wtPruneBase, Force: wtPruneForce, DryRun: wtPruneDryRun, } - report, err := wtClient.Prune(opts) - printWorktreeReport(report) - return err + result, err := wtClient.Prune(opts) + if err != nil { + return err + } + if outputFormat() == "json" { + action := "removed" + if opts.DryRun { + action = "would-remove" + } + items := make([]PruneJSON, len(result.Candidates)) + for i, c := range result.Candidates { + items[i] = PruneJSON{ + Name: c.Name, + Branch: c.Branch, + Status: c.Status, + Action: action, + } + } + return printJSON(outWriter(), items) + } + printWorktreeReport(result.Report) + return nil } diff --git a/cmd/worktree_share.go b/cmd/worktree_share.go index 7be810c..64b6c67 100644 --- a/cmd/worktree_share.go +++ b/cmd/worktree_share.go @@ -73,6 +73,11 @@ var wtShareRemoveCmd = &cobra.Command{ }, } +type ShareJSON struct { + Path string `json:"path"` + Strategy string `json:"strategy"` +} + var wtShareListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -84,6 +89,14 @@ var wtShareListCmd = &cobra.Command{ return err } + if outputFormat() == "json" { + items := make([]ShareJSON, len(cfg.Resources)) + for i, res := range cfg.Resources { + items[i] = ShareJSON{Path: res.Path, Strategy: string(res.Strategy)} + } + return printJSON(outWriter(), items) + } + if len(cfg.Resources) == 0 { fmt.Println("No shared resources configured.") return nil diff --git a/internal/worktree/prune.go b/internal/worktree/prune.go index f1697b0..217945e 100644 --- a/internal/worktree/prune.go +++ b/internal/worktree/prune.go @@ -17,56 +17,66 @@ type PruneOptions struct { DryRun bool // Preview what would be removed without making changes } +type PruneCandidate struct { + Name string + Branch string + Status string +} + +type PruneResult struct { + Report + Candidates []PruneCandidate +} + // Prune removes worktrees whose branches are merged into the base branch. -func (c *Client) Prune(opts PruneOptions) (Report, error) { - var report Report +func (c *Client) Prune(opts PruneOptions) (PruneResult, error) { + var result PruneResult root, err := c.GetWorktreeRoot() if err != nil { - return report, fmt.Errorf("failed to find worktree root: %w", err) + return result, fmt.Errorf("failed to find worktree root: %w", err) } baseBranch, err := c.resolveBaseBranch(root, opts.BaseBranch) if err != nil { - return report, err + return result, err } baseBranchName := localBranchName(baseBranch) worktrees, err := c.List() if err != nil { - return report, err + return result, err } repoDir := repoDirForGit(root) - isBare := repoDir != root // bare layout: repoDir points to .bare, not root itself + isBare := repoDir != root var prunedAny bool for _, wt := range worktrees { if wt.IsBare || filepath.Base(wt.Path) == ".bare" || wt.Path == root { continue } - // In bare layout, skip worktrees outside the managed root directory if isBare && isExternalPath(root, wt.Path) { continue } name := filepath.Base(wt.Path) if wt.IsLocked { - report.Warn(fmt.Sprintf("Skipped %s: worktree is locked", name)) + result.Warn(fmt.Sprintf("Skipped %s: worktree is locked", name)) continue } if wt.Branch == "" || wt.Branch == "(detached)" { - report.Warn(fmt.Sprintf("Skipped %s: detached HEAD", name)) + result.Warn(fmt.Sprintf("Skipped %s: detached HEAD", name)) continue } if wt.Branch == baseBranchName { - report.Warn(fmt.Sprintf("Skipped %s: base branch '%s'", name, baseBranchName)) + result.Warn(fmt.Sprintf("Skipped %s: base branch '%s'", name, baseBranchName)) continue } merged, err := c.isBranchMerged(root, wt.Branch, baseBranch) if err != nil { - report.Warn(fmt.Sprintf("Skipped %s: %v", name, err)) + result.Warn(fmt.Sprintf("Skipped %s: %v", name, err)) continue } if !merged { @@ -75,15 +85,18 @@ func (c *Client) Prune(opts PruneOptions) (Report, error) { status := c.GetWorktreeStatus(wt.Path) if status == "modified" && !opts.Force { - report.Warn(fmt.Sprintf("Skipped %s: worktree has uncommitted changes (use --force)", name)) + result.Warn(fmt.Sprintf("Skipped %s: worktree has uncommitted changes (use --force)", name)) continue } + candidate := PruneCandidate{Name: name, Branch: wt.Branch, Status: status} + if opts.DryRun { - report.Warn("Would remove worktree: " + wt.Path) - report.Warn(" Branch: " + wt.Branch) - report.Warn(" Status: " + status) - report.Warn("Would delete branch: " + wt.Branch) + result.Warn("Would remove worktree: " + wt.Path) + result.Warn(" Branch: " + wt.Branch) + result.Warn(" Status: " + status) + result.Warn("Would delete branch: " + wt.Branch) + result.Candidates = append(result.Candidates, candidate) prunedAny = true continue } @@ -94,25 +107,26 @@ func (c *Client) Prune(opts PruneOptions) (Report, error) { } args = append(args, wt.Path) - result, err := c.runner.RunLogged(args...) + gitResult, err := c.runner.RunLogged(args...) if err != nil { - return report, gitutil.WrapGitError("failed to remove worktree", result, err) + return result, gitutil.WrapGitError("failed to remove worktree", gitResult, err) } - report.Warn(fmt.Sprintf("Removed worktree '%s'", name)) + result.Warn(fmt.Sprintf("Removed worktree '%s'", name)) - result, err = c.runner.RunLogged("-C", repoDir, "branch", "-D", wt.Branch) + gitResult, err = c.runner.RunLogged("-C", repoDir, "branch", "-D", wt.Branch) if err != nil { - return report, gitutil.WrapGitError("failed to delete branch", result, err) + return result, gitutil.WrapGitError("failed to delete branch", gitResult, err) } - report.Warn(fmt.Sprintf("Deleted branch '%s'", wt.Branch)) + result.Warn(fmt.Sprintf("Deleted branch '%s'", wt.Branch)) + result.Candidates = append(result.Candidates, candidate) prunedAny = true } if !prunedAny { - report.Warn("No worktrees pruned.") + result.Warn("No worktrees pruned.") } - return report, nil + return result, nil } func (c *Client) resolveBaseBranch(root string, override string) (string, error) {