From 75caa4ac6d31a0afb291e738ed5fb7624014cf6d Mon Sep 17 00:00:00 2001 From: samiralajmovic Date: Wed, 11 Feb 2026 07:01:15 +0100 Subject: [PATCH 1/2] Update to 0.32.0 --- Makefile | 2 +- core/mani.1 | 12 ++++++------ core/tui/views/tui_help.go | 2 +- docs/changelog.md | 7 ++++++- docs/commands.md | 22 ++++------------------ 5 files changed, 18 insertions(+), 27 deletions(-) diff --git a/Makefile b/Makefile index e94019f..274cb45 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ NAME := mani PACKAGE := github.com/alajmo/$(NAME) DATE := $(shell date +"%Y %B %d") GIT := $(shell [ -d .git ] && git rev-parse --short HEAD) -VERSION := v0.31.2 +VERSION := v0.32.0 default: build diff --git a/core/mani.1 b/core/mani.1 index 6057b64..c0802c0 100644 --- a/core/mani.1 +++ b/core/mani.1 @@ -1,4 +1,4 @@ -.TH "MANI" "1" "2025 December 05" "v0.31.2" "Mani Manual" "mani" +.TH "MANI" "1" "2026 February 10" "v0.32.0" "Mani Manual" "mani" .SH NAME mani - repositories manager and task runner @@ -6,10 +6,7 @@ mani - repositories manager and task runner .B mani [command] [flags] .SH DESCRIPTION -mani is a CLI tool that helps you manage multiple repositories. - -It's useful when you are working with microservices, multi-project systems, multiple libraries, or just a collection -of repositories and want a central place for pulling all repositories and running commands across them. +manage multiple repositories and run commands across them .SH OPTIONS .TP @@ -213,6 +210,9 @@ clone projects in parallel \fB-d, --paths=[]\fR clone projects by path .TP +\fB-w, --remove-orphaned-worktrees[=false]\fR +remove git worktrees not in config +.TP \fB-s, --status[=false]\fR display status only .TP @@ -257,7 +257,7 @@ select all projects select current working directory .TP \fB--headers=[project,tag,description]\fR -specify columns to display [project, path, relpath, description, url, tag] +specify columns to display [project, path, relpath, description, url, tag, worktree] .TP \fB-d, --paths=[]\fR select projects by paths diff --git a/core/tui/views/tui_help.go b/core/tui/views/tui_help.go index 10236f1..b2318e2 100644 --- a/core/tui/views/tui_help.go +++ b/core/tui/views/tui_help.go @@ -8,7 +8,7 @@ import ( "github.com/rivo/tview" ) -var Version = "v0.31.2" +var Version = "v0.32.0" func ShowHelpModal() { t, table := createShortcutsTable() diff --git a/docs/changelog.md b/docs/changelog.md index 9a68665..d125ceb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.32.0 ### Features @@ -11,6 +11,7 @@ - Worktrees can be inside or outside the parent project directory - Added `remove_orphaned_worktrees` config option to remove worktrees not in config - Added `--remove-orphaned-worktrees` / `-w` flag to `mani sync` +- Added `worktree` column option for `mani list projects` ### Fixes @@ -18,6 +19,10 @@ - Fixed TUI search/filter label showing raw color tags when using default theme - Fixed `mani init` to only add root directory as project when inside a git repo +### Misc + +- Added benchmarks for performance testing + ## 0.31.2 ### Fixes diff --git a/docs/commands.md b/docs/commands.md index 64bc487..cae87f7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,13 +4,6 @@ repositories manager and task runner -### Synopsis - -mani is a CLI tool that helps you manage multiple repositories. - -It's useful when you are working with microservices, multi-project systems, multiple libraries, or just a collection -of repositories and want a central place for pulling all repositories and running commands across them. - ### Options ``` @@ -144,9 +137,8 @@ Initialize a mani repository Initialize a mani repository. -Creates a mani.yaml configuration file in the current directory. When inside a git -repository, it also creates/updates .gitignore. When auto-discovery is enabled, -it finds Git repositories and their worktrees. +Creates a new mani repository by generating a mani.yaml configuration file +and a .gitignore file in the current directory. ``` init [flags] @@ -155,7 +147,7 @@ init [flags] ### Examples ``` - # Initialize with default settings (discovers repos and worktrees) + # Initialize with default settings mani init # Initialize without auto-discovering projects @@ -168,7 +160,7 @@ init [flags] ### Options ``` - --auto-discovery automatically discover and add Git repositories and worktrees to mani.yaml (default true) + --auto-discovery automatically discover and add Git repositories to mani.yaml (default true) -h, --help help for init -g, --sync-gitignore synchronize .gitignore file (default true) ``` @@ -202,12 +194,6 @@ sync [flags] # Sync project remotes. This will modify the projects .git state mani sync --sync-remotes - # Create worktrees defined in config (default behavior) - mani sync - - # Remove worktrees not defined in config - mani sync --remove-orphaned-worktrees - # Clone repositories even if project sync field is set to false mani sync --ignore-sync-state From b6276bc61be628afbf57b9b94d650a79b87a7e71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:53:44 +0000 Subject: [PATCH 2/2] Add JSON and YAML output format support for mani run/exec commands --- cmd/exec.go | 4 +- cmd/list.go | 4 +- cmd/list_projects.go | 55 +++++-- cmd/list_tags.go | 74 ++++++---- cmd/list_tasks.go | 53 +++++-- cmd/run.go | 4 +- core/dao/spec.go | 2 +- core/errors.go | 2 +- core/exec/exec.go | 20 +++ core/exec/json.go | 284 ++++++++++++++++++++++++++++++++++++ core/exec/json_test.go | 307 +++++++++++++++++++++++++++++++++++++++ core/print/print_list.go | 56 +++++++ docs/commands.md | 4 +- docs/config.md | 2 +- docs/output.md | 85 +++++++++++ 15 files changed, 885 insertions(+), 71 deletions(-) create mode 100644 core/exec/json.go create mode 100644 core/exec/json_test.go create mode 100644 core/print/print_list.go diff --git a/cmd/exec.go b/cmd/exec.go index f1c9153..05c8ae6 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -69,12 +69,12 @@ executed in each directory.`, cmd.Flags().BoolVarP(&runFlags.Cwd, "cwd", "k", false, "use current working directory") cmd.Flags().BoolVarP(&runFlags.All, "all", "a", false, "target all projects") - cmd.Flags().StringVarP(&runFlags.Output, "output", "o", "", "set output format [stream|table|markdown|html]") + cmd.Flags().StringVarP(&runFlags.Output, "output", "o", "", "set output format [stream|table|markdown|html|json|yaml]") err := cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } - valid := []string{"table", "markdown", "html"} + valid := []string{"stream", "table", "markdown", "html", "json", "yaml"} return valid, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) diff --git a/cmd/list.go b/cmd/list.go index 107b220..e858424 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -42,13 +42,13 @@ func listCmd(config *dao.Config, configErr *error) *cobra.Command { }) core.CheckIfError(err) - cmd.PersistentFlags().StringVarP(&listFlags.Output, "output", "o", "table", "set output format [table|markdown|html]") + cmd.PersistentFlags().StringVarP(&listFlags.Output, "output", "o", "table", "set output format [table|markdown|html|json|yaml]") err = cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } - valid := []string{"table", "markdown", "html"} + valid := []string{"table", "markdown", "html", "json", "yaml"} return valid, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) diff --git a/cmd/list_projects.go b/cmd/list_projects.go index cdf9fd3..fdf0a62 100644 --- a/cmd/list_projects.go +++ b/cmd/list_projects.go @@ -142,22 +142,47 @@ func listProjects( if len(projects) == 0 { fmt.Println("No matching projects found") - } else { - theme.Table.Border.Rows = core.Ptr(false) - theme.Table.Header.Format = core.Ptr("t") - - options := print.PrintTableOptions{ - Output: listFlags.Output, - Theme: *theme, - Tree: listFlags.Tree, - AutoWrap: true, - OmitEmptyRows: false, - OmitEmptyColumns: true, - Color: *theme.Color, + return + } + + // Handle JSON/YAML output + if listFlags.Output == "json" || listFlags.Output == "yaml" { + outputProjects := make([]print.ProjectOutput, len(projects)) + for i, p := range projects { + outputProjects[i] = print.ProjectOutput{ + Name: p.Name, + Path: p.Path, + RelPath: p.RelPath, + Description: p.Desc, + URL: p.URL, + Tags: p.Tags, + } + } + + if listFlags.Output == "json" { + err = print.PrintListJSON(outputProjects, os.Stdout) + } else { + err = print.PrintListYAML(outputProjects, os.Stdout) } + core.CheckIfError(err) + return + } - fmt.Println() - print.PrintTable(projects, options, projectFlags.Headers, []string{}, os.Stdout) - fmt.Println() + // Table/Markdown/HTML output + theme.Table.Border.Rows = core.Ptr(false) + theme.Table.Header.Format = core.Ptr("t") + + options := print.PrintTableOptions{ + Output: listFlags.Output, + Theme: *theme, + Tree: listFlags.Tree, + AutoWrap: true, + OmitEmptyRows: false, + OmitEmptyColumns: true, + Color: *theme.Color, } + + fmt.Println() + print.PrintTable(projects, options, projectFlags.Headers, []string{}, os.Stdout) + fmt.Println() } diff --git a/cmd/list_tags.go b/cmd/list_tags.go index e6dd85b..9747855 100644 --- a/cmd/list_tags.go +++ b/cmd/list_tags.go @@ -59,48 +59,62 @@ func listTags( theme, err := config.GetTheme(listFlags.Theme) core.CheckIfError(err) - theme.Table.Border.Rows = core.Ptr(false) - theme.Table.Header.Format = core.Ptr("t") - - options := print.PrintTableOptions{ - Output: listFlags.Output, - Theme: *theme, - Tree: listFlags.Tree, - AutoWrap: true, - OmitEmptyRows: false, - OmitEmptyColumns: true, - Color: *theme.Color, - } - allTags := config.GetTags() + var tagsToUse []string if len(args) > 0 { foundTags := core.Intersection(args, allTags) - // Could not find one of the provided tags if len(foundTags) != len(args) { core.CheckIfError(&core.TagNotFound{Tags: args}) } + tagsToUse = foundTags + } else { + tagsToUse = allTags + } - tags, err := config.GetTagAssocations(foundTags) - core.CheckIfError(err) + tags, err := config.GetTagAssocations(tagsToUse) + core.CheckIfError(err) - if len(tags) == 0 { - fmt.Println("No tags") - } else { - fmt.Println() - print.PrintTable(tags, options, tagFlags.Headers, []string{}, os.Stdout) - fmt.Println() + if len(tags) == 0 { + fmt.Println("No tags") + return + } + + // Handle JSON/YAML output + if listFlags.Output == "json" || listFlags.Output == "yaml" { + outputTags := make([]print.TagOutput, len(tags)) + for i, t := range tags { + outputTags[i] = print.TagOutput{ + Name: t.Name, + Projects: t.Projects, + } } - } else { - tags, err := config.GetTagAssocations(allTags) - core.CheckIfError(err) - if len(tags) == 0 { - fmt.Println("No tags") + + if listFlags.Output == "json" { + err = print.PrintListJSON(outputTags, os.Stdout) } else { - fmt.Println("") - print.PrintTable(tags, options, tagFlags.Headers, []string{}, os.Stdout) - fmt.Println("") + err = print.PrintListYAML(outputTags, os.Stdout) } + core.CheckIfError(err) + return + } + + // Table/Markdown/HTML output + theme.Table.Border.Rows = core.Ptr(false) + theme.Table.Header.Format = core.Ptr("t") + + options := print.PrintTableOptions{ + Output: listFlags.Output, + Theme: *theme, + Tree: listFlags.Tree, + AutoWrap: true, + OmitEmptyRows: false, + OmitEmptyColumns: true, + Color: *theme.Color, } + + fmt.Println() + print.PrintTable(tags, options, tagFlags.Headers, []string{}, os.Stdout) + fmt.Println() } diff --git a/cmd/list_tasks.go b/cmd/list_tasks.go index ea10c9c..31a5911 100644 --- a/cmd/list_tasks.go +++ b/cmd/list_tasks.go @@ -67,22 +67,45 @@ func listTasks( if len(tasks) == 0 { fmt.Println("No tasks") - } else { - theme.Table.Border.Rows = core.Ptr(false) - theme.Table.Header.Format = core.Ptr("t") - - options := print.PrintTableOptions{ - Output: listFlags.Output, - Theme: *theme, - Tree: listFlags.Tree, - AutoWrap: true, - OmitEmptyRows: false, - OmitEmptyColumns: true, - Color: *theme.Color, + return + } + + // Handle JSON/YAML output + if listFlags.Output == "json" || listFlags.Output == "yaml" { + outputTasks := make([]print.TaskOutput, len(tasks)) + for i, t := range tasks { + outputTasks[i] = print.TaskOutput{ + Name: t.Name, + Description: t.Desc, + Spec: t.SpecData.Name, + Target: t.TargetData.Name, + } + } + + if listFlags.Output == "json" { + err = print.PrintListJSON(outputTasks, os.Stdout) + } else { + err = print.PrintListYAML(outputTasks, os.Stdout) } + core.CheckIfError(err) + return + } - fmt.Println() - print.PrintTable(tasks, options, taskFlags.Headers, []string{}, os.Stdout) - fmt.Println() + // Table/Markdown/HTML output + theme.Table.Border.Rows = core.Ptr(false) + theme.Table.Header.Format = core.Ptr("t") + + options := print.PrintTableOptions{ + Output: listFlags.Output, + Theme: *theme, + Tree: listFlags.Tree, + AutoWrap: true, + OmitEmptyRows: false, + OmitEmptyColumns: true, + Color: *theme.Color, } + + fmt.Println() + print.PrintTable(tasks, options, taskFlags.Headers, []string{}, os.Stdout) + fmt.Println() } diff --git a/cmd/run.go b/cmd/run.go index ad7c953..0e948ef 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -93,13 +93,13 @@ The tasks are specified in a mani.yaml file along with the projects you can targ cmd.Flags().BoolVarP(&runFlags.Edit, "edit", "e", false, "edit task") cmd.Flags().Uint32P("forks", "f", 4, "maximum number of concurrent processes") - cmd.Flags().StringVarP(&runFlags.Output, "output", "o", "", "set output format [stream|table|markdown|html]") + cmd.Flags().StringVarP(&runFlags.Output, "output", "o", "", "set output format [stream|table|markdown|html|json|yaml]") err := cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } - valid := []string{"stream", "table", "html", "markdown"} + valid := []string{"stream", "table", "html", "markdown", "json", "yaml"} return valid, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) diff --git a/core/dao/spec.go b/core/dao/spec.go index 6c8d767..f96ac6f 100644 --- a/core/dao/spec.go +++ b/core/dao/spec.go @@ -52,7 +52,7 @@ func (c *Config) GetSpecList() ([]Spec, []ResourceErrors[Spec]) { } switch spec.Output { - case "", "table", "stream", "html", "markdown": + case "", "table", "stream", "html", "markdown", "json", "yaml": default: foundErrors = true specError := ResourceErrors[Spec]{ diff --git a/core/errors.go b/core/errors.go index 92f55df..ec3818b 100644 --- a/core/errors.go +++ b/core/errors.go @@ -121,7 +121,7 @@ type SpecOutputError struct { } func (c *SpecOutputError) Error() string { - return fmt.Sprintf("invalid output for spec `%s`, found `%s`, expected one of: stream, table, html, markdown", c.Name, c.Output) + return fmt.Sprintf("invalid output for spec `%s`, found `%s`, expected one of: stream, table, html, markdown, json, yaml", c.Name, c.Output) } type TargetNotFound struct { diff --git a/core/exec/exec.go b/core/exec/exec.go index e0fffe1..37c07a6 100644 --- a/core/exec/exec.go +++ b/core/exec/exec.go @@ -77,6 +77,26 @@ func (exec *Exec) Run( } print.PrintTable(data.Rows, options, data.Headers[0:1], data.Headers[1:], os.Stdout) fmt.Println("") + case "json": + isParallel := tasks[0].SpecData.Parallel + results := exec.JSON(runFlags, "json", os.Stdout) + // Only print collected results if not parallel (parallel streams immediately) + if !isParallel { + err = PrintJSON(results, os.Stdout) + if err != nil { + return err + } + } + case "yaml": + isParallel := tasks[0].SpecData.Parallel + results := exec.JSON(runFlags, "yaml", os.Stdout) + // Only print collected results if not parallel (parallel streams immediately) + if !isParallel { + err = PrintYAML(results, os.Stdout) + if err != nil { + return err + } + } default: exec.Text(runFlags.DryRun, os.Stdout, os.Stderr) } diff --git a/core/exec/json.go b/core/exec/json.go new file mode 100644 index 0000000..3740cd0 --- /dev/null +++ b/core/exec/json.go @@ -0,0 +1,284 @@ +package exec + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "strings" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/alajmo/mani/core" + "github.com/alajmo/mani/core/dao" +) + +// TaskResult represents the structured output for a single task execution +type TaskResult struct { + Project string `json:"project" yaml:"project"` + Tasks []string `json:"tasks" yaml:"tasks"` + Output []string `json:"output" yaml:"output"` + ExitCode int `json:"exit_code" yaml:"exit_code"` +} + +func (exec *Exec) JSON(runFlags *core.RunFlags, outputFormat string, writer io.Writer) []TaskResult { + task := exec.Tasks[0] + clients := exec.Clients + projects := exec.Projects + isParallel := task.SpecData.Parallel + + // Collect all unique task names + taskNames := make([]string, 0) + taskSet := make(map[string]bool) + for _, t := range exec.Tasks { + if !taskSet[t.Name] { + taskSet[t.Name] = true + taskNames = append(taskNames, t.Name) + } + } + + // No spinner for structured output formats - it would interfere with JSON/YAML parsing + // Just handle interrupt signal for clean exit + go func() { + sigchan := make(chan os.Signal, 1) + signal.Notify(sigchan, os.Interrupt) + <-sigchan + os.Exit(0) + }() + + results := make([]TaskResult, len(projects)) + var dataMutex = sync.RWMutex{} + var outputMutex = sync.Mutex{} + + // Initialize results with project names and all task names + for i, p := range projects { + results[i] = TaskResult{ + Project: p.Name, + Tasks: taskNames, + Output: []string{}, + ExitCode: 0, + } + } + + wg := core.NewSizedWaitGroup(task.SpecData.Forks) + for i, c := range clients { + wg.Add() + if isParallel { + go func(i int, c Client, wg *core.SizedWaitGroup) { + defer wg.Done() + _ = exec.JSONWork(i, runFlags.DryRun, results, &dataMutex) + // Stream output immediately in parallel mode + outputMutex.Lock() + var streamErr error + if outputFormat == "json" { + streamErr = PrintJSONStream(results[i], writer) + } else if outputFormat == "yaml" { + streamErr = PrintYAMLStream(results[i], writer) + } + if streamErr != nil { + fmt.Fprintf(os.Stderr, "%v", streamErr) + } + outputMutex.Unlock() + }(i, c, &wg) + } else { + func(i int, c Client, wg *core.SizedWaitGroup) { + defer wg.Done() + _ = exec.JSONWork(i, runFlags.DryRun, results, &dataMutex) + }(i, c, &wg) + } + } + wg.Wait() + + // Return results for non-parallel mode (they will be printed by caller) + // For parallel mode, results were already streamed + return results +} + +func (exec *Exec) JSONWork(rIndex int, dryRun bool, results []TaskResult, dataMutex *sync.RWMutex) error { + client := exec.Clients[rIndex] + task := exec.Tasks[rIndex] + + var output []string + var exitCode int + + for _, cmd := range task.Commands { + if cmd.TTY { + return ExecTTY(cmd.Cmd, cmd.EnvList) + } + + out, code, err := RunJSONCmd(JSONCmd{ + client: client, + dryRun: dryRun, + shell: cmd.ShellProgram, + env: cmd.EnvList, + cmd: cmd.Cmd, + cmdArr: cmd.CmdArg, + }) + + output = append(output, out...) + if code != 0 { + exitCode = code + } + + if err != nil && !task.SpecData.IgnoreErrors { + dataMutex.Lock() + results[rIndex].Output = output + results[rIndex].ExitCode = exitCode + dataMutex.Unlock() + return err + } + } + + if task.Cmd != "" { + if task.TTY { + return ExecTTY(task.Cmd, task.EnvList) + } + + out, code, err := RunJSONCmd(JSONCmd{ + client: client, + dryRun: dryRun, + shell: task.ShellProgram, + env: task.EnvList, + cmd: task.Cmd, + cmdArr: task.CmdArg, + }) + + output = append(output, out...) + if code != 0 { + exitCode = code + } + + if err != nil && !task.SpecData.IgnoreErrors { + dataMutex.Lock() + results[rIndex].Output = output + results[rIndex].ExitCode = exitCode + dataMutex.Unlock() + return err + } + } + + dataMutex.Lock() + results[rIndex].Output = output + results[rIndex].ExitCode = exitCode + dataMutex.Unlock() + + return nil +} + +type JSONCmd struct { + client Client + dryRun bool + shell string + env []string + cmd string + cmdArr []string +} + +func RunJSONCmd(j JSONCmd) ([]string, int, error) { + combinedEnvs := dao.MergeEnvs(j.client.Env, j.env) + + if j.dryRun { + return []string{j.cmd}, 0, nil + } + + err := j.client.Run(j.shell, combinedEnvs, j.cmdArr) + if err != nil { + return []string{}, 1, err + } + + var outputLines []string + var mu sync.Mutex + var wg sync.WaitGroup + + // Read STDOUT + wg.Add(1) + go func(client Client) { + defer wg.Done() + out, err := io.ReadAll(client.Stdout()) + if err != nil && err != io.EOF { + return + } + outStr := strings.TrimSuffix(string(out), "\n") + if outStr != "" { + lines := strings.Split(outStr, "\n") + mu.Lock() + outputLines = append(outputLines, lines...) + mu.Unlock() + } + }(j.client) + + // Read STDERR + wg.Add(1) + go func(client Client) { + defer wg.Done() + out, err := io.ReadAll(client.Stderr()) + if err != nil && err != io.EOF { + return + } + outStr := strings.TrimSuffix(string(out), "\n") + if outStr != "" { + lines := strings.Split(outStr, "\n") + mu.Lock() + outputLines = append(outputLines, lines...) + mu.Unlock() + } + }(j.client) + + wg.Wait() + + exitCode := 0 + if err := j.client.Wait(); err != nil { + exitCode = 1 + outputLines = append(outputLines, err.Error()) + return outputLines, exitCode, err + } + + return outputLines, exitCode, nil +} + +// PrintJSON outputs the results as JSON +func PrintJSON(results []TaskResult, writer io.Writer) error { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(results) +} + +// PrintJSONStream outputs each result as a single-line JSON object (for parallel/streaming) +func PrintJSONStream(result TaskResult, writer io.Writer) error { + data, err := json.Marshal(result) + if err != nil { + return err + } + fmt.Fprintf(writer, "%s\n", data) + return nil +} + +// PrintYAML outputs the results as YAML with document separators +func PrintYAML(results []TaskResult, writer io.Writer) error { + for i, result := range results { + // Add document separator before each document (except the first) + if i > 0 { + fmt.Fprintf(writer, "---\n") + } + encoder := yaml.NewEncoder(writer) + encoder.SetIndent(2) + if err := encoder.Encode(result); err != nil { + return err + } + encoder.Close() + } + return nil +} + +// PrintYAMLStream outputs a single result as a YAML document with separator (for parallel/streaming) +func PrintYAMLStream(result TaskResult, writer io.Writer) error { + fmt.Fprintf(writer, "---\n") + encoder := yaml.NewEncoder(writer) + encoder.SetIndent(2) + if err := encoder.Encode(result); err != nil { + return err + } + return encoder.Close() +} diff --git a/core/exec/json_test.go b/core/exec/json_test.go new file mode 100644 index 0000000..d154f17 --- /dev/null +++ b/core/exec/json_test.go @@ -0,0 +1,307 @@ +package exec + +import ( + "bytes" + "encoding/json" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPrintJSON(t *testing.T) { + tests := []struct { + name string + results []TaskResult + wantErr bool + validate func(t *testing.T, output []byte) + }{ + { + name: "single result", + results: []TaskResult{ + { + Project: "project1", + Tasks: []string{"task1"}, + Output: []string{"line1", "line2"}, + ExitCode: 0, + }, + }, + wantErr: false, + validate: func(t *testing.T, output []byte) { + var results []TaskResult + if err := json.Unmarshal(output, &results); err != nil { + t.Errorf("failed to unmarshal JSON: %v", err) + return + } + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + return + } + if results[0].Project != "project1" { + t.Errorf("expected project 'project1', got %q", results[0].Project) + } + if results[0].ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", results[0].ExitCode) + } + }, + }, + { + name: "multiple results", + results: []TaskResult{ + { + Project: "project1", + Tasks: []string{"task1"}, + Output: []string{"success"}, + ExitCode: 0, + }, + { + Project: "project2", + Tasks: []string{"task1"}, + Output: []string{"error", "failed"}, + ExitCode: 1, + }, + }, + wantErr: false, + validate: func(t *testing.T, output []byte) { + var results []TaskResult + if err := json.Unmarshal(output, &results); err != nil { + t.Errorf("failed to unmarshal JSON: %v", err) + return + } + if len(results) != 2 { + t.Errorf("expected 2 results, got %d", len(results)) + return + } + if results[1].ExitCode != 1 { + t.Errorf("expected exit code 1 for second result, got %d", results[1].ExitCode) + } + }, + }, + { + name: "multiple tasks", + results: []TaskResult{ + { + Project: "project1", + Tasks: []string{"echo", "pwd"}, + Output: []string{"hello", "/home/user"}, + ExitCode: 0, + }, + }, + wantErr: false, + validate: func(t *testing.T, output []byte) { + var results []TaskResult + if err := json.Unmarshal(output, &results); err != nil { + t.Errorf("failed to unmarshal JSON: %v", err) + return + } + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + return + } + if len(results[0].Tasks) != 2 { + t.Errorf("expected 2 tasks, got %d", len(results[0].Tasks)) + return + } + if results[0].Tasks[0] != "echo" || results[0].Tasks[1] != "pwd" { + t.Errorf("expected tasks [echo, pwd], got %v", results[0].Tasks) + } + }, + }, + { + name: "empty results", + results: []TaskResult{}, + wantErr: false, + validate: func(t *testing.T, output []byte) { + var results []TaskResult + if err := json.Unmarshal(output, &results); err != nil { + t.Errorf("failed to unmarshal JSON: %v", err) + return + } + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := PrintJSON(tt.results, &buf) + if (err != nil) != tt.wantErr { + t.Errorf("PrintJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.validate != nil { + tt.validate(t, buf.Bytes()) + } + }) + } +} + +func TestPrintYAML(t *testing.T) { + tests := []struct { + name string + results []TaskResult + wantErr bool + validate func(t *testing.T, output []byte) + }{ + { + name: "single result", + results: []TaskResult{ + { + Project: "project1", + Tasks: []string{"task1"}, + Output: []string{"line1", "line2"}, + ExitCode: 0, + }, + }, + wantErr: false, + validate: func(t *testing.T, output []byte) { + var result TaskResult + decoder := yaml.NewDecoder(bytes.NewReader(output)) + if err := decoder.Decode(&result); err != nil { + t.Errorf("failed to unmarshal YAML: %v", err) + return + } + if result.Project != "project1" { + t.Errorf("expected project 'project1', got %q", result.Project) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + }, + }, + { + name: "multiple results as YAML documents", + results: []TaskResult{ + { + Project: "project1", + Tasks: []string{"task1"}, + Output: []string{"success"}, + ExitCode: 0, + }, + { + Project: "project2", + Tasks: []string{"task1"}, + Output: []string{"error"}, + ExitCode: 1, + }, + }, + wantErr: false, + validate: func(t *testing.T, output []byte) { + var results []TaskResult + decoder := yaml.NewDecoder(bytes.NewReader(output)) + for { + var result TaskResult + if err := decoder.Decode(&result); err != nil { + break + } + results = append(results, result) + } + if len(results) != 2 { + t.Errorf("expected 2 results, got %d", len(results)) + return + } + if results[1].ExitCode != 1 { + t.Errorf("expected exit code 1 for second result, got %d", results[1].ExitCode) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := PrintYAML(tt.results, &buf) + if (err != nil) != tt.wantErr { + t.Errorf("PrintYAML() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.validate != nil { + tt.validate(t, buf.Bytes()) + } + }) + } +} + +func TestPrintJSONStream(t *testing.T) { + tests := []struct { + name string + result TaskResult + wantErr bool + }{ + { + name: "single result streamed", + result: TaskResult{ + Project: "project1", + Tasks: []string{"task1"}, + Output: []string{"output line"}, + ExitCode: 0, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := PrintJSONStream(tt.result, &buf) + if (err != nil) != tt.wantErr { + t.Errorf("PrintJSONStream() error = %v, wantErr %v", err, tt.wantErr) + return + } + // Check that output is a single line (streaming format) + output := buf.String() + if len(output) == 0 { + t.Error("expected non-empty output") + return + } + if output[len(output)-1] != '\n' { + t.Error("expected output to end with newline") + } + // Verify it's valid JSON + var result TaskResult + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Errorf("output is not valid JSON: %v", err) + } + }) + } +} + +func TestPrintYAMLStream(t *testing.T) { + tests := []struct { + name string + result TaskResult + wantErr bool + }{ + { + name: "single result streamed", + result: TaskResult{ + Project: "project1", + Tasks: []string{"task1"}, + Output: []string{"output line"}, + ExitCode: 0, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := PrintYAMLStream(tt.result, &buf) + if (err != nil) != tt.wantErr { + t.Errorf("PrintYAMLStream() error = %v, wantErr %v", err, tt.wantErr) + return + } + // Verify it's valid YAML + var result TaskResult + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Errorf("output is not valid YAML: %v", err) + } + if result.Project != tt.result.Project { + t.Errorf("expected project %q, got %q", tt.result.Project, result.Project) + } + }) + } +} diff --git a/core/print/print_list.go b/core/print/print_list.go new file mode 100644 index 0000000..f2aa2d9 --- /dev/null +++ b/core/print/print_list.go @@ -0,0 +1,56 @@ +package print + +import ( + "encoding/json" + "fmt" + "io" + + "gopkg.in/yaml.v3" +) + +// ProjectOutput represents a project in JSON/YAML output +type ProjectOutput struct { + Name string `json:"name" yaml:"name"` + Path string `json:"path" yaml:"path"` + RelPath string `json:"rel_path" yaml:"rel_path"` + Description string `json:"description" yaml:"description"` + URL string `json:"url" yaml:"url"` + Tags []string `json:"tags" yaml:"tags"` +} + +// TaskOutput represents a task in JSON/YAML output +type TaskOutput struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Spec string `json:"spec" yaml:"spec"` + Target string `json:"target" yaml:"target"` +} + +// TagOutput represents a tag in JSON/YAML output +type TagOutput struct { + Name string `json:"name" yaml:"name"` + Projects []string `json:"projects" yaml:"projects"` +} + +// PrintListJSON outputs a list as JSON +func PrintListJSON[T any](items []T, writer io.Writer) error { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(items) +} + +// PrintListYAML outputs a list as YAML with document separators +func PrintListYAML[T any](items []T, writer io.Writer) error { + for i, item := range items { + if i > 0 { + fmt.Fprintf(writer, "---\n") + } + encoder := yaml.NewEncoder(writer) + encoder.SetIndent(2) + if err := encoder.Encode(item); err != nil { + return err + } + encoder.Close() + } + return nil +} diff --git a/docs/commands.md b/docs/commands.md index cae87f7..b8769c8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -66,7 +66,7 @@ run --ignore-non-existing skip non-existing projects --omit-empty-columns hide empty columns in table output --omit-empty-rows hide empty rows in table output - -o, --output string set output format [stream|table|markdown|html] + -o, --output string set output format [stream|table|markdown|html|json|yaml] --parallel execute tasks in parallel across projects -d, --paths strings select projects by path -p, --projects strings select projects by name @@ -116,7 +116,7 @@ exec [flags] --ignore-non-existing ignore non-existing projects --omit-empty-columns omit empty columns in table output --omit-empty-rows omit empty rows in table output - -o, --output string set output format [stream|table|markdown|html] + -o, --output string set output format [stream|table|markdown|html|json|yaml] --parallel run tasks in parallel across projects -d, --paths strings select projects by path -p, --projects strings select projects by name diff --git a/docs/config.md b/docs/config.md index fb67273..3d1a8e8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -99,7 +99,7 @@ projects: specs: default: # Output format for task results - # Options: stream, table, html, markdown + # Options: stream, table, html, markdown, json, yaml output: stream # Enable parallel task execution diff --git a/docs/output.md b/docs/output.md index 4298777..61d52c8 100644 --- a/docs/output.md +++ b/docs/output.md @@ -58,6 +58,91 @@ The following output formats are available: | test-2 | world | bar | ``` +- **json** + ```json + [ + { + "project": "test", + "tasks": ["hello"], + "output": ["world"], + "exit_code": 0 + }, + { + "project": "test-2", + "tasks": ["hello"], + "output": ["world"], + "exit_code": 0 + } + ] + ``` + +- **yaml** + ```yaml + project: test + tasks: + - hello + output: + - world + exit_code: 0 + --- + project: test-2 + tasks: + - hello + output: + - world + exit_code: 0 + ``` + + YAML format outputs each result as a separate YAML document, making it suitable for streaming output and processing with tools like `yq`. + +## Structured Output Benefits + +The `json` and `yaml` output formats provide structured data that includes: + +- **project**: The name of the project the command was run on +- **tasks**: An array of task names that were executed +- **output**: An array of output lines from the command +- **exit_code**: The exit code of the command (0 for success, non-zero for failure) + +This is especially useful for: + +- Piping output to `jq` or `yq` for further processing +- Storing results in document databases or structured logs +- Capturing exit codes for each project when running commands across multiple repositories +- Building automation scripts that need to process the results programmatically + +## Streaming Output with Parallel Execution + +When running with `--parallel`, the `json` and `yaml` output formats use a streaming format where each result is output immediately as it completes: + +- **JSON streaming**: Each result is a single-line JSON object followed by a newline + ```bash + $ mani run hello --all --output json --parallel + {"project":"test","tasks":["hello"],"output":["world"],"exit_code":0} + {"project":"test-2","tasks":["hello"],"output":["world"],"exit_code":0} + ``` + +- **YAML streaming**: Each result is a separate YAML document (separated by `---`) + ```bash + $ mani run hello --all --output yaml --parallel + --- + project: test + tasks: + - hello + output: + - world + exit_code: 0 + --- + project: test-2 + tasks: + - hello + output: + - world + exit_code: 0 + ``` + +This streaming format is ideal for processing results as they complete without waiting for all tasks to finish. + ## Omit Empty Table Rows and Columns Omit empty outputs using `--omit-empty-rows` and `--omit-empty-columns` flags or task spec. Works for tables, markdown and html formats.