Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions cmd/output.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"encoding/json"
"fmt"
"io"
"os"
)
Expand All @@ -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
}
188 changes: 188 additions & 0 deletions cmd/output_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
6 changes: 6 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 25 additions & 1 deletion cmd/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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
}
Expand All @@ -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)
Expand Down
Loading
Loading