From 2c9f24820eab47f933522edd6bba687944711d91 Mon Sep 17 00:00:00 2001 From: ramonzx6 Date: Mon, 10 Aug 2026 08:19:22 +0000 Subject: [PATCH 1/7] feat(cli): expand multi-repository workflows --- cmd/check.go | 232 +++++++++++ cmd/command_core_test.go | 155 +++++++ cmd/command_test_helpers_test.go | 63 +++ cmd/completion.go | 30 ++ cmd/doctor.go | 119 ++++++ cmd/exec.go | 434 +++++++++++++++++--- cmd/exec_command_test.go | 98 +++++ cmd/extended_command_test.go | 545 ++++++++++++++++++++++++ cmd/list.go | 151 +++++-- cmd/list_command_test.go | 177 ++++++++ cmd/options.go | 92 +++++ cmd/policy_command_test.go | 46 +++ cmd/root.go | 145 ++++++- cmd/snapshot.go | 365 ++++++++++++++++ cmd/snapshot_command_test.go | 202 +++++++++ cmd/status.go | 199 +++++++++ cmd/sync.go | 199 +++++++++ cmd/sync_completion_test.go | 96 +++++ cmd/utils.go | 139 +++---- cmd/utils_test.go | 104 ----- cmd/version.go | 41 +- go.mod | 2 +- internal/gitexec/gitexec.go | 249 +++++++++++ internal/gitexec/gitexec_test.go | 207 ++++++++++ internal/gitstatus/status.go | 289 +++++++++++++ internal/gitstatus/status_test.go | 228 ++++++++++ internal/repository/repository.go | 548 +++++++++++++++++++++++++ internal/repository/repository_test.go | 382 +++++++++++++++++ main.go | 8 +- 29 files changed, 5246 insertions(+), 299 deletions(-) create mode 100644 cmd/check.go create mode 100644 cmd/command_core_test.go create mode 100644 cmd/command_test_helpers_test.go create mode 100644 cmd/completion.go create mode 100644 cmd/doctor.go create mode 100644 cmd/exec_command_test.go create mode 100644 cmd/extended_command_test.go create mode 100644 cmd/list_command_test.go create mode 100644 cmd/options.go create mode 100644 cmd/policy_command_test.go create mode 100644 cmd/snapshot.go create mode 100644 cmd/snapshot_command_test.go create mode 100644 cmd/status.go create mode 100644 cmd/sync.go create mode 100644 cmd/sync_completion_test.go delete mode 100644 cmd/utils_test.go create mode 100644 internal/gitexec/gitexec.go create mode 100644 internal/gitexec/gitexec_test.go create mode 100644 internal/gitstatus/status.go create mode 100644 internal/gitstatus/status_test.go create mode 100644 internal/repository/repository.go create mode 100644 internal/repository/repository_test.go diff --git a/cmd/check.go b/cmd/check.go new file mode 100644 index 0000000..14e5892 --- /dev/null +++ b/cmd/check.go @@ -0,0 +1,232 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/ramonzx6/gitmulti/internal/gitstatus" + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +type checkResult struct { + Status gitstatus.State `json:"status"` + Violations []string `json:"violations,omitempty"` + FetchError string `json:"fetchError,omitempty"` +} + +type checkDocument struct { + SchemaVersion int `json:"schemaVersion"` + Results []checkResult `json:"results"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} + +func newCheckCommand(app App) *cobra.Command { + var scan scanFlags + var failOn []string + var quiet bool + var format string + var fetch bool + var jobs int + var timeout time.Duration + var noPrompt bool + var requireMatch bool + + command := &cobra.Command{ + Use: "check", + Short: "Enforce repository state policies", + Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if jobs < 1 { + return errorsForFlag("--jobs must be at least 1") + } + if timeout < 0 { + return errorsForFlag("--timeout cannot be negative") + } + runApp := app + if noPrompt { + runApp.Git = runApp.Git.WithEnv("GIT_TERMINAL_PROMPT=0") + } + if format != "table" && format != "json" && format != "ndjson" { + return errorsForFlag(fmt.Sprintf("unsupported check format %q", format)) + } + policies, err := normalizePolicies(failOn) + if err != nil { + return err + } + discovered, err := discoverRepositories(command.Context(), app, command, scan, quiet) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 && requireMatch { + return newExitError(1, "no Git repositories matched") + } + fetchFailures := 0 + fetchErrors := make(map[string]string) + if fetch && len(discovered.Repositories) > 0 { + if _, err := runApp.Git.LookPath(); err != nil { + return err + } + fetchRecords := executeAcross(command, runApp, discovered.Repositories, []string{"fetch", "--all", "--prune"}, jobs, timeout, false, "grouped", false) + for _, record := range fetchRecords { + if record.Error != "" { + fetchFailures++ + fetchErrors[record.Repository] = record.Error + if !quiet { + fmt.Fprintf(command.ErrOrStderr(), "%s: fetch failed: %s\n", record.Repository, record.Error) + } + } + } + } + + states := collectStatuses(command.Context(), runApp, discovered.Repositories, jobs) + results := make([]checkResult, 0, len(states)) + violationCount := 0 + for _, state := range states { + violations := evaluatePolicies(state, policies) + violationCount += len(violations) + results = append(results, checkResult{Status: state, Violations: violations, FetchError: fetchErrors[state.Name]}) + } + if !quiet { + if err := renderCheckResults(command, results, format, discovered.Warnings); err != nil { + return err + } + } + collectionFailures := statusErrorCount(states) + if fetchFailures > 0 { + return newExitError(1, "fetch failed in %d repositories", fetchFailures) + } + if collectionFailures > 0 { + if quiet { + return exitError{code: 1} + } + return newExitError(1, "check could not inspect %d repositories", collectionFailures) + } + if violationCount > 0 { + if quiet { + return exitError{code: 1} + } + return newExitError(1, "repository policy check failed with %d violations", violationCount) + } + return nil + }, + } + scan.bind(command) + command.Flags().StringSliceVar(&failOn, "fail-on", []string{"dirty", "diverged"}, "conditions that fail the check: dirty, unpushed, behind, diverged, detached, no-upstream") + command.Flags().BoolVarP(&quiet, "quiet", "q", false, "produce no output; use only the exit status") + command.Flags().StringVar(&format, "format", "table", "output format: table, json, or ndjson") + command.Flags().BoolVar(&fetch, "fetch", false, "fetch and prune remotes before checking") + command.Flags().IntVarP(&jobs, "jobs", "j", 4, "maximum concurrent Git processes") + command.Flags().DurationVar(&timeout, "timeout", 0, "per-repository fetch timeout") + command.Flags().BoolVar(&noPrompt, "no-prompt", false, "disable Git credential prompts for network operations") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} + +func normalizePolicies(values []string) ([]string, error) { + allowed := map[string]bool{ + "dirty": true, "unpushed": true, "behind": true, "diverged": true, + "detached": true, "no-upstream": true, + } + values = splitValues(values) + seen := make(map[string]bool) + policies := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ToLower(value) + if !allowed[value] { + return nil, errorsForFlag(fmt.Sprintf("unsupported check policy %q", value)) + } + if !seen[value] { + seen[value] = true + policies = append(policies, value) + } + } + sort.Strings(policies) + return policies, nil +} + +func evaluatePolicies(state gitstatus.State, policies []string) []string { + if state.Err != nil { + return nil + } + violations := make([]string, 0) + for _, policy := range policies { + violated := false + switch policy { + case "dirty": + violated = state.Dirty + case "unpushed": + violated = state.Ahead > 0 + case "behind": + violated = state.Behind > 0 + case "diverged": + violated = state.Ahead > 0 && state.Behind > 0 + case "detached": + violated = state.Detached + case "no-upstream": + violated = state.NoUpstream + } + if violated { + violations = append(violations, policy) + } + } + return violations +} + +func renderCheckResults(command *cobra.Command, results []checkResult, format string, diagnostics ...[]repository.Warning) error { + var warnings []repository.Warning + if len(diagnostics) > 0 { + warnings = diagnostics[0] + } + switch format { + case "json": + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(checkDocument{SchemaVersion: outputSchemaVersion, Results: results, Diagnostics: warnings}) + case "ndjson": + encoder := json.NewEncoder(command.OutOrStdout()) + for _, result := range results { + if err := encoder.Encode(struct { + Type string `json:"type"` + checkResult + }{Type: "check", checkResult: result}); err != nil { + return err + } + } + for _, warning := range warnings { + if err := encoder.Encode(struct { + Type string `json:"type"` + repository.Warning + }{Type: "diagnostic", Warning: warning}); err != nil { + return err + } + } + return nil + case "table": + if _, err := fmt.Fprintln(command.OutOrStdout(), "REPOSITORY\tRESULT\tVIOLATIONS"); err != nil { + return err + } + for _, result := range results { + outcome := "ok" + violations := strings.Join(result.Violations, ",") + if result.FetchError != "" { + outcome = "error" + violations = "fetch: " + result.FetchError + } else if result.Status.Err != nil { + outcome = "error" + violations = result.Status.Error + } else if len(result.Violations) > 0 { + outcome = "fail" + } + if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%s\n", result.Status.Name, outcome, violations); err != nil { + return err + } + } + return nil + default: + return errorsForFlag(fmt.Sprintf("unsupported check format %q", format)) + } +} diff --git a/cmd/command_core_test.go b/cmd/command_core_test.go new file mode 100644 index 0000000..f4f5973 --- /dev/null +++ b/cmd/command_core_test.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestRunReturnsTypedExitCodesAndPrintsOneError(t *testing.T) { + var stdout, stderr bytes.Buffer + app := discardApp() + app.Out = &stdout + app.Err = &stderr + code := Run(context.Background(), []string{"exec"}, app) + if code != 2 { + t.Fatalf("usage exit code = %d, want 2", code) + } + if got := strings.Count(stderr.String(), "Error:"); got != 1 { + t.Fatalf("stderr has %d error prefixes, want 1: %q", got, stderr.String()) + } + if strings.Contains(stderr.String(), "Usage:") { + t.Fatalf("runtime stderr unexpectedly includes usage: %q", stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = Run(context.Background(), []string{"list", "--require-match"}, app) + if code != 1 { + t.Fatalf("require-match exit code = %d, want 1", code) + } +} + +func TestParseGitCommandSupportsBothInvocationStyles(t *testing.T) { + tests := []struct { + name string + command string + args []string + want []string + }{ + { + name: "legacy command string", + command: `commit -m "hello world"`, + want: []string{"commit", "-m", "hello world"}, + }, + { + name: "positional arguments", + args: []string{"status", "--short"}, + want: []string{"status", "--short"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parseGitCommand(test.command, test.args) + if err != nil { + t.Fatalf("parseGitCommand returned error: %v", err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("parsed args = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestParseGitCommandRejectsEmptyMixedAndMalformedInput(t *testing.T) { + tests := []struct { + name string + command string + args []string + want string + }{ + {name: "empty", want: "Git command cannot be empty"}, + {name: "mixed", command: "status", args: []string{"log"}, want: "either --command or positional"}, + {name: "malformed quoting", command: `commit -m "unterminated`, want: "invalid Git command"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := parseGitCommand(test.command, test.args) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestNewRootCommandDoesNotLeakFlagsBetweenInstances(t *testing.T) { + var options []string + app := App{ + Discover: func(_ context.Context, opts repository.Options) (repository.Result, error) { + options = append(options, opts.Root) + return repository.Result{}, nil + }, + } + + first := runCLI(t, app, "list", "--path", "/first-root") + if first.err != nil { + t.Fatalf("first command returned error: %v", first.err) + } + second := runCLI(t, app, "list") + if second.err != nil { + t.Fatalf("second command returned error: %v", second.err) + } + if !reflect.DeepEqual(options, []string{"/first-root", "."}) { + t.Fatalf("discovery roots = %#v, want %#v", options, []string{"/first-root", "."}) + } +} + +func TestVersionJSONAndArguments(t *testing.T) { + result := runCLI(t, discardApp(), "version", "--json") + if result.err != nil { + t.Fatalf("version --json returned error: %v", result.err) + } + var got buildInfo + if err := json.Unmarshal([]byte(result.stdout), &got); err != nil { + t.Fatalf("version output is not JSON: %v\n%s", err, result.stdout) + } + if got.Version == "" || got.Commit == "" || got.BuildDate == "" { + t.Fatalf("version JSON omitted required fields: %#v", got) + } + + short := runCLI(t, discardApp(), "version", "--short") + if short.err != nil { + t.Fatalf("version --short returned error: %v", short.err) + } + if strings.TrimSpace(short.stdout) != got.Version { + t.Fatalf("short version = %q, JSON version = %q", strings.TrimSpace(short.stdout), got.Version) + } + + unexpected := runCLI(t, discardApp(), "version", "unexpected-argument") + if unexpected.err == nil { + t.Fatalf("unexpected version argument error = %v", unexpected.err) + } + + conflicting := runCLI(t, discardApp(), "version", "--short", "--json") + var coded interface{ ExitCode() int } + if conflicting.err == nil || !strings.Contains(conflicting.err.Error(), "short") || !strings.Contains(conflicting.err.Error(), "json") || !errors.As(conflicting.err, &coded) || coded.ExitCode() != 2 { + t.Fatalf("conflicting version flags error = %v", conflicting.err) + } +} + +func TestExitErrorExposesExitCode(t *testing.T) { + err := errorsForFlag("invalid flag value") + var coded interface{ ExitCode() int } + if !errors.As(err, &coded) { + t.Fatalf("error %v does not expose an exit code", err) + } + if coded.ExitCode() != 2 { + t.Fatalf("exit code = %d, want 2", coded.ExitCode()) + } +} diff --git a/cmd/command_test_helpers_test.go b/cmd/command_test_helpers_test.go new file mode 100644 index 0000000..d042810 --- /dev/null +++ b/cmd/command_test_helpers_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "path/filepath" + "testing" + + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type commandResult struct { + stdout string + stderr string + err error +} + +func runCLI(t *testing.T, app App, args ...string) commandResult { + t.Helper() + var stdout, stderr bytes.Buffer + app.Out = &stdout + app.Err = &stderr + if app.In == nil { + app.In = bytes.NewReader(nil) + } + root := NewRootCommand(app) + root.SetArgs(args) + err := root.Execute() + return commandResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func fixtureRepositories(root string, names ...string) repository.Result { + result := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + result.Repositories = append(result.Repositories, repository.Repository{ + Name: name, + Path: filepath.Join(root, filepath.FromSlash(name)), + }) + } + return result +} + +func appWithRepositories(result repository.Result) App { + return App{ + In: bytes.NewReader(nil), + Git: gitexec.Runner{Executable: "gitmulti-test-git-does-not-exist"}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return result, nil + }, + } +} + +func discardApp() App { + return App{ + In: bytes.NewReader(nil), + Out: io.Discard, + Err: io.Discard, + Git: gitexec.Runner{Executable: "gitmulti-test-git-does-not-exist"}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { return repository.Result{}, nil }, + } +} diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..70ea24a --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newCompletionCommand(_ App) *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: exactArgs(1), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + RunE: func(command *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return command.Root().GenBashCompletion(command.OutOrStdout()) + case "zsh": + return command.Root().GenZshCompletion(command.OutOrStdout()) + case "fish": + return command.Root().GenFishCompletion(command.OutOrStdout(), true) + case "powershell": + return command.Root().GenPowerShellCompletionWithDesc(command.OutOrStdout()) + default: + return errorsForFlag(fmt.Sprintf("unsupported shell %q", args[0])) + } + }, + } +} diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..87ca5d1 --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type doctorFinding struct { + Severity string `json:"severity"` + Check string `json:"check"` + Path string `json:"path,omitempty"` + Message string `json:"message"` +} + +type doctorDocument struct { + SchemaVersion int `json:"schemaVersion"` + Findings []doctorFinding `json:"findings"` +} + +func newDoctorCommand(app App) *cobra.Command { + var scan scanFlags + var format string + var includeStatus bool + var strict bool + var jobs int + + command := &cobra.Command{ + Use: "doctor", + Short: "Diagnose Git and repository discovery problems", + Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if format != "text" && format != "json" { + return errorsForFlag(fmt.Sprintf("unsupported doctor format %q", format)) + } + if jobs < 1 { + return errorsForFlag("--jobs must be at least 1") + } + findings := make([]doctorFinding, 0) + gitAvailable := true + gitPath, err := app.Git.LookPath() + if err != nil { + gitAvailable = false + findings = append(findings, doctorFinding{Severity: "error", Check: "git", Message: err.Error()}) + } else { + version := app.Git.Run(command.Context(), "", []string{"--version"}, nil) + if version.Err != nil { + findings = append(findings, doctorFinding{Severity: "error", Check: "git", Path: gitPath, Message: version.Err.Error()}) + } else { + findings = append(findings, doctorFinding{Severity: "info", Check: "git", Path: gitPath, Message: strings.TrimSpace(version.Stdout)}) + } + } + + scan.validate = gitAvailable + discovered, discoverErr := discoverRepositories(command.Context(), app, command, scan) + if discoverErr != nil { + findings = append(findings, doctorFinding{Severity: "error", Check: "discovery", Path: scan.path, Message: discoverErr.Error()}) + } else { + findings = append(findings, doctorFinding{Severity: "info", Check: "discovery", Path: scan.path, Message: fmt.Sprintf("found %d repositories", len(discovered.Repositories))}) + for _, warning := range discovered.Warnings { + findings = append(findings, doctorFinding{Severity: "warning", Check: "discovery", Path: warning.Path, Message: warning.Message}) + } + if includeStatus && gitAvailable { + states := collectStatuses(command.Context(), app, discovered.Repositories, jobs) + for _, state := range states { + switch { + case state.Err != nil: + findings = append(findings, doctorFinding{Severity: "error", Check: "status", Path: state.Path, Message: state.Error}) + case state.Detached: + findings = append(findings, doctorFinding{Severity: "warning", Check: "status", Path: state.Path, Message: "detached HEAD"}) + case state.NoUpstream: + findings = append(findings, doctorFinding{Severity: "warning", Check: "status", Path: state.Path, Message: "branch has no upstream"}) + } + } + } + } + + if err := renderDoctor(command, findings, format); err != nil { + return err + } + failed := 0 + for _, finding := range findings { + if finding.Severity == "error" || strict && finding.Severity == "warning" { + failed++ + } + } + if failed > 0 { + return newExitError(1, "doctor found %d problems", failed) + } + return nil + }, + } + scan.bind(command) + command.Flags().StringVar(&format, "format", "text", "output format: text or json") + command.Flags().BoolVar(&includeStatus, "status", false, "check detached HEAD and upstream state") + command.Flags().BoolVar(&strict, "strict", false, "treat warnings as failures") + command.Flags().IntVarP(&jobs, "jobs", "j", 4, "maximum concurrent status checks") + return command +} + +func renderDoctor(command *cobra.Command, findings []doctorFinding, format string) error { + if format == "json" { + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(doctorDocument{SchemaVersion: outputSchemaVersion, Findings: findings}) + } + for _, finding := range findings { + location := "" + if finding.Path != "" { + location = " (" + finding.Path + ")" + } + if _, err := fmt.Fprintf(command.OutOrStdout(), "%s %s%s: %s\n", strings.ToUpper(finding.Severity), finding.Check, location, finding.Message); err != nil { + return err + } + } + return nil +} diff --git a/cmd/exec.go b/cmd/exec.go index ced6417..7cf7d4a 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -1,74 +1,414 @@ package cmd import ( + "bytes" + "context" + "encoding/json" + "errors" "fmt" + "io" + "strconv" "strings" + "sync" + "time" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -var execCmd = &cobra.Command{ - Use: "exec [flags] [-- ]", - Aliases: []string{"ex"}, - Args: cobra.ArbitraryArgs, - Short: "execute a git command on repositories inside the specified directory (default: current directory)", - Long: "execute a git command on repositories inside the specified directory (default: current directory)", - RunE: func(cmd *cobra.Command, args []string) error { - gitArgs, err := parseGitCommand(execCommand, args) - if err != nil { - return err - } +type execRecord struct { + Repository string `json:"repository"` + Path string `json:"path"` + Command []string `json:"command"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + ExitCode int `json:"exitCode"` + Duration time.Duration `json:"duration"` + Error string `json:"error,omitempty"` + Skipped bool `json:"skipped,omitempty"` +} - if execPath == "." { - fmt.Println("\nthese are the repositories inside the current directory:") - } else { - fmt.Println("\nthese are the repositories inside the specified directory:") - } - fmt.Println() +type execDocument struct { + SchemaVersion int `json:"schemaVersion"` + Results []execRecord `json:"results"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} - repositories, err := findRepositories(execPath, execRecursive) - if err != nil { - return err - } +func newExecCommand(app App) *cobra.Command { + var scan scanFlags + var commandString string + var jobs int + var failFast bool + var timeout time.Duration + var dryRun bool + var output string + var interactive bool + var quiet bool + var requireMatch bool + + command := &cobra.Command{ + Use: "exec [flags] -- ", + Aliases: []string{"ex"}, + Short: "Execute a Git command across repositories", + Args: cobra.ArbitraryArgs, + RunE: func(command *cobra.Command, args []string) error { + gitArgs, err := parseGitCommand(commandString, args) + if err != nil { + return errorsForFlag(err.Error()) + } + if jobs < 1 { + return errorsForFlag("--jobs must be at least 1") + } + if timeout < 0 { + return errorsForFlag("--timeout cannot be negative") + } + if interactive { + if jobs != 1 { + return errorsForFlag("--interactive requires --jobs=1") + } + output = "raw" + } + if !validExecOutput(output) { + return errorsForFlag(fmt.Sprintf("unsupported output mode %q", output)) + } + if output == "raw" && jobs != 1 { + return errorsForFlag("raw output requires --jobs=1") + } - if len(repositories) == 0 { - fmt.Println("no git repositories found") + discovered, err := discoverRepositories(command.Context(), app, command, scan) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 { + if requireMatch { + return newExitError(1, "no Git repositories matched") + } + return nil + } + + if dryRun { + for _, repo := range discovered.Repositories { + if _, err := fmt.Fprintln(command.OutOrStdout(), displayCommand(repo.Path, gitArgs)); err != nil { + return err + } + } + return nil + } + if _, err := app.Git.LookPath(); err != nil { + return err + } + + records := executeAcross(command, app, discovered.Repositories, gitArgs, jobs, timeout, failFast, output, interactive) + if err := renderExecRecords(command, records, output, quiet, discovered.Warnings); err != nil { + return err + } + failures := 0 + skipped := 0 + for _, record := range records { + if record.Skipped { + skipped++ + } else if record.Error != "" { + failures++ + } + } + if failures > 0 || skipped > 0 { + return newExitError(1, "Git command failed in %d and skipped %d of %d repositories", failures, skipped, len(records)) + } return nil - } + }, + } + scan.bind(command) + command.Flags().StringVarP(&commandString, "command", "c", "", "Git command string (deprecated; use positional arguments after --)") + _ = command.Flags().MarkDeprecated("command", "use positional Git arguments after --") + command.Flags().IntVarP(&jobs, "jobs", "j", 1, "maximum concurrent Git processes") + command.Flags().BoolVar(&failFast, "fail-fast", false, "cancel remaining work after the first failure") + command.Flags().DurationVar(&timeout, "timeout", 0, "per-repository command timeout") + command.Flags().BoolVar(&dryRun, "dry-run", false, "print commands without executing them") + command.Flags().StringVar(&output, "output", "grouped", "output mode: grouped, prefixed, raw, json, or ndjson") + command.Flags().BoolVar(&interactive, "interactive", false, "inherit stdin and use raw sequential output") + command.Flags().BoolVarP(&quiet, "quiet", "q", false, "suppress repository headings") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} - failures := []string{} - for _, repo := range repositories { - fmt.Println("executing git command on " + repo.Name) +func errorsForFlag(message string) error { return newExitError(2, "%s", message) } + +func validExecOutput(output string) bool { + switch output { + case "grouped", "prefixed", "raw", "json", "ndjson": + return true + default: + return false + } +} - output, err := executeGitCommand(repo.Path, gitArgs) - if output != "" { - fmt.Print(output) - if !strings.HasSuffix(output, "\n") { - fmt.Println() +func executeAcross(command *cobra.Command, app App, repositories []repository.Repository, args []string, jobs int, timeout time.Duration, failFast bool, output string, interactive bool) []execRecord { + ctx, cancel := context.WithCancel(command.Context()) + defer cancel() + records := make([]execRecord, len(repositories)) + if len(repositories) == 0 { + return records + } + workerCount := jobs + if workerCount < 1 { + workerCount = 1 + } + if workerCount > len(repositories) { + workerCount = len(repositories) + } + indices := make(chan int) + var wait sync.WaitGroup + var outputMu sync.Mutex + for worker := 0; worker < workerCount; worker++ { + wait.Add(1) + go func() { + defer wait.Done() + for index := range indices { + repo := repositories[index] + record := execRecord{Repository: repo.Name, Path: repo.Path, Command: append([]string(nil), args...)} + runCtx := ctx + var timeoutCancel context.CancelFunc + if timeout > 0 { + runCtx, timeoutCancel = context.WithTimeout(ctx, timeout) } + var result gitexec.Result + if output == "raw" { + var input io.Reader + if interactive { + input = command.InOrStdin() + } + result = app.Git.Stream(runCtx, repo.Path, args, input, command.OutOrStdout(), command.ErrOrStderr()) + } else if output == "prefixed" { + stdout := newLinePrefixWriter(command.OutOrStdout(), repo.Name, &outputMu) + stderr := newLinePrefixWriter(command.ErrOrStderr(), repo.Name, &outputMu) + result = app.Git.Stream(runCtx, repo.Path, args, nil, stdout, stderr) + if flushErr := stdout.Flush(); flushErr != nil && result.Err == nil { + result.Err = flushErr + result.ExitCode = -1 + } + if flushErr := stderr.Flush(); flushErr != nil && result.Err == nil { + result.Err = flushErr + result.ExitCode = -1 + } + } else { + result = app.Git.Run(runCtx, repo.Path, args, nil) + } + if timeoutCancel != nil { + timeoutCancel() + } + record.Stdout = result.Stdout + record.Stderr = result.Stderr + record.ExitCode = result.ExitCode + record.Duration = result.Duration + if result.Err != nil { + record.Error = result.Err.Error() + if failFast && errors.Is(result.Err, context.Canceled) { + record.Skipped = true + } + if failFast { + cancel() + } + } + records[index] = record } - if err != nil { - fmt.Printf("failed to execute git command on %s: %v\n", repo.Name, err) - failures = append(failures, repo.Name) + }() + } + for index := range repositories { + if failFast && ctx.Err() != nil { + records[index] = execRecord{Repository: repositories[index].Name, Path: repositories[index].Path, Command: append([]string(nil), args...), ExitCode: -1, Error: "not started after fail-fast", Skipped: true} + continue + } + indices <- index + } + close(indices) + wait.Wait() + return records +} + +func renderExecRecords(command *cobra.Command, records []execRecord, output string, quiet bool, diagnostics ...[]repository.Warning) error { + var warnings []repository.Warning + if len(diagnostics) > 0 { + warnings = diagnostics[0] + } + switch output { + case "raw": + return nil + case "json": + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(execDocument{SchemaVersion: outputSchemaVersion, Results: records, Diagnostics: warnings}) + case "ndjson": + encoder := json.NewEncoder(command.OutOrStdout()) + for _, record := range records { + if err := encoder.Encode(struct { + Type string `json:"type"` + execRecord + }{Type: "execution", execRecord: record}); err != nil { + return err + } + } + for _, warning := range warnings { + if err := encoder.Encode(struct { + Type string `json:"type"` + repository.Warning + }{Type: "diagnostic", Warning: warning}); err != nil { + return err } + } + return nil + case "prefixed": + for _, record := range records { + if err := writePrefixed(command.OutOrStdout(), record.Repository, record.Stdout); err != nil { + return err + } + if err := writePrefixed(command.ErrOrStderr(), record.Repository, record.Stderr); err != nil { + return err + } + if record.Error != "" { + if _, err := fmt.Fprintf(command.ErrOrStderr(), "[%s] error: %s\n", record.Repository, record.Error); err != nil { + return err + } + } + } + return nil + case "grouped": + for _, record := range records { + if !quiet { + if _, err := fmt.Fprintf(command.OutOrStdout(), "== %s ==\n", record.Repository); err != nil { + return err + } + } + if _, err := io.WriteString(command.OutOrStdout(), record.Stdout); err != nil { + return err + } + if _, err := io.WriteString(command.ErrOrStderr(), record.Stderr); err != nil { + return err + } + if record.Error != "" { + if _, err := fmt.Fprintf(command.ErrOrStderr(), "%s: %s\n", record.Repository, record.Error); err != nil { + return err + } + } + } + return nil + default: + return errorsForFlag(fmt.Sprintf("unsupported output mode %q", output)) + } +} - fmt.Println() - fmt.Println("--------------------------------") - fmt.Println() +func writePrefixed(writer io.Writer, prefix, value string) error { + if value == "" { + return nil + } + lines := strings.SplitAfter(value, "\n") + for _, line := range lines { + if line == "" { + continue + } + if _, err := fmt.Fprintf(writer, "[%s] %s", prefix, line); err != nil { + return err + } + if !strings.HasSuffix(line, "\n") { + if _, err := fmt.Fprintln(writer); err != nil { + return err + } } + } + return nil +} + +type linePrefixWriter struct { + writer io.Writer + prefix string + shared *sync.Mutex + pending bytes.Buffer + lineAtStart bool +} + +const linePrefixChunkSize = 64 * 1024 + +func newLinePrefixWriter(writer io.Writer, label string, shared *sync.Mutex) *linePrefixWriter { + quoted := strconv.QuoteToASCII(label) + if len(quoted) >= 2 { + quoted = quoted[1 : len(quoted)-1] + } + return &linePrefixWriter{writer: writer, prefix: "[" + quoted + "] ", shared: shared, lineAtStart: true} +} - if len(failures) > 0 { - return fmt.Errorf("git command failed in %d repositories: %v", len(failures), failures) +func (writer *linePrefixWriter) Write(data []byte) (int, error) { + written := 0 + for len(data) > 0 { + if writer.pending.Len() >= linePrefixChunkSize { + if err := writer.drain(false); err != nil { + return written, err + } + } + space := linePrefixChunkSize - writer.pending.Len() + if space > len(data) { + space = len(data) + } + if _, err := writer.pending.Write(data[:space]); err != nil { + return written, err } + written += space + data = data[space:] + if err := writer.drain(false); err != nil { + return written, err + } + } + return written, nil +} +func (writer *linePrefixWriter) Flush() error { + return writer.drain(true) +} + +func (writer *linePrefixWriter) drain(flush bool) error { + for { + buffered := writer.pending.Bytes() + newline := bytes.IndexByte(buffered, '\n') + if newline >= 0 { + line := append([]byte(nil), buffered[:newline+1]...) + writer.pending.Next(newline + 1) + if err := writer.writeChunk(line, writer.lineAtStart); err != nil { + return err + } + writer.lineAtStart = true + continue + } + if writer.pending.Len() >= linePrefixChunkSize { + chunk := append([]byte(nil), buffered[:linePrefixChunkSize]...) + writer.pending.Next(linePrefixChunkSize) + if err := writer.writeChunk(chunk, writer.lineAtStart); err != nil { + return err + } + writer.lineAtStart = false + continue + } + if flush && writer.pending.Len() > 0 { + chunk := append([]byte(nil), buffered...) + writer.pending.Reset() + chunk = append(chunk, '\n') + if err := writer.writeChunk(chunk, writer.lineAtStart); err != nil { + return err + } + writer.lineAtStart = true + } return nil - }, + } } -func init() { - execCmd.Flags().StringVarP(&execPath, "path", "p", ".", "path to scan for repositories") - execCmd.Flags().BoolVarP(&execRecursive, "recursive", "r", false, "scan repositories recursively") - execCmd.Flags().StringVarP(&execCommand, "command", "c", "", "git command to execute") - execCmd.MarkFlagRequired("command") - rootCmd.AddCommand(execCmd) +func (writer *linePrefixWriter) writeChunk(chunk []byte, atLineStart bool) error { + if writer.shared != nil { + writer.shared.Lock() + defer writer.shared.Unlock() + } + if atLineStart { + if _, err := io.WriteString(writer.writer, writer.prefix); err != nil { + return err + } + } + _, err := writer.writer.Write(chunk) + return err } diff --git a/cmd/exec_command_test.go b/cmd/exec_command_test.go new file mode 100644 index 0000000..f82e48c --- /dev/null +++ b/cmd/exec_command_test.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestExecAcceptsPositionalGitArgumentsWithoutExecutingGit(t *testing.T) { + root := t.TempDir() + fixture := fixtureRepositories(root, "repo") + result := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run", "--", "status", "--short") + if result.err != nil { + t.Fatalf("positional exec returned error: %v", result.err) + } + want := "git -C " + quoteForTest(filepath.Join(root, "repo")) + " \"status\" \"--short\"\n" + if result.stdout != want { + t.Fatalf("dry-run output = %q, want %q", result.stdout, want) + } +} + +func TestExecSupportsLegacyCommandStringAndRejectsMissingOrMixedStyles(t *testing.T) { + fixture := fixtureRepositories(t.TempDir(), "repo") + legacy := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run", "--command", `status --short`) + if legacy.err != nil { + t.Fatalf("legacy -c exec returned error: %v", legacy.err) + } + if !strings.Contains(legacy.stdout, `"status" "--short"`) { + t.Fatalf("legacy dry-run output = %q, want parsed arguments", legacy.stdout) + } + + missing := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run") + if missing.err == nil || !strings.Contains(missing.err.Error(), "Git command cannot be empty") { + t.Fatalf("missing command error = %v", missing.err) + } + + mixed := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run", "--command", "status", "--", "log") + if mixed.err == nil || !strings.Contains(mixed.err.Error(), "either --command or positional") { + t.Fatalf("mixed command styles error = %v", mixed.err) + } +} + +func TestExecDryRunDoesNotRequireGitExecutable(t *testing.T) { + fixture := fixtureRepositories(t.TempDir(), "repo") + app := appWithRepositories(fixture) + app.Git.Executable = "definitely-not-installed-gitmulti-test-git" + result := runCLI(t, app, "exec", "--dry-run", "--", "status") + if result.err != nil { + t.Fatalf("dry-run unexpectedly resolved Git: %v", result.err) + } +} + +func TestExecValidatesExecutionFlags(t *testing.T) { + fixture := fixtureRepositories(t.TempDir(), "repo") + tests := []struct { + name string + args []string + want string + }{ + {name: "zero jobs", args: []string{"exec", "--jobs", "0", "--dry-run", "--", "status"}, want: "--jobs must be at least 1"}, + {name: "negative timeout", args: []string{"exec", "--timeout=-1s", "--dry-run", "--", "status"}, want: "--timeout cannot be negative"}, + {name: "interactive parallel", args: []string{"exec", "--interactive", "--jobs", "2", "--dry-run", "--", "status"}, want: "--interactive requires --jobs=1"}, + {name: "raw parallel", args: []string{"exec", "--output", "raw", "--jobs", "2", "--dry-run", "--", "status"}, want: "raw output requires --jobs=1"}, + {name: "invalid output", args: []string{"exec", "--output", "invalid", "--dry-run", "--", "status"}, want: "unsupported output mode"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := runCLI(t, appWithRepositories(fixture), test.args...) + if result.err == nil || !strings.Contains(result.err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", result.err, test.want) + } + var coded interface{ ExitCode() int } + if !errors.As(result.err, &coded) || coded.ExitCode() != 2 { + t.Fatalf("error = %v, want usage exit code 2", result.err) + } + }) + } +} + +func TestExecRequiresMatchWhenRequested(t *testing.T) { + result := runCLI(t, appWithRepositories(repository.Result{}), "exec", "--dry-run", "--require-match", "--", "status") + if result.err == nil || !strings.Contains(result.err.Error(), "no Git repositories matched") { + t.Fatalf("require-match error = %v", result.err) + } + var coded interface{ ExitCode() int } + if !errors.As(result.err, &coded) { + t.Fatalf("require-match error = %v, want an exit code", result.err) + } +} + +func quoteForTest(value string) string { + // displayCommand uses strconv.Quote, whose output is deterministic for + // paths produced by TempDir on every supported platform. + return `"` + strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `"`, `\"`) + `"` +} diff --git a/cmd/extended_command_test.go b/cmd/extended_command_test.go new file mode 100644 index 0000000..1fb1570 --- /dev/null +++ b/cmd/extended_command_test.go @@ -0,0 +1,545 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" + + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestStatusCommandRendersTableJSONAndDirtyOnly(t *testing.T) { + app, _ := fakeStatusApp(t, "clean", "dirty") + + table := runCLI(t, app, "status", "--format", "table", "--jobs", "1") + if table.err != nil { + t.Fatalf("status table returned error: %v", table.err) + } + if !strings.Contains(table.stdout, "REPOSITORY\tBRANCH\tSTATE\tUPSTREAM\tAHEAD\tBEHIND") || + !strings.Contains(table.stdout, "clean\tmain\tclean\torigin/main\t0\t0") || + !strings.Contains(table.stdout, "dirty\tmain\tdirty\torigin/main\t0\t0") { + t.Fatalf("status table output = %q", table.stdout) + } + + dirty := runCLI(t, app, "status", "--dirty-only", "--format", "table") + if dirty.err != nil { + t.Fatalf("status --dirty-only returned error: %v", dirty.err) + } + if strings.Contains(dirty.stdout, "clean\tmain") || !strings.Contains(dirty.stdout, "dirty\tmain\tdirty") { + t.Fatalf("dirty-only output = %q", dirty.stdout) + } + + jsonResult := runCLI(t, app, "status", "--format", "json") + if jsonResult.err != nil { + t.Fatalf("status JSON returned error: %v", jsonResult.err) + } + var document statusDocument + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("status JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != outputSchemaVersion || len(document.Repositories) != 2 { + t.Fatalf("status document = %#v", document) + } +} + +func TestStatusCommandReportsCollectionErrors(t *testing.T) { + app, _ := fakeStatusApp(t, "error") + result := runCLI(t, app, "status", "--format", "table") + if result.err == nil || !strings.Contains(result.err.Error(), "status collection failed") { + t.Fatalf("status error = %v", result.err) + } + if !strings.Contains(result.stdout, "error\t-") || !strings.Contains(result.stderr, "error:") { + t.Fatalf("status collection output stdout=%q stderr=%q", result.stdout, result.stderr) + } +} + +func TestStatusNoAheadBehindRequestsUnknownCounts(t *testing.T) { + app, _ := fakeStatusApp(t, "unknown-noahead") + result := runCLI(t, app, "status", "--no-ahead-behind", "--format", "json") + if result.err != nil { + t.Fatalf("status --no-ahead-behind returned error: %v", result.err) + } + var document statusDocument + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("status JSON decode failed: %v\n%s", err, result.stdout) + } + if len(document.Repositories) != 1 || document.Repositories[0].AheadBehindKnown || document.Repositories[0].Ahead != 0 || document.Repositories[0].Behind != 0 { + t.Fatalf("unknown ahead/behind state = %#v", document.Repositories) + } + + ndjson := runCLI(t, app, "status", "--no-ahead-behind", "--format", "ndjson") + if ndjson.err != nil { + t.Fatalf("status NDJSON returned error: %v", ndjson.err) + } + var line statusLine + if err := json.Unmarshal([]byte(strings.TrimSpace(ndjson.stdout)), &line); err != nil { + t.Fatalf("status NDJSON decode failed: %v", err) + } + if line.Type != "status" || line.Status == nil || line.Status.AheadBehindKnown { + t.Fatalf("status NDJSON line = %#v", line) + } + + unknownApp, _ := fakeStatusApp(t, "unknown") + table := runCLI(t, unknownApp, "status", "--format", "table") + if table.err != nil || !strings.Contains(table.stdout, "unknown\tmain\tclean\torigin/main\t-\t-") { + t.Fatalf("unknown table result = %#v", table) + } +} + +func TestCheckCommandPassFailQuietJSONAndAllPolicies(t *testing.T) { + passApp, _ := fakeStatusApp(t, "clean") + pass := runCLI(t, passApp, "check", "--fail-on", "dirty,unpushed,behind,diverged,detached,no-upstream") + if pass.err != nil || !strings.Contains(pass.stdout, "clean\tok") { + t.Fatalf("passing check result = %#v", pass) + } + + failApp, _ := fakeStatusApp(t, "all") + fail := runCLI(t, failApp, "check", "--fail-on", "dirty,unpushed,behind,diverged,detached,no-upstream") + if fail.err == nil || !strings.Contains(fail.err.Error(), "policy check failed") { + t.Fatalf("failing check error = %v", fail.err) + } + for _, policy := range []string{"dirty", "diverged", "detached", "no-upstream", "unpushed", "behind"} { + if !strings.Contains(fail.stdout, policy) { + t.Fatalf("failing check output lacks %q: %q", policy, fail.stdout) + } + } + if !strings.Contains(fail.stdout, "all\tfail\t") { + t.Fatalf("failing check output = %q", fail.stdout) + } + + quiet := runCLI(t, failApp, "check", "--quiet", "--fail-on", "dirty") + if quiet.err == nil || quiet.stdout != "" || quiet.stderr != "" { + t.Fatalf("quiet check result = %#v, want failure with no output", quiet) + } + + jsonResult := runCLI(t, failApp, "check", "--format", "json", "--fail-on", "dirty") + if jsonResult.err == nil { + t.Fatal("JSON check should retain failing exit status") + } + var document checkDocument + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("check JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != outputSchemaVersion || len(document.Results) != 1 || !reflect.DeepEqual(document.Results[0].Violations, []string{"dirty"}) { + t.Fatalf("check document = %#v", document) + } +} + +func TestCheckFetchNoPromptSetsGitTerminalPromptAndTimeoutOption(t *testing.T) { + app, _ := fakeStatusApp(t, "prompt-check") + result := runCLI(t, app, "check", "--fetch", "--no-prompt", "--timeout", "1s", "--fail-on", "dirty") + if result.err != nil { + t.Fatalf("check --fetch --no-prompt returned error: %v\nstdout=%q\nstderr=%q", result.err, result.stdout, result.stderr) + } +} + +func TestCheckFetchFailureStillRendersStructuredResults(t *testing.T) { + app, _ := fakeStatusApp(t, "fetch-fail") + result := runCLI(t, app, "check", "--fetch", "--format", "json", "--fail-on", "dirty") + if result.err == nil || !strings.Contains(result.err.Error(), "fetch failed") { + t.Fatalf("check fetch failure error = %v", result.err) + } + var document checkDocument + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("fetch failure output is not valid JSON: %v\n%s", err, result.stdout) + } + if len(document.Results) != 1 || document.Results[0].FetchError == "" { + t.Fatalf("fetch failure result = %#v", document.Results) + } + + ndjson := runCLI(t, app, "check", "--fetch", "--format", "ndjson", "--fail-on", "dirty") + if ndjson.err == nil { + t.Fatal("NDJSON fetch failure should retain a failing exit status") + } + lines := strings.Split(strings.TrimSpace(ndjson.stdout), "\n") + if len(lines) != 1 || !strings.Contains(lines[0], "fetchError") { + t.Fatalf("NDJSON fetch failure output = %q", ndjson.stdout) + } +} + +func TestDoctorCommandTextJSONErrorsAndStrictWarnings(t *testing.T) { + app, _ := fakeStatusApp(t, "clean") + textResult := runCLI(t, app, "doctor") + if textResult.err != nil || !strings.Contains(textResult.stdout, "INFO git") || !strings.Contains(textResult.stdout, "INFO discovery") { + t.Fatalf("doctor text result = %#v", textResult) + } + + jsonResult := runCLI(t, app, "doctor", "--format", "json") + if jsonResult.err != nil { + t.Fatalf("doctor JSON returned error: %v", jsonResult.err) + } + var document doctorDocument + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("doctor JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != outputSchemaVersion || len(document.Findings) < 2 { + t.Fatalf("doctor document = %#v", document) + } + + missing := app + missing.Git = gitexec.Runner{Executable: "gitmulti-doctor-command-missing"} + errorResult := runCLI(t, missing, "doctor") + if errorResult.err == nil || !strings.Contains(errorResult.stdout, "ERROR git") { + t.Fatalf("doctor missing-git result = %#v", errorResult) + } + + warningApp := app + warningApp.Discover = func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{Warnings: []repository.Warning{{Path: "/tmp/warn", Message: "inaccessible"}}}, nil + } + strict := runCLI(t, warningApp, "doctor", "--strict") + if strict.err == nil || !strings.Contains(strict.err.Error(), "doctor found") || !strings.Contains(strict.stdout, "WARNING discovery") { + t.Fatalf("doctor strict result = %#v", strict) + } + quietWarning := runCLI(t, warningApp, "check", "--quiet") + if quietWarning.err != nil || quietWarning.stdout != "" || quietWarning.stderr != "" { + t.Fatalf("quiet check warning result = %#v", quietWarning) + } + invalidTimeout := runCLI(t, app, "check", "--timeout=-1s") + var coded interface{ ExitCode() int } + if invalidTimeout.err == nil || !errors.As(invalidTimeout.err, &coded) || coded.ExitCode() != 2 { + t.Fatalf("check timeout error = %v, want exit code 2", invalidTimeout.err) + } +} + +func TestExecRendersAllOutputModes(t *testing.T) { + app, fixture := fakeStatusApp(t, "clean") + for _, test := range []struct { + name string + args []string + }{ + {name: "grouped", args: []string{"exec", "--output", "grouped", "--", "emit"}}, + {name: "prefixed", args: []string{"exec", "--output", "prefixed", "--", "emit"}}, + {name: "raw", args: []string{"exec", "--output", "raw", "--", "emit"}}, + } { + t.Run(test.name, func(t *testing.T) { + result := runCLI(t, app, test.args...) + if result.err != nil { + t.Fatalf("exec %s returned error: %v", test.name, result.err) + } + if !strings.Contains(result.stdout, "out-clean") || !strings.Contains(result.stderr, "err-clean") { + t.Fatalf("exec %s output stdout=%q stderr=%q", test.name, result.stdout, result.stderr) + } + if test.name == "grouped" && !strings.Contains(result.stdout, "== clean ==") { + t.Fatalf("grouped output lacks heading: %q", result.stdout) + } + if test.name == "prefixed" && !strings.Contains(result.stdout, "[clean] out-clean") { + t.Fatalf("prefixed output lacks label: %q", result.stdout) + } + }) + } + + jsonResult := runCLI(t, app, "exec", "--output", "json", "--", "emit") + if jsonResult.err != nil { + t.Fatalf("exec JSON returned error: %v", jsonResult.err) + } + var document execDocument + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("exec JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if len(document.Results) != 1 || document.Results[0].ExitCode != 0 || document.Results[0].Stdout != "out-clean\n" { + t.Fatalf("exec JSON document = %#v", document) + } + + ndjsonResult := runCLI(t, app, "exec", "--output", "ndjson", "--", "emit") + if ndjsonResult.err != nil { + t.Fatalf("exec NDJSON returned error: %v", ndjsonResult.err) + } + var record execRecord + if err := json.Unmarshal([]byte(strings.TrimSpace(ndjsonResult.stdout)), &record); err != nil { + t.Fatalf("exec NDJSON decode failed: %v\n%s", err, ndjsonResult.stdout) + } + if record.Repository != fixture.Repositories[0].Name || record.ExitCode != 0 { + t.Fatalf("exec NDJSON record = %#v", record) + } +} + +func TestExecAggregatesFailuresTimeoutsAndRunsWithConcurrency(t *testing.T) { + app, fixture := fakeStatusApp(t, "fail", "clean", "fail", "clean") + failure := runCLI(t, app, "exec", "--output", "json", "--jobs", "2", "--", "fail") + if failure.err == nil || !strings.Contains(failure.err.Error(), "failed in 4") { + t.Fatalf("failure aggregation error = %v", failure.err) + } + var document execDocument + if err := json.Unmarshal([]byte(failure.stdout), &document); err != nil { + t.Fatalf("failure JSON decode failed: %v", err) + } + if len(document.Results) != len(fixture.Repositories) { + t.Fatalf("failure records = %d, want %d", len(document.Results), len(fixture.Repositories)) + } + + timeout := runCLI(t, app, "exec", "--output", "json", "--timeout", "20ms", "--", "hang") + if timeout.err == nil || !strings.Contains(timeout.err.Error(), "failed in") { + t.Fatalf("timeout error = %v", timeout.err) + } + if !strings.Contains(timeout.stdout, "context deadline exceeded") { + t.Fatalf("timeout JSON = %q", timeout.stdout) + } +} + +func TestWorkerCountsAreCappedToRepositoryCount(t *testing.T) { + app, fixture := fakeStatusApp(t, "clean") + command := newExecCommand(app) + command.SetContext(context.Background()) + maxJobs := int(^uint(0) >> 1) + records := executeAcross(command, app, fixture.Repositories, []string{"emit"}, maxJobs, 0, false, "json", false) + if len(records) != 1 || records[0].Error != "" || records[0].ExitCode != 0 { + t.Fatalf("executeAcross with huge jobs = %#v", records) + } + states := collectStatuses(command.Context(), app, fixture.Repositories, maxJobs) + if len(states) != 1 || states[0].Err != nil || states[0].Branch != "main" { + t.Fatalf("collectStatuses with huge jobs = %#v", states) + } +} + +func TestLinePrefixWriterHandlesPartialLinesAndControlLabels(t *testing.T) { + var output bytes.Buffer + shared := &sync.Mutex{} + writer := newLinePrefixWriter(&output, "repo\n\t\x01", shared) + if n, err := writer.Write([]byte("first")); err != nil || n != len("first") || output.Len() != 0 { + t.Fatalf("partial write = n:%d err:%v output:%q", n, err, output.String()) + } + if n, err := writer.Write([]byte(" line\nsecond")); err != nil || n != len(" line\nsecond") { + t.Fatalf("line write = n:%d err:%v", n, err) + } + if want := "[repo\\n\\t\\x01] first line\n"; output.String() != want { + t.Fatalf("line output = %q, want %q", output.String(), want) + } + if err := writer.Flush(); err != nil { + t.Fatalf("Flush returned error: %v", err) + } + if want := "[repo\\n\\t\\x01] first line\n[repo\\n\\t\\x01] second\n"; output.String() != want { + t.Fatalf("flushed output = %q, want %q", output.String(), want) + } +} + +func TestLinePrefixWriterBoundsNewlineFreeOutput(t *testing.T) { + const size = 2*1024*1024 + 17 + input := bytes.Repeat([]byte{'x'}, size) + var output bytes.Buffer + writer := newLinePrefixWriter(&output, "large", &sync.Mutex{}) + if n, err := writer.Write(input); err != nil || n != len(input) { + t.Fatalf("large write = n:%d err:%v, want %d bytes", n, err, len(input)) + } + if err := writer.Flush(); err != nil { + t.Fatalf("Flush returned error: %v", err) + } + want := append([]byte("[large] "), input...) + want = append(want, '\n') + if !bytes.Equal(output.Bytes(), want) { + t.Fatalf("large output length = %d, want %d", output.Len(), len(want)) + } +} + +func TestSafeFastForwardRefusesUnsafeRepositories(t *testing.T) { + app, fixture := fakeStatusApp(t, "dirty", "detached", "noupstream", "clean") + result := runCLI(t, app, "sync", "--mode", "ff-only", "--output", "json", "--jobs", "2") + if result.err == nil || !strings.Contains(result.err.Error(), "unsafe") { + t.Fatalf("ff-only refusal error = %v", result.err) + } + var document execDocument + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("ff-only JSON decode failed: %v\n%s", err, result.stdout) + } + if len(document.Results) != len(fixture.Repositories) { + t.Fatalf("ff-only records = %d, want %d", len(document.Results), len(fixture.Repositories)) + } + refused := 0 + for _, record := range document.Results { + if strings.Contains(record.Error, "refusing ff-only sync") { + refused++ + } + } + if refused != 3 { + t.Fatalf("ff-only refused %d repositories, want 3: %#v", refused, document.Results) + } +} + +func TestSnapshotCommandJSONIncludesRedactedRemotes(t *testing.T) { + app, fixture := fakeStatusApp(t, "clean") + result := runCLI(t, app, "snapshot", "--format", "json", "--include-remotes") + if result.err != nil { + t.Fatalf("snapshot command returned error: %v", result.err) + } + var document snapshotDocument + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("snapshot JSON decode failed: %v\n%s", err, result.stdout) + } + if document.SchemaVersion != outputSchemaVersion || len(document.Repositories) != 1 { + t.Fatalf("snapshot document = %#v", document) + } + record := document.Repositories[0] + if record.Head != "deadbeef" || record.Branch != "main" || record.Remotes["origin"][0] != "https://REDACTED:REDACTED@example.com/project.git" || strings.Contains(result.stdout, "password") { + t.Fatalf("snapshot record = %#v; output=%q", record, result.stdout) + } + _ = fixture +} + +func TestMachineOutputsRetainDiscoveryDiagnostics(t *testing.T) { + app, _ := fakeStatusApp(t, "clean") + baseDiscover := app.Discover + app.Discover = func(ctx context.Context, options repository.Options) (repository.Result, error) { + result, err := baseDiscover(ctx, options) + result.Warnings = []repository.Warning{{Path: "/tmp/diagnostic", Message: "skipped entry"}} + return result, err + } + + execResult := runCLI(t, app, "exec", "--output", "json", "--", "emit") + var executions execDocument + if execResult.err != nil || json.Unmarshal([]byte(execResult.stdout), &executions) != nil || len(executions.Diagnostics) != 1 { + t.Fatalf("exec diagnostics result = %#v", execResult) + } + + checkResult := runCLI(t, app, "check", "--format", "json", "--fail-on", "dirty") + var checks checkDocument + if checkResult.err != nil || json.Unmarshal([]byte(checkResult.stdout), &checks) != nil || len(checks.Diagnostics) != 1 { + t.Fatalf("check diagnostics result = %#v", checkResult) + } + + snapshotResult := runCLI(t, app, "snapshot", "--format", "json") + var snapshot snapshotDocument + if snapshotResult.err != nil || json.Unmarshal([]byte(snapshotResult.stdout), &snapshot) != nil || len(snapshot.Diagnostics) != 1 { + t.Fatalf("snapshot diagnostics result = %#v", snapshotResult) + } +} + +func fakeStatusApp(t *testing.T, names ...string) (App, repository.Result) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("deterministic fake Git fixture uses a POSIX executable") + } + root := t.TempDir() + fixture := fixtureRepositories(root, names...) + for _, repo := range fixture.Repositories { + if err := os.MkdirAll(repo.Path, 0o755); err != nil { + t.Fatalf("create fake repository %q: %v", repo.Path, err) + } + } + script := writeFakeGit(t) + app := appWithRepositories(fixture) + app.Git = gitexec.Runner{Executable: script} + return app, fixture +} + +func writeFakeGit(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "fake-git") + script := `#!/bin/sh +repo=${PWD##*/} +if [ "$1" = "--version" ]; then + echo "git version fake-1.0" + exit 0 +fi +if [ "$1" = "-c" ] && [ "$2" = "core.fsmonitor=false" ]; then + shift 2 +fi +if [ "$1" = "--no-optional-locks" ]; then + case "$repo" in + clean) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' + ;; + dirty) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' '1 .M N... 100644 100644 100644 abc abc changed' '? untracked' + ;; + all) + printf '%s\0' '# branch.oid abc' '# branch.head (detached)' '# branch.ab +2 -3' '1 .M N... 100644 100644 100644 abc abc changed' + ;; + ahead) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +2 -0' + ;; + behind) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -2' + ;; + diverged) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +2 -3' + ;; + detached) + printf '%s\0' '# branch.oid abc' '# branch.head (detached)' + ;; + noupstream) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.ab +0 -0' + ;; + unknown) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' + ;; + unknown-noahead) + if [ "$5" != "--no-ahead-behind" ]; then + echo "missing --no-ahead-behind" >&2 + exit 11 + fi + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' + ;; + prompt-check) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' + ;; + fetch-fail) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' + ;; + error) + echo 'synthetic status failure' >&2 + exit 9 + ;; + *) + echo "unknown fake repository $repo" >&2 + exit 10 + ;; + esac + exit 0 +fi +case "$1" in + emit) + printf 'out-%s\n' "$repo" + printf 'err-%s\n' "$repo" >&2 + ;; + fail) + printf 'out-%s\n' "$repo" + printf 'err-%s\n' "$repo" >&2 + exit 7 + ;; + hang) + while :; do sleep 1; done + ;; + rev-parse) + printf 'deadbeef\n' + ;; + symbolic-ref) + printf 'main\n' + ;; + remote) + printf 'origin\thttps://alice:password@example.com/project.git (fetch)\n' + printf 'origin\thttps://alice:password@example.com/project.git (push)\n' + ;; + pull|fetch) + if [ "$repo" = "fetch-fail" ]; then + echo 'synthetic fetch failure' >&2 + exit 13 + fi + if [ "$repo" = "prompt-check" ] && [ "${GIT_TERMINAL_PROMPT:-}" != "0" ]; then + echo 'credential prompt was not disabled' >&2 + exit 12 + fi + printf 'synced-%s\n' "$repo" + ;; + *) + printf 'out-%s\n' "$repo" + ;; +esac +` + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + if err := os.Chmod(path, 0o700); err != nil { + t.Fatalf("chmod fake Git: %v", err) + } + return path +} diff --git a/cmd/list.go b/cmd/list.go index 2c4576a..50033aa 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,45 +1,134 @@ package cmd import ( + "encoding/json" "fmt" + "path/filepath" + "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -var listCmd = &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Args: cobra.NoArgs, - Short: "list repositories inside the specified directory (default: current directory)", - Long: "list repositories inside the specified directory (default: current directory)", - RunE: func(cmd *cobra.Command, args []string) error { - if listPath == "." { - fmt.Println("\nthese are the repositories inside the current directory:") - } else { - fmt.Println("\nthese are the repositories inside the specified directory:") - } - fmt.Println() - - repositories, err := findRepositories(listPath, listRecursive) - if err != nil { - return err - } +func newListCommand(app App) *cobra.Command { + var scan scanFlags + var format string + var absolute bool + var nullTerminated bool + var count bool + var requireMatch bool - if len(repositories) == 0 { - fmt.Println("no git repositories found") - return nil - } + command := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List discovered Git repositories", + Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !validListFormat(format) { + return errorsForFlag(fmt.Sprintf("unsupported list format %q", format)) + } + if absolute { + if format != "relative" && format != "absolute" { + return errorsForFlag(fmt.Sprintf("--absolute cannot be combined with --format %s", format)) + } + format = "absolute" + } + if nullTerminated && format != "name" && format != "relative" && format != "absolute" { + return errorsForFlag("--null is only supported with name, relative, or absolute output") + } + if count && nullTerminated { + return errorsForFlag("--count cannot be combined with --null") + } + if count && absolute { + return errorsForFlag("--count cannot be combined with --absolute") + } + if count && format != "relative" { + return errorsForFlag(fmt.Sprintf("--count cannot be combined with --format %s", format)) + } - for _, repo := range repositories { - fmt.Println(repo.Name + " is a git repository") - } + result, err := discoverRepositories(command.Context(), app, command, scan) + if err != nil { + return err + } + if requireMatch && len(result.Repositories) == 0 { + return newExitError(1, "no Git repositories matched") + } + if count { + _, err := fmt.Fprintln(command.OutOrStdout(), len(result.Repositories)) + return err + } + return renderRepositoryList(command, result, format, nullTerminated) + }, + } + scan.bind(command) + command.Flags().StringVar(&format, "format", "relative", "output format: name, relative, absolute, json, or ndjson") + command.Flags().BoolVar(&absolute, "absolute", false, "print absolute paths") + command.Flags().BoolVarP(&nullTerminated, "null", "0", false, "terminate text records with NUL") + command.Flags().BoolVar(&count, "count", false, "print only the repository count") + command.Flags().BoolVar(&scan.validate, "validate", false, "validate repository markers using Git") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} - return nil - }, +func validListFormat(format string) bool { + switch format { + case "name", "relative", "absolute", "json", "ndjson": + return true + default: + return false + } } -func init() { - listCmd.Flags().StringVarP(&listPath, "path", "p", ".", "path to scan for repositories") - listCmd.Flags().BoolVarP(&listRecursive, "recursive", "r", false, "scan repositories recursively") - rootCmd.AddCommand(listCmd) +func renderRepositoryList(command *cobra.Command, result repository.Result, format string, nullTerminated bool) error { + switch format { + case "name", "relative", "absolute": + separator := "\n" + if nullTerminated { + separator = "\x00" + } + for _, repo := range result.Repositories { + value := repo.Name + if format == "name" { + value = filepath.Base(repo.Path) + } else if format == "absolute" { + absolutePath, err := filepath.Abs(repo.Path) + if err != nil { + return err + } + value = absolutePath + } + if _, err := fmt.Fprint(command.OutOrStdout(), value, separator); err != nil { + return err + } + } + return nil + case "json": + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(struct { + SchemaVersion int `json:"schemaVersion"` + Repositories []repository.Repository `json:"repositories"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` + }{SchemaVersion: outputSchemaVersion, Repositories: result.Repositories, Diagnostics: result.Warnings}) + case "ndjson": + encoder := json.NewEncoder(command.OutOrStdout()) + for _, repo := range result.Repositories { + if err := encoder.Encode(struct { + Type string `json:"type"` + repository.Repository + }{Type: "repository", Repository: repo}); err != nil { + return err + } + } + for _, warning := range result.Warnings { + if err := encoder.Encode(struct { + Type string `json:"type"` + repository.Warning + }{Type: "diagnostic", Warning: warning}); err != nil { + return err + } + } + return nil + default: + return errorsForFlag(fmt.Sprintf("unsupported list format %q", format)) + } } diff --git a/cmd/list_command_test.go b/cmd/list_command_test.go new file mode 100644 index 0000000..3ed7cea --- /dev/null +++ b/cmd/list_command_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestListRendersTextFormatsAndCount(t *testing.T) { + root := t.TempDir() + fixture := fixtureRepositories(root, "alpha", "nested/beta") + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "name", args: []string{"list", "--format", "name"}, want: "alpha\nbeta\n"}, + {name: "relative", args: []string{"list", "--format", "relative"}, want: "alpha\nnested/beta\n"}, + {name: "null", args: []string{"list", "--format", "name", "--null"}, want: "alpha\x00beta\x00"}, + {name: "count", args: []string{"list", "--count"}, want: "2\n"}, + } { + t.Run(test.name, func(t *testing.T) { + result := runCLI(t, appWithRepositories(fixture), test.args...) + if result.err != nil { + t.Fatalf("list returned error: %v", result.err) + } + if result.stdout != test.want { + t.Fatalf("stdout = %q, want %q", result.stdout, test.want) + } + }) + } + + absolute := runCLI(t, appWithRepositories(fixture), "list", "--format", "absolute") + if absolute.err != nil { + t.Fatalf("absolute list returned error: %v", absolute.err) + } + wantAbsolute := filepath.Join(root, "alpha") + "\n" + filepath.Join(root, "nested", "beta") + "\n" + if absolute.stdout != wantAbsolute { + t.Fatalf("absolute stdout = %q, want %q", absolute.stdout, wantAbsolute) + } + + alias := runCLI(t, appWithRepositories(fixture), "ls", "--format", "name") + if alias.err != nil || alias.stdout != "alpha\nbeta\n" { + t.Fatalf("ls result = %#v, want list output", alias) + } +} + +func TestListRendersJSONAndNDJSONContracts(t *testing.T) { + root := t.TempDir() + fixture := fixtureRepositories(root, "alpha", "beta") + + jsonResult := runCLI(t, appWithRepositories(fixture), "list", "--format", "json") + if jsonResult.err != nil { + t.Fatalf("list --format json returned error: %v", jsonResult.err) + } + var document struct { + SchemaVersion int `json:"schemaVersion"` + Repositories []repository.Repository `json:"repositories"` + } + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("list JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != outputSchemaVersion || !reflect.DeepEqual(document.Repositories, fixture.Repositories) { + t.Fatalf("JSON document = %#v, want schema %d and repositories %#v", document, outputSchemaVersion, fixture.Repositories) + } + + ndjsonResult := runCLI(t, appWithRepositories(fixture), "list", "--format", "ndjson") + if ndjsonResult.err != nil { + t.Fatalf("list --format ndjson returned error: %v", ndjsonResult.err) + } + lines := strings.Split(strings.TrimSpace(ndjsonResult.stdout), "\n") + if len(lines) != len(fixture.Repositories) { + t.Fatalf("NDJSON line count = %d, want %d", len(lines), len(fixture.Repositories)) + } + for index, line := range lines { + var got repository.Repository + if err := json.Unmarshal([]byte(line), &got); err != nil { + t.Fatalf("NDJSON line %d decode failed: %v", index, err) + } + if !reflect.DeepEqual(got, fixture.Repositories[index]) { + t.Errorf("NDJSON repository %d = %#v, want %#v", index, got, fixture.Repositories[index]) + } + } +} + +func TestListRequireMatchAndFlagValidation(t *testing.T) { + empty := appWithRepositories(repository.Result{}) + result := runCLI(t, empty, "list", "--require-match") + if result.err == nil { + t.Fatal("--require-match should fail when no repositories match") + } + var coded interface{ ExitCode() int } + if !errors.As(result.err, &coded) { + t.Fatalf("require-match error = %v, want an exit code", result.err) + } + + for _, args := range [][]string{ + {"list", "--format", "json", "--absolute"}, + {"list", "--format", "json", "--null"}, + {"list", "--format", "invalid"}, + {"list", "--count", "--format", "json"}, + {"list", "--count", "--null"}, + {"list", "--max-depth", "1"}, + {"list", "--include-nested"}, + } { + result := runCLI(t, empty, args...) + var coded interface{ ExitCode() int } + if result.err == nil || !errors.As(result.err, &coded) || coded.ExitCode() != 2 { + t.Errorf("args %v unexpectedly succeeded", args) + } + } +} + +func TestListPassesScanOptionsToDiscoverer(t *testing.T) { + var got repository.Options + app := App{ + Discover: func(_ context.Context, options repository.Options) (repository.Result, error) { + got = options + return repository.Result{}, nil + }, + } + result := runCLI(t, app, "list", "--path", "/workspace", "--recursive", "--include-root", "--include-nested", "--max-depth", "3", "--match", "services/*", "--exclude", "services/archive", "--include-hidden", "--follow-symlinks", "--include-bare") + if result.err != nil { + t.Fatalf("list with scan options returned error: %v", result.err) + } + want := repository.Options{ + Root: "/workspace", + Recursive: true, + IncludeRoot: true, + IncludeNested: true, + MaxDepth: 3, + Match: []string{"services/*"}, + Exclude: []string{"services/archive"}, + IncludeHidden: true, + FollowSymlinks: true, + IncludeBare: true, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("discovery options = %#v, want %#v", got, want) + } +} + +func TestListValidationUsesConfiguredGitRunner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake Git fixture uses a POSIX executable") + } + validated := false + app := App{ + Git: gitexec.Runner{Executable: writeFakeGit(t)}, + Discover: func(ctx context.Context, options repository.Options) (repository.Result, error) { + if options.ValidateRepository == nil { + t.Fatal("validation callback was not injected") + } + if err := options.ValidateRepository(ctx, t.TempDir()); err != nil { + t.Fatalf("injected validation returned error: %v", err) + } + validated = true + return repository.Result{}, nil + }, + } + + result := runCLI(t, app, "list", "--validate") + if result.err != nil { + t.Fatalf("list --validate returned error: %v", result.err) + } + if !validated { + t.Fatal("configured Git runner was not used for validation") + } +} diff --git a/cmd/options.go b/cmd/options.go new file mode 100644 index 0000000..0ba4330 --- /dev/null +++ b/cmd/options.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +type scanFlags struct { + path string + recursive bool + includeRoot bool + includeNested bool + maxDepth int + match []string + exclude []string + includeHidden bool + followSymlinks bool + includeBare bool + validate bool +} + +func (flags *scanFlags) bind(command *cobra.Command) { + command.Flags().StringVarP(&flags.path, "path", "p", ".", "root path to scan") + command.Flags().BoolVarP(&flags.recursive, "recursive", "r", false, "scan nested directories") + command.Flags().BoolVar(&flags.includeRoot, "include-root", false, "include the root path when it is a repository") + command.Flags().BoolVar(&flags.includeNested, "include-nested", false, "scan inside discovered repositories") + command.Flags().IntVar(&flags.maxDepth, "max-depth", 0, "maximum recursive depth; zero means unlimited") + command.Flags().StringArrayVar(&flags.match, "match", nil, "include repository names matching a glob (repeatable)") + command.Flags().StringArrayVar(&flags.exclude, "exclude", nil, "exclude repository names matching a glob (repeatable)") + command.Flags().BoolVar(&flags.includeHidden, "include-hidden", false, "scan dot-prefixed directories") + command.Flags().BoolVar(&flags.followSymlinks, "follow-symlinks", false, "follow directory symlinks with cycle protection") + command.Flags().BoolVar(&flags.includeBare, "include-bare", false, "include bare Git repositories") +} + +func (flags scanFlags) options() repository.Options { + return repository.Options{ + Root: flags.path, + Recursive: flags.recursive, + IncludeRoot: flags.includeRoot, + IncludeNested: flags.includeNested, + MaxDepth: flags.maxDepth, + Match: append([]string(nil), flags.match...), + Exclude: append([]string(nil), flags.exclude...), + IncludeHidden: flags.includeHidden, + FollowSymlinks: flags.followSymlinks, + IncludeBare: flags.includeBare, + Validate: flags.validate, + } +} + +func discoverRepositories(ctx context.Context, app App, command *cobra.Command, flags scanFlags, suppressWarnings ...bool) (repository.Result, error) { + if flags.maxDepth < 0 { + return repository.Result{}, errorsForFlag("--max-depth cannot be negative") + } + if flags.maxDepth > 0 && !flags.recursive { + return repository.Result{}, errorsForFlag("--max-depth requires --recursive") + } + if flags.includeNested && !flags.recursive { + return repository.Result{}, errorsForFlag("--include-nested requires --recursive") + } + options := flags.options() + if options.Validate { + options.ValidateRepository = func(ctx context.Context, path string) error { + result := app.Git.Run(ctx, path, []string{"rev-parse", "--git-dir"}, nil) + if result.Err == nil { + return nil + } + if stderr := strings.TrimSpace(result.Stderr); stderr != "" { + return fmt.Errorf("%w (%s)", result.Err, stderr) + } + return result.Err + } + } + result, err := app.Discover(ctx, options) + if err != nil { + return repository.Result{}, err + } + quiet := len(suppressWarnings) > 0 && suppressWarnings[0] + for _, warning := range result.Warnings { + if quiet { + continue + } + if _, err := fmt.Fprintf(command.ErrOrStderr(), "warning: %s: %s\n", warning.Path, warning.Message); err != nil { + return repository.Result{}, err + } + } + return result, nil +} diff --git a/cmd/policy_command_test.go b/cmd/policy_command_test.go new file mode 100644 index 0000000..a187e01 --- /dev/null +++ b/cmd/policy_command_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/gitstatus" +) + +func TestNormalizePoliciesSplitsDeduplicatesAndSorts(t *testing.T) { + got, err := normalizePolicies([]string{"dirty,behind", "DIRTY", "no-upstream"}) + if err != nil { + t.Fatalf("normalizePolicies returned error: %v", err) + } + want := []string{"behind", "dirty", "no-upstream"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("policies = %#v, want %#v", got, want) + } + + if _, err := normalizePolicies([]string{"dirty", "unknown"}); err == nil || !strings.Contains(err.Error(), "unsupported check policy") { + t.Fatalf("invalid policy error = %v", err) + } +} + +func TestEvaluatePoliciesCoversRepositoryConditions(t *testing.T) { + state := gitstatus.State{ + Dirty: true, + Ahead: 2, + Behind: 3, + Detached: true, + NoUpstream: true, + } + got := evaluatePolicies(state, []string{"dirty", "unpushed", "behind", "diverged", "detached", "no-upstream"}) + want := []string{"dirty", "unpushed", "behind", "diverged", "detached", "no-upstream"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("violations = %#v, want %#v", got, want) + } + + if got := evaluatePolicies(gitstatus.State{Err: errSentinel}, []string{"dirty"}); got != nil { + t.Fatalf("error state violations = %#v, want nil", got) + } +} + +var errSentinel = errors.New("sentinel") diff --git a/cmd/root.go b/cmd/root.go index 10bd1af..5099b69 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,33 +1,144 @@ package cmd import ( + "context" + "errors" "fmt" + "io" "os" + "runtime/debug" + "strings" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -var rootCmd = &cobra.Command{ - Use: "gitmulti", - Short: "gitmulti is a cli tool to perform git commands on multiple repositories", - Long: "gitmulti is a cli tool to perform git commands on multiple repositories", - Run: func(cmd *cobra.Command, args []string) { - if versionFlag { - fmt.Println("gitmulti version " + version) - return - } +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) + +type App struct { + In io.Reader + Out io.Writer + Err io.Writer + Git gitexec.Runner + Discover func(context.Context, repository.Options) (repository.Result, error) +} + +func DefaultApp() App { + return App{ + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Git: gitexec.Runner{}, + Discover: repository.Discover, + } +} - cmd.Help() - }, +func (app App) normalized() App { + if app.In == nil { + app.In = os.Stdin + } + if app.Out == nil { + app.Out = os.Stdout + } + if app.Err == nil { + app.Err = os.Stderr + } + if app.Discover == nil { + app.Discover = repository.Discover + } + return app } -func Execute() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Oops. An error while executing Git Multi '%s'\n", err) - os.Exit(1) +func NewRootCommand(app App) *cobra.Command { + app = app.normalized() + info := currentBuildInfo() + + root := &cobra.Command{ + Use: "gitmulti", + Short: "Run Git operations across multiple repositories", + Long: "GitMulti discovers repositories under a directory and reports on or operates across them.", + SilenceErrors: true, + SilenceUsage: true, + Version: info.Version, } + root.SetIn(app.In) + root.SetOut(app.Out) + root.SetErr(app.Err) + root.SetVersionTemplate("gitmulti version {{.Version}}\n") + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return exitError{code: 2, message: err.Error()} + }) + + root.AddCommand( + newListCommand(app), + newExecCommand(app), + newStatusCommand(app), + newCheckCommand(app), + newDoctorCommand(app), + newSyncCommand(app), + newSnapshotCommand(app), + newCompletionCommand(app), + newVersionCommand(app, info), + ) + + return root } -func init() { - rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "print the version number of gitmulti") +func Execute() int { + return Run(context.Background(), os.Args[1:], DefaultApp()) +} + +func Run(ctx context.Context, args []string, app App) int { + root := NewRootCommand(app) + root.SetArgs(args) + if err := root.ExecuteContext(ctx); err != nil { + if err.Error() != "" { + fmt.Fprintf(root.ErrOrStderr(), "Error: %v\n", err) + } + var exitErr interface{ ExitCode() int } + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + if strings.HasPrefix(err.Error(), "unknown command") { + return 2 + } + return 1 + } + return 0 +} + +type buildInfo struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"buildDate"` + Dirty bool `json:"dirty"` + GoVersion string `json:"goVersion,omitempty"` + Module string `json:"module,omitempty"` +} + +func currentBuildInfo() buildInfo { + info := buildInfo{Version: Version, Commit: Commit, BuildDate: BuildDate} + if bi, ok := debug.ReadBuildInfo(); ok { + info.GoVersion = bi.GoVersion + info.Module = bi.Main.Path + if info.Version == "dev" && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + info.Version = bi.Main.Version + } + if info.Commit == "unknown" { + for _, setting := range bi.Settings { + if setting.Key == "vcs.revision" { + info.Commit = setting.Value + } + if setting.Key == "vcs.modified" && setting.Value == "true" { + info.Dirty = true + } + } + } + } + return info } diff --git a/cmd/snapshot.go b/cmd/snapshot.go new file mode 100644 index 0000000..eb74528 --- /dev/null +++ b/cmd/snapshot.go @@ -0,0 +1,365 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +type snapshotRepository struct { + Name string `json:"name"` + Path string `json:"path"` + Head string `json:"head,omitempty"` + Branch string `json:"branch,omitempty"` + Remotes map[string][]string `json:"remotes,omitempty"` + Error string `json:"error,omitempty"` +} + +type snapshotDocument struct { + SchemaVersion int `json:"schemaVersion"` + Repositories []snapshotRepository `json:"repositories"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} + +func newSnapshotCommand(app App) *cobra.Command { + var scan scanFlags + var outputPath string + var format string + var includeRemotes bool + var force bool + var requireMatch bool + + command := &cobra.Command{ + Use: "snapshot", + Short: "Export repository revisions and remotes", + Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if format != "json" && format != "ndjson" { + return errorsForFlag(fmt.Sprintf("unsupported snapshot format %q", format)) + } + discovered, err := discoverRepositories(command.Context(), app, command, scan) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 && requireMatch { + return newExitError(1, "no Git repositories matched") + } + if len(discovered.Repositories) > 0 { + if _, err := app.Git.LookPath(); err != nil { + return err + } + } + + records := make([]snapshotRepository, 0, len(discovered.Repositories)) + failures := 0 + for _, repo := range discovered.Repositories { + record := snapshotRepository{Name: repo.Name, Path: repo.Path} + head := app.Git.Run(command.Context(), repo.Path, []string{"rev-parse", "HEAD"}, nil) + if head.Err != nil { + record.Error = head.Err.Error() + failures++ + } else { + record.Head = strings.TrimSpace(head.Stdout) + } + branch := app.Git.Run(command.Context(), repo.Path, []string{"symbolic-ref", "--quiet", "--short", "HEAD"}, nil) + if branch.Err == nil { + record.Branch = strings.TrimSpace(branch.Stdout) + } + if includeRemotes { + remotes := app.Git.Run(command.Context(), repo.Path, []string{"remote", "-v"}, nil) + if remotes.Err != nil && record.Error == "" { + record.Error = remotes.Err.Error() + failures++ + } else { + record.Remotes = parseRemotes(remotes.Stdout) + } + } + records = append(records, record) + } + + writer, closeWriter, err := snapshotWriter(command.OutOrStdout(), outputPath, force) + if err != nil { + return err + } + encoder := json.NewEncoder(writer) + var encodeErr error + if format == "json" { + encoder.SetIndent("", " ") + encodeErr = encoder.Encode(snapshotDocument{SchemaVersion: outputSchemaVersion, Repositories: records, Diagnostics: discovered.Warnings}) + } else { + for _, record := range records { + if err := encoder.Encode(struct { + Type string `json:"type"` + snapshotRepository + }{Type: "repository", snapshotRepository: record}); err != nil { + encodeErr = err + break + } + } + for _, warning := range discovered.Warnings { + if err := encoder.Encode(struct { + Type string `json:"type"` + repository.Warning + }{Type: "diagnostic", Warning: warning}); err != nil { + encodeErr = err + break + } + } + } + if closeWriter != nil { + if encodeErr != nil { + if aborter, ok := writer.(interface{ Abort() error }); ok { + _ = aborter.Abort() + } + } else if closeErr := closeWriter(); closeErr != nil { + encodeErr = closeErr + } + } + if encodeErr != nil { + return encodeErr + } + if failures > 0 { + return newExitError(1, "snapshot failed for %d repositories", failures) + } + return nil + }, + } + scan.bind(command) + command.Flags().StringVarP(&outputPath, "output", "o", "", "write the snapshot to a file instead of stdout") + command.Flags().StringVar(&format, "format", "json", "output format: json or ndjson") + command.Flags().BoolVar(&includeRemotes, "include-remotes", false, "include configured remote URLs with credentials redacted") + command.Flags().BoolVar(&force, "force", false, "overwrite an existing output file") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} + +type snapshotTempFile struct { + file *os.File + tempPath string + destination string + force bool +} + +func (writer *snapshotTempFile) Write(data []byte) (int, error) { + return writer.file.Write(data) +} + +func (writer *snapshotTempFile) Commit() error { + if writer.file == nil { + return nil + } + if err := writer.file.Sync(); err != nil { + return writer.fail(err) + } + if err := writer.file.Close(); err != nil { + return writer.fail(err) + } + writer.file = nil + + if err := validateSnapshotDestination(writer.destination, writer.force); err != nil { + return writer.fail(err) + } + if writer.force { + if err := os.Rename(writer.tempPath, writer.destination); err != nil { + return writer.fail(err) + } + writer.tempPath = "" + } else { + // A hard link is an atomic, no-overwrite install on the same + // filesystem. Unlike rename, it cannot replace a destination that + // appeared after the initial validation. + if err := os.Link(writer.tempPath, writer.destination); err != nil { + return writer.fail(err) + } + tempPath := writer.tempPath + if err := os.Remove(tempPath); err != nil { + // The destination is already installed. Retry cleanup, but never + // remove the successfully installed destination if cleanup fails. + cleanupErr := writer.Abort() + if cleanupErr != nil { + return fmt.Errorf("remove temporary snapshot %s after install: %w (cleanup: %v)", tempPath, err, cleanupErr) + } + return fmt.Errorf("remove temporary snapshot %s after install: %w", tempPath, err) + } + writer.tempPath = "" + } + return nil +} + +func (writer *snapshotTempFile) Abort() error { + if writer.file != nil { + _ = writer.file.Close() + writer.file = nil + } + if writer.tempPath == "" { + return nil + } + err := os.Remove(writer.tempPath) + writer.tempPath = "" + if os.IsNotExist(err) { + return nil + } + return err +} + +func (writer *snapshotTempFile) fail(err error) error { + _ = writer.Abort() + return err +} + +func validateSnapshotDestination(path string, force bool) error { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("inspect snapshot output %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing symlink snapshot output %s", path) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("snapshot output %s is not a regular file", path) + } + if !force { + return fmt.Errorf("snapshot output %s already exists", path) + } + return nil +} + +func snapshotWriter(stdout io.Writer, path string, force bool) (io.Writer, func() error, error) { + if path == "" { + return stdout, nil, nil + } + if err := validateSnapshotDestination(path, force); err != nil { + return nil, nil, err + } + directory := filepath.Dir(path) + base := filepath.Base(path) + file, err := os.CreateTemp(directory, "."+base+".tmp-*") + if err != nil { + return nil, nil, fmt.Errorf("create temporary snapshot in %s: %w", directory, err) + } + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + _ = os.Remove(file.Name()) + return nil, nil, fmt.Errorf("secure temporary snapshot %s: %w", file.Name(), err) + } + temporary := &snapshotTempFile{file: file, tempPath: file.Name(), destination: path, force: force} + return temporary, temporary.Commit, nil +} + +func parseRemotes(output string) map[string][]string { + remotes := make(map[string][]string) + for _, line := range strings.Split(output, "\n") { + fields := strings.SplitN(line, "\t", 2) + if len(fields) != 2 { + continue + } + name := strings.TrimSpace(fields[0]) + remoteURL := strings.TrimSpace(fields[1]) + remoteURL = strings.TrimSuffix(remoteURL, " (fetch)") + remoteURL = strings.TrimSuffix(remoteURL, " (push)") + if name == "" || remoteURL == "" { + continue + } + remoteURL = redactRemoteURL(remoteURL) + seen := false + for _, existing := range remotes[name] { + if existing == remoteURL { + seen = true + break + } + } + if !seen { + remotes[name] = append(remotes[name], remoteURL) + } + } + return remotes +} + +func redactRemoteURL(raw string) string { + if safe, handled := redactSCPRemote(raw); handled { + return safe + } + parsed, err := url.Parse(raw) + if err != nil { + // An invalid escape can make URL parsing fail before userinfo or + // query credentials are exposed. Never return the original value + // in that case: a snapshot must fail closed. + return "REDACTED" + } + if parsed.Scheme == "" { + if strings.Contains(raw, "?") { + return "REDACTED" + } + return raw + } + if parsed.User != nil { + username := parsed.User.Username() + _, hasPassword := parsed.User.Password() + if parsed.Scheme == "http" || parsed.Scheme == "https" { + username = "REDACTED" + } + if hasPassword { + parsed.User = url.UserPassword(username, "REDACTED") + } else { + parsed.User = url.User(username) + } + } + query := parsed.Query() + if parsed.Scheme == "file" && parsed.User == nil && len(query) == 0 { + // Keep local paths exactly as Git reported them; url.URL.String + // would percent-encode spaces in an otherwise non-sensitive path. + return raw + } + for key := range query { + normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_")) + if strings.Contains(normalized, "token") || + strings.Contains(normalized, "auth") || + strings.Contains(normalized, "password") || + strings.Contains(normalized, "passwd") || + strings.Contains(normalized, "secret") || + strings.Contains(normalized, "signature") || + strings.Contains(normalized, "credential") || + normalized == "key" || strings.HasSuffix(normalized, "_key") || normalized == "sig" { + query.Set(key, "REDACTED") + } + } + parsed.RawQuery = query.Encode() + return parsed.String() +} + +// redactSCPRemote handles Git's scp-like SSH syntax, which net/url rejects: +// user@host:path. There is no password field in this syntax, so an ordinary +// user/host/path remains useful. Suspicious userinfo or query material is +// replaced completely rather than returned unparsed. +func redactSCPRemote(raw string) (string, bool) { + if strings.Contains(raw, "://") { + return "", false + } + at := strings.IndexByte(raw, '@') + if at <= 0 || at == len(raw)-1 { + return "", false + } + colon := strings.IndexByte(raw[at+1:], ':') + if colon < 1 { + return "", false + } + user := raw[:at] + hostPath := raw[at+1:] + if strings.ContainsAny(user, "/?#") { + return "", false + } + if strings.Contains(user, ":") || strings.Contains(hostPath, "?") { + return "REDACTED", true + } + return raw, true +} diff --git a/cmd/snapshot_command_test.go b/cmd/snapshot_command_test.go new file mode 100644 index 0000000..77fb960 --- /dev/null +++ b/cmd/snapshot_command_test.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestParseRemotesRedactsCredentialsAndSecrets(t *testing.T) { + output := strings.Join([]string{ + "origin\thttps://alice:password@example.com/project.git (fetch)", + "origin\thttps://alice:password@example.com/project.git (push)", + "token\thttps://example.com/project.git?access_token=abc&mode=ro (fetch)", + "ssh\tgit@example.com:project.git (fetch)", + }, "\n") + got := parseRemotes(output) + want := map[string][]string{ + "origin": {"https://REDACTED:REDACTED@example.com/project.git"}, + "token": {"https://example.com/project.git?access_token=REDACTED&mode=ro"}, + "ssh": {"git@example.com:project.git"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsed remotes = %#v, want %#v", got, want) + } + if strings.Contains(strings.Join(got["origin"], " "), "password") || strings.Contains(strings.Join(got["token"], " "), "abc") { + t.Fatal("remote credentials or token leaked") + } +} + +func TestParseRemotesPreservesSpacesInTabDelimitedURLs(t *testing.T) { + got := parseRemotes("origin\tfile:///tmp/project with spaces (fetch)\n") + want := map[string][]string{"origin": {"file:///tmp/project with spaces"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsed remotes = %#v, want %#v", got, want) + } +} + +func TestRedactRemoteURLHandlesQuerySecretVariants(t *testing.T) { + for _, input := range []string{ + "https://example.test/repo?token=abc", + "https://example.test/repo?AUTHORIZATION=abc", + "https://token-value@example.test/repo", + "https://example.test/repo?X-Amz-Credential=abc", + "https://user:password@example.test/repo", + } { + got := redactRemoteURL(input) + if strings.Contains(got, "abc") || strings.Contains(got, "password") || strings.Contains(got, "token-value") { + t.Errorf("redacted URL %q still contains secret: %q", input, got) + } + } +} + +func TestRedactRemoteURLFailsClosedOnInvalidEscapes(t *testing.T) { + raw := "https://alice:SENSITIVE_VALUE@example.invalid/%zz" + got := redactRemoteURL(raw) + if got == raw || strings.Contains(got, "SENSITIVE_VALUE") || strings.Contains(got, "alice") { + t.Fatalf("invalid remote URL was not redacted: %q", got) + } +} + +func TestRedactRemoteURLFailsClosedOnSchemeLessSecretQuery(t *testing.T) { + raw := "origin/path?token=SENSITIVE_VALUE" + got := redactRemoteURL(raw) + if strings.Contains(got, "SENSITIVE_VALUE") { + t.Fatalf("scheme-less remote query leaked secret: %q", got) + } +} + +func TestRedactRemoteURLPreservesSafeSCPRemotes(t *testing.T) { + const raw = "git@github.com:org/repo.git" + if got := redactRemoteURL(raw); got != raw { + t.Fatalf("safe SCP remote = %q, want %q", got, raw) + } + if got := redactRemoteURL("alice:secret@github.com:org/repo.git"); strings.Contains(got, "secret") { + t.Fatalf("SCP-like credential leaked: %q", got) + } +} + +func TestSnapshotWriterUsesPrivateModeAndRefusesOverwrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot.json") + if err := os.WriteFile(path, []byte("original"), 0o644); err != nil { + t.Fatalf("write existing output: %v", err) + } + if _, closeWriter, err := snapshotWriter(nil, path, false); err == nil { + if closeWriter != nil { + _ = closeWriter() + } + t.Fatal("snapshotWriter unexpectedly overwrote existing file") + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read existing output: %v", err) + } + if string(contents) != "original" { + t.Fatalf("existing output changed to %q", contents) + } + + newPath := filepath.Join(t.TempDir(), "new.json") + writer, closeWriter, err := snapshotWriter(nil, newPath, false) + if err != nil { + t.Fatalf("snapshotWriter returned error: %v", err) + } + temporary := writer.(*snapshotTempFile) + temporaryPath := temporary.tempPath + if _, err := writer.Write([]byte("new")); err != nil { + t.Fatalf("write snapshot: %v", err) + } + if err := closeWriter(); err != nil { + t.Fatalf("close snapshot: %v", err) + } + if temporary.tempPath != "" { + t.Fatalf("temporary path after commit = %q, want empty", temporary.tempPath) + } + if _, err := os.Stat(temporaryPath); !os.IsNotExist(err) { + t.Fatalf("temporary snapshot still exists: %v", err) + } + info, err := os.Stat(newPath) + if err != nil { + t.Fatalf("stat snapshot: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("snapshot mode = %#o, want 0600", mode) + } + + writer, closeWriter, err = snapshotWriter(nil, newPath, true) + if err != nil { + t.Fatalf("force snapshotWriter returned error: %v", err) + } + if _, err := writer.Write([]byte("forced")); err != nil { + t.Fatalf("write forced snapshot: %v", err) + } + if err := closeWriter(); err != nil { + t.Fatalf("close forced snapshot: %v", err) + } + contents, err = os.ReadFile(newPath) + if err != nil { + t.Fatalf("read forced snapshot: %v", err) + } + if string(contents) != "forced" { + t.Fatalf("forced snapshot contents = %q, want forced", contents) + } +} + +func TestSnapshotOptionErrorsHaveExpectedExitCodes(t *testing.T) { + empty := appWithRepositories(repository.Result{}) + invalid := runCLI(t, empty, "snapshot", "--format", "invalid") + var coded interface{ ExitCode() int } + if invalid.err == nil || !errors.As(invalid.err, &coded) || coded.ExitCode() != 2 { + t.Fatalf("invalid snapshot format error = %v, want exit code 2", invalid.err) + } + + missing := runCLI(t, empty, "snapshot", "--require-match") + if missing.err == nil || !errors.As(missing.err, &coded) || coded.ExitCode() != 1 { + t.Fatalf("snapshot require-match error = %v, want exit code 1", missing.err) + } +} + +func TestSnapshotWithoutMatchesDoesNotRequireGit(t *testing.T) { + result := runCLI(t, appWithRepositories(repository.Result{}), "snapshot", "--format", "json") + if result.err != nil { + t.Fatalf("empty snapshot returned error: %v", result.err) + } + var document snapshotDocument + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("empty snapshot is not valid JSON: %v\n%s", err, result.stdout) + } + if document.SchemaVersion != outputSchemaVersion || len(document.Repositories) != 0 { + t.Fatalf("empty snapshot document = %#v", document) + } +} + +func TestSnapshotWriterRefusesSymlinkDestination(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires platform support") + } + directory := t.TempDir() + target := filepath.Join(directory, "target.json") + link := filepath.Join(directory, "snapshot.json") + if err := os.WriteFile(target, []byte("target"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatalf("create destination symlink: %v", err) + } + if _, _, err := snapshotWriter(nil, link, true); err == nil { + t.Fatal("snapshotWriter accepted a symlink destination") + } + contents, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(contents) != "target" { + t.Fatalf("symlink target changed to %q", contents) + } +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..23201ef --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/ramonzx6/gitmulti/internal/gitstatus" + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +const outputSchemaVersion = 1 + +type statusDocument struct { + SchemaVersion int `json:"schemaVersion"` + Repositories []gitstatus.State `json:"repositories"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} + +type statusLine struct { + Type string `json:"type"` + Status *gitstatus.State `json:"status,omitempty"` + Path string `json:"path,omitempty"` + Message string `json:"message,omitempty"` +} + +func newStatusCommand(app App) *cobra.Command { + var scan scanFlags + var dirtyOnly bool + var format string + var noAheadBehind bool + var jobs int + var requireMatch bool + + command := &cobra.Command{ + Use: "status", + Short: "Show an aggregate repository status dashboard", + Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if jobs < 1 { + return errorsForFlag("--jobs must be at least 1") + } + if format != "table" && format != "json" && format != "ndjson" { + return errorsForFlag(fmt.Sprintf("unsupported status format %q", format)) + } + discovered, err := discoverRepositories(command.Context(), app, command, scan) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 && requireMatch { + return newExitError(1, "no Git repositories matched") + } + states := collectStatuses(command.Context(), app, discovered.Repositories, jobs, noAheadBehind) + if dirtyOnly { + filtered := states[:0] + for _, state := range states { + if state.Dirty || state.Err != nil { + filtered = append(filtered, state) + } + } + states = filtered + } + if err := renderStatuses(command, states, discovered.Warnings, format, noAheadBehind); err != nil { + return err + } + failures := statusErrorCount(states) + if failures > 0 { + return newExitError(1, "status collection failed for %d repositories", failures) + } + return nil + }, + } + scan.bind(command) + command.Flags().BoolVar(&dirtyOnly, "dirty-only", false, "show only dirty repositories") + command.Flags().StringVar(&format, "format", "table", "output format: table, json, or ndjson") + command.Flags().BoolVar(&noAheadBehind, "no-ahead-behind", false, "hide ahead and behind counts") + command.Flags().IntVarP(&jobs, "jobs", "j", 4, "maximum concurrent status checks") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} + +func collectStatuses(ctx context.Context, app App, repositories []repository.Repository, jobs int, noAheadBehind ...bool) []gitstatus.State { + states := make([]gitstatus.State, len(repositories)) + if len(repositories) == 0 { + return states + } + workerCount := jobs + if workerCount < 1 { + workerCount = 1 + } + if workerCount > len(repositories) { + workerCount = len(repositories) + } + indices := make(chan int) + collector := gitstatus.NewCollector(app.Git) + options := gitstatus.CollectOptions{} + if len(noAheadBehind) > 0 && noAheadBehind[0] { + options.NoAheadBehind = true + } + var wait sync.WaitGroup + for worker := 0; worker < workerCount; worker++ { + wait.Add(1) + go func() { + defer wait.Done() + for index := range indices { + repo := repositories[index] + states[index] = collector.CollectWithOptions(ctx, repo.Name, repo.Path, options) + } + }() + } + for index := range repositories { + indices <- index + } + close(indices) + wait.Wait() + return states +} + +func statusErrorCount(states []gitstatus.State) int { + count := 0 + for _, state := range states { + if state.Err != nil { + count++ + } + } + return count +} + +func renderStatuses(command *cobra.Command, states []gitstatus.State, warnings []repository.Warning, format string, hideAheadBehind bool) error { + switch format { + case "json": + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(statusDocument{SchemaVersion: outputSchemaVersion, Repositories: states, Diagnostics: warnings}) + case "ndjson": + encoder := json.NewEncoder(command.OutOrStdout()) + for index := range states { + if err := encoder.Encode(statusLine{Type: "status", Status: &states[index]}); err != nil { + return err + } + } + for _, warning := range warnings { + if err := encoder.Encode(statusLine{Type: "diagnostic", Path: warning.Path, Message: warning.Message}); err != nil { + return err + } + } + return nil + case "table": + if hideAheadBehind { + if _, err := fmt.Fprintln(command.OutOrStdout(), "REPOSITORY\tBRANCH\tSTATE\tUPSTREAM"); err != nil { + return err + } + } else if _, err := fmt.Fprintln(command.OutOrStdout(), "REPOSITORY\tBRANCH\tSTATE\tUPSTREAM\tAHEAD\tBEHIND"); err != nil { + return err + } + for _, state := range states { + status := "clean" + if state.Dirty { + status = "dirty" + } + if state.Detached { + status = "detached" + } + if state.Err != nil { + status = "error" + } + branch := state.Branch + if branch == "" { + branch = "-" + } + upstream := state.Upstream + if upstream == "" { + upstream = "-" + } + ahead, behind := "-", "-" + if state.AheadBehindKnown { + ahead = fmt.Sprintf("%d", state.Ahead) + behind = fmt.Sprintf("%d", state.Behind) + } + if hideAheadBehind { + if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%s\t%s\n", state.Name, branch, status, upstream); err != nil { + return err + } + } else if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%s\t%s\t%s\t%s\n", state.Name, branch, status, upstream, ahead, behind); err != nil { + return err + } + if state.Err != nil { + if _, err := fmt.Fprintf(command.ErrOrStderr(), "%s: %v\n", state.Name, state.Err); err != nil { + return err + } + } + } + return nil + default: + return errorsForFlag(fmt.Sprintf("unsupported status format %q", format)) + } +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..7022574 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +func newSyncCommand(app App) *cobra.Command { + var scan scanFlags + var mode string + var remote string + var prune bool + var tags bool + var dryRun bool + var jobs int + var failFast bool + var timeout time.Duration + var noPrompt bool + var output string + var requireMatch bool + + command := &cobra.Command{ + Use: "sync", + Short: "Safely fetch or fast-forward repositories", + Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if mode != "fetch" && mode != "ff-only" { + return errorsForFlag(fmt.Sprintf("unsupported sync mode %q", mode)) + } + if jobs < 1 { + return errorsForFlag("--jobs must be at least 1") + } + if timeout < 0 { + return errorsForFlag("--timeout cannot be negative") + } + if strings.HasPrefix(remote, "-") { + return errorsForFlag("--remote cannot begin with '-'") + } + runApp := app + if noPrompt { + runApp.Git = runApp.Git.WithEnv("GIT_TERMINAL_PROMPT=0") + } + if !validExecOutput(output) { + return errorsForFlag(fmt.Sprintf("unsupported output mode %q", output)) + } + if output == "raw" && jobs != 1 { + return errorsForFlag("raw output requires --jobs=1") + } + + discovered, err := discoverRepositories(command.Context(), app, command, scan) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 { + if requireMatch { + return newExitError(1, "no Git repositories matched") + } + return nil + } + args := syncArgs(mode, remote, prune, tags) + if dryRun { + for _, repo := range discovered.Repositories { + if _, err := fmt.Fprintln(command.OutOrStdout(), displayCommand(repo.Path, args)); err != nil { + return err + } + } + return nil + } + if _, err := runApp.Git.LookPath(); err != nil { + return err + } + + var records []execRecord + if mode == "ff-only" { + records = executeSafeFastForward(command, runApp, discovered.Repositories, args, jobs, timeout, failFast, output) + } else { + records = executeAcross(command, runApp, discovered.Repositories, args, jobs, timeout, failFast, output, false) + } + if err := renderExecRecords(command, records, output, false, discovered.Warnings); err != nil { + return err + } + if output == "raw" { + for _, record := range records { + if record.Error != "" { + if _, err := fmt.Fprintf(command.ErrOrStderr(), "%s: %s\n", record.Repository, record.Error); err != nil { + return err + } + } + } + } + failures := 0 + skipped := 0 + for _, record := range records { + if record.Skipped { + skipped++ + } else if record.Error != "" { + failures++ + } + } + if failures > 0 || skipped > 0 { + return newExitError(1, "sync failed or was unsafe in %d and skipped %d of %d repositories", failures, skipped, len(records)) + } + return nil + }, + } + scan.bind(command) + command.Flags().StringVar(&mode, "mode", "fetch", "sync mode: fetch or ff-only") + command.Flags().StringVar(&remote, "remote", "", "specific remote to use; fetch mode defaults to --all") + command.Flags().BoolVar(&prune, "prune", true, "prune deleted remote-tracking references") + command.Flags().BoolVar(&tags, "tags", false, "fetch tags") + command.Flags().BoolVar(&dryRun, "dry-run", false, "print commands without running Git or safety checks") + command.Flags().IntVarP(&jobs, "jobs", "j", 1, "maximum concurrent Git processes") + command.Flags().BoolVar(&failFast, "fail-fast", false, "cancel remaining work after the first failure") + command.Flags().DurationVar(&timeout, "timeout", 0, "per-repository command timeout") + command.Flags().BoolVar(&noPrompt, "no-prompt", false, "disable Git credential prompts for network operations") + command.Flags().StringVar(&output, "output", "grouped", "output mode: grouped, prefixed, raw, json, or ndjson") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} + +func syncArgs(mode, remote string, prune, tags bool) []string { + args := []string{} + if mode == "fetch" { + args = append(args, "fetch") + if prune { + args = append(args, "--prune") + } + if tags { + args = append(args, "--tags") + } + if remote == "" { + args = append(args, "--all") + } else { + args = append(args, remote) + } + return args + } + args = append(args, "pull", "--ff-only") + if prune { + args = append(args, "--prune") + } + if tags { + args = append(args, "--tags") + } + if remote != "" { + args = append(args, remote) + } + return args +} + +func executeSafeFastForward(command *cobra.Command, app App, repositories []repository.Repository, args []string, jobs int, timeout time.Duration, failFast bool, output string) []execRecord { + states := collectStatuses(command.Context(), app, repositories, jobs) + records := make([]execRecord, len(repositories)) + safe := make([]repository.Repository, 0, len(repositories)) + indices := make([]int, 0, len(repositories)) + preflightFailure := false + for index, state := range states { + records[index] = execRecord{Repository: repositories[index].Name, Path: repositories[index].Path, Command: append([]string(nil), args...), ExitCode: -1} + reason := "" + switch { + case state.Err != nil: + reason = state.Err.Error() + case state.Dirty: + reason = "working tree is dirty" + case state.Detached: + reason = "HEAD is detached" + case state.Unborn: + reason = "branch has no commits" + case state.NoUpstream: + reason = "branch has no upstream" + } + if reason != "" { + records[index].Error = "refusing ff-only sync: " + reason + if failFast { + preflightFailure = true + } + continue + } + if preflightFailure { + records[index].Error = "not started after fail-fast" + records[index].Skipped = true + continue + } + safe = append(safe, repositories[index]) + indices = append(indices, index) + } + if len(safe) == 0 { + return records + } + executed := executeAcross(command, app, safe, args, jobs, timeout, failFast, output, false) + for index, result := range executed { + records[indices[index]] = result + } + return records +} diff --git a/cmd/sync_completion_test.go b/cmd/sync_completion_test.go new file mode 100644 index 0000000..0172cbc --- /dev/null +++ b/cmd/sync_completion_test.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func TestSyncArgsBuildsSafeFetchAndFastForwardCommands(t *testing.T) { + tests := []struct { + name string + mode string + remote string + prune bool + tags bool + want []string + }{ + {name: "all fetch", mode: "fetch", prune: true, want: []string{"fetch", "--prune", "--all"}}, + {name: "remote fetch with tags", mode: "fetch", remote: "origin", prune: false, tags: true, want: []string{"fetch", "--tags", "origin"}}, + {name: "ff only", mode: "ff-only", prune: true, want: []string{"pull", "--ff-only", "--prune"}}, + {name: "ff only remote tags", mode: "ff-only", remote: "upstream", prune: false, tags: true, want: []string{"pull", "--ff-only", "--tags", "upstream"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := syncArgs(test.mode, test.remote, test.prune, test.tags); !reflect.DeepEqual(got, test.want) { + t.Fatalf("syncArgs = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestSyncDryRunDoesNotInvokeGit(t *testing.T) { + fixture := fixtureRepositories(t.TempDir(), "repo") + app := appWithRepositories(fixture) + app.Git.Executable = "definitely-not-installed-gitmulti-sync" + result := runCLI(t, app, "sync", "--dry-run", "--mode", "fetch") + if result.err != nil { + t.Fatalf("sync dry-run returned error: %v", result.err) + } + if !strings.Contains(result.stdout, `git -C "`) || !strings.Contains(result.stdout, `"fetch" "--prune" "--all"`) { + t.Fatalf("sync dry-run output = %q, want fetch command", result.stdout) + } +} + +func TestSyncValidatesModeAndOutput(t *testing.T) { + fixture := fixtureRepositories(t.TempDir(), "repo") + for _, test := range []struct { + args []string + want string + }{ + {args: []string{"sync", "--mode", "merge", "--dry-run"}, want: "unsupported sync mode"}, + {args: []string{"sync", "--output", "invalid", "--dry-run"}, want: "unsupported output mode"}, + {args: []string{"sync", "--jobs", "0", "--dry-run"}, want: "--jobs must be at least 1"}, + {args: []string{"sync", "--timeout=-1s", "--dry-run"}, want: "--timeout cannot be negative"}, + {args: []string{"sync", "--remote", "--all", "--dry-run"}, want: "--remote cannot begin"}, + } { + result := runCLI(t, appWithRepositories(fixture), test.args...) + if result.err == nil || !strings.Contains(result.err.Error(), test.want) { + t.Errorf("args %v error = %v, want %q", test.args, result.err, test.want) + } + } +} + +func TestSyncRawReportsFFOnlyRefusalsAndFailFastSkipsEligibleRepos(t *testing.T) { + app, _ := fakeStatusApp(t, "dirty", "clean") + result := runCLI(t, app, "sync", "--mode", "ff-only", "--output", "raw", "--fail-fast") + if result.err == nil || !strings.Contains(result.err.Error(), "unsafe") { + t.Fatalf("ff-only raw error = %v", result.err) + } + if !strings.Contains(result.stderr, "dirty: refusing ff-only sync") || !strings.Contains(result.stderr, "clean: not started after fail-fast") { + t.Fatalf("ff-only raw diagnostics = %q", result.stderr) + } + if strings.Contains(result.stdout, "synced-clean") { + t.Fatalf("fail-fast raw sync executed an eligible repository after refusal: %q", result.stdout) + } +} + +func TestSyncNoPromptSetsGitTerminalPrompt(t *testing.T) { + app, _ := fakeStatusApp(t, "prompt-check") + result := runCLI(t, app, "sync", "--no-prompt", "--mode", "fetch") + if result.err != nil { + t.Fatalf("sync --no-prompt returned error: %v\nstdout=%q\nstderr=%q", result.err, result.stdout, result.stderr) + } +} + +func TestCompletionRejectsUnsupportedShell(t *testing.T) { + result := runCLI(t, discardApp(), "completion", "tcsh") + if result.err == nil || !strings.Contains(result.err.Error(), "unsupported shell") { + t.Fatalf("unsupported completion shell error = %v", result.err) + } + var coded interface{ ExitCode() int } + if !errors.As(result.err, &coded) || coded.ExitCode() != 2 { + t.Fatalf("unsupported completion shell error = %v, want exit code 2", result.err) + } +} diff --git a/cmd/utils.go b/cmd/utils.go index f4015f1..b394e90 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -3,112 +3,79 @@ package cmd import ( "errors" "fmt" - "os" - "os/exec" - "path/filepath" + "strconv" "strings" "github.com/google/shlex" + "github.com/spf13/cobra" ) -var ( - listPath string - listRecursive bool - execPath string - execRecursive bool - execCommand string - versionFlag bool - version string = "0.1.0" // version of the gitmulti CLI tool -) - -type repository struct { - Name string - Path string -} - -// isGitRepository checks if a directory contains a .git directory or file. -func isGitRepository(dirPath string) bool { - gitPath := filepath.Join(dirPath, ".git") - _, err := os.Stat(gitPath) - return err == nil -} - -// findRepositories scans child directories of rootPath for git repositories. -func findRepositories(rootPath string, recursive bool) ([]repository, error) { - rootPath = filepath.Clean(rootPath) - repositories := []repository{} - - var scan func(string) error - scan = func(parentPath string) error { - entries, err := os.ReadDir(parentPath) - if err != nil { - return fmt.Errorf("failed to read directory %s: %w", parentPath, err) - } - - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - - subDirPath := filepath.Join(parentPath, entry.Name()) - if isGitRepository(subDirPath) { - repoName, err := filepath.Rel(rootPath, subDirPath) - if err != nil { - repoName = entry.Name() - } - repositories = append(repositories, repository{ - Name: repoName, - Path: subDirPath, - }) - } - - if recursive { - if err := scan(subDirPath); err != nil { - return err - } - } - } - - return nil - } - - if err := scan(rootPath); err != nil { - return nil, err - } - - return repositories, nil -} - func parseGitCommand(command string, args []string) ([]string, error) { trimmedCommand := strings.TrimSpace(command) if trimmedCommand != "" && len(args) > 0 { - return nil, errors.New("use either --command or positional git arguments, not both") + return nil, errors.New("use either --command or positional Git arguments, not both") } - if trimmedCommand == "" { if len(args) == 0 { - return nil, errors.New("git command cannot be empty") + return nil, errors.New("Git command cannot be empty") } - return args, nil + return append([]string(nil), args...), nil } - parsedArgs, err := shlex.Split(trimmedCommand) if err != nil { - return nil, fmt.Errorf("invalid git command %q: %w", command, err) + return nil, fmt.Errorf("invalid Git command %q: %w", command, err) + } + if len(parsedArgs) == 0 { + return nil, errors.New("Git command cannot be empty") } - return parsedArgs, nil } -// executeGitCommand executes a git command on a given directory. -func executeGitCommand(dirPath string, args []string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Dir = dirPath +type exitError struct { + code int + message string +} - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), err +func (err exitError) Error() string { return err.message } +func (err exitError) ExitCode() int { return err.code } + +func newExitError(code int, format string, args ...any) error { + return exitError{code: code, message: fmt.Sprintf(format, args...)} +} + +func displayCommand(dir string, args []string) string { + parts := []string{"git", "-C", strconv.Quote(dir)} + for _, arg := range args { + parts = append(parts, strconv.Quote(arg)) + } + return strings.Join(parts, " ") +} + +func splitValues(values []string) []string { + var result []string + for _, value := range values { + for _, item := range strings.Split(value, ",") { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } } + return result +} - return string(output), nil +func noArgs(command *cobra.Command, args []string) error { + if err := cobra.NoArgs(command, args); err != nil { + return errorsForFlag(err.Error()) + } + return nil +} + +func exactArgs(count int) cobra.PositionalArgs { + return func(command *cobra.Command, args []string) error { + if err := cobra.ExactArgs(count)(command, args); err != nil { + return errorsForFlag(err.Error()) + } + return nil + } } diff --git a/cmd/utils_test.go b/cmd/utils_test.go deleted file mode 100644 index 29be40b..0000000 --- a/cmd/utils_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestFindRepositoriesScansOnlyChildrenByDefault(t *testing.T) { - root := t.TempDir() - mustWriteFile(t, filepath.Join(root, ".git")) - mustMkdir(t, filepath.Join(root, "repo", ".git")) - mustMkdir(t, filepath.Join(root, "repo", "nested", ".git")) - - repositories, err := findRepositories(root, false) - if err != nil { - t.Fatalf("findRepositories returned error: %v", err) - } - - got := repositoryNames(repositories) - want := []string{"repo"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("repository names = %v, want %v", got, want) - } -} - -func TestFindRepositoriesScansRecursivelyWhenEnabled(t *testing.T) { - root := t.TempDir() - mustMkdir(t, filepath.Join(root, "repo", ".git")) - mustWriteFile(t, filepath.Join(root, "repo", "nested", ".git")) - - repositories, err := findRepositories(root, true) - if err != nil { - t.Fatalf("findRepositories returned error: %v", err) - } - - got := repositoryNames(repositories) - want := []string{"repo", filepath.Join("repo", "nested")} - if !reflect.DeepEqual(got, want) { - t.Fatalf("repository names = %v, want %v", got, want) - } -} - -func TestParseGitCommandSupportsFlagCommand(t *testing.T) { - got, err := parseGitCommand(`commit -m "hello world"`, nil) - if err != nil { - t.Fatalf("parseGitCommand returned error: %v", err) - } - - want := []string{"commit", "-m", "hello world"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("parsed args = %v, want %v", got, want) - } -} - -func TestParseGitCommandSupportsPositionalArgs(t *testing.T) { - args := []string{"status", "--short"} - got, err := parseGitCommand("", args) - if err != nil { - t.Fatalf("parseGitCommand returned error: %v", err) - } - - if !reflect.DeepEqual(got, args) { - t.Fatalf("parsed args = %v, want %v", got, args) - } -} - -func TestParseGitCommandRejectsEmptyCommand(t *testing.T) { - if _, err := parseGitCommand("", nil); err == nil { - t.Fatal("expected empty command error") - } -} - -func TestParseGitCommandRejectsMixedCommandStyles(t *testing.T) { - if _, err := parseGitCommand("status", []string{"log"}); err == nil { - t.Fatal("expected mixed command styles error") - } -} - -func repositoryNames(repositories []repository) []string { - names := make([]string, 0, len(repositories)) - for _, repository := range repositories { - names = append(names, repository.Name) - } - return names -} - -func mustMkdir(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(path, 0755); err != nil { - t.Fatalf("failed to create directory %s: %v", path, err) - } -} - -func mustWriteFile(t *testing.T, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatalf("failed to create parent directory for %s: %v", path, err) - } - if err := os.WriteFile(path, []byte("gitdir: .git"), 0644); err != nil { - t.Fatalf("failed to write file %s: %v", path, err) - } -} diff --git a/cmd/version.go b/cmd/version.go index 2d35c25..1fecb76 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,21 +1,40 @@ package cmd import ( + "encoding/json" "fmt" "github.com/spf13/cobra" ) -var versionCmd = &cobra.Command{ - Use: "version", - Aliases: []string{"v"}, - Short: "Print the version number of gitmulti", - Long: "Print the version number of gitmulti CLI tool", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("gitmulti version " + version) - }, -} +func newVersionCommand(app App, info buildInfo) *cobra.Command { + var short bool + var asJSON bool -func init() { - rootCmd.AddCommand(versionCmd) + command := &cobra.Command{ + Use: "version", + Aliases: []string{"v"}, + Short: "Print version and build information", + Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if short && asJSON { + return errorsForFlag("--short and --json cannot be used together") + } + switch { + case asJSON: + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(info) + case short: + _, err := fmt.Fprintln(command.OutOrStdout(), info.Version) + return err + default: + _, err := fmt.Fprintf(command.OutOrStdout(), "gitmulti version %s\ncommit: %s\nbuilt: %s\ndirty: %t\ngo: %s\n", info.Version, info.Commit, info.BuildDate, info.Dirty, info.GoVersion) + return err + } + }, + } + command.Flags().BoolVar(&short, "short", false, "print only the version") + command.Flags().BoolVar(&asJSON, "json", false, "print build information as JSON") + return command } diff --git a/go.mod b/go.mod index 62bcee9..a823373 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitmulti +module github.com/ramonzx6/gitmulti go 1.24.0 diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go new file mode 100644 index 0000000..c660b69 --- /dev/null +++ b/internal/gitexec/gitexec.go @@ -0,0 +1,249 @@ +// Package gitexec contains the small process-execution boundary used by +// gitmulti. It deliberately executes Git directly, without invoking a shell. +package gitexec + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +const defaultWaitDelay = 250 * time.Millisecond + +// Runner executes Git commands. An empty Executable uses the git found on the +// current PATH, which makes the zero value ready for use. +// +// Executable may be a command name (for example, "git") or a path to a Git +// executable. Keeping this as a field also makes tests able to point a Runner +// at a deterministic fake executable. +type Runner struct { + Executable string + // Env contains KEY=VALUE overrides added to the child environment. + Env []string +} + +// New returns a Runner configured for executable. An empty value means git. +func New(executable string) Runner { + return Runner{Executable: executable} +} + +// NewRunner is an alias for New for callers that prefer a constructor whose +// name describes the returned value. +func NewRunner(executable string) Runner { + return New(executable) +} + +// WithEnv returns a copy of the runner with additional child-environment +// overrides. Later values replace earlier values with the same key. +func (r Runner) WithEnv(values ...string) Runner { + r.Env = append(append([]string(nil), r.Env...), values...) + return r +} + +func (r Runner) executable() string { + if strings.TrimSpace(r.Executable) == "" { + return "git" + } + return r.Executable +} + +// LookPath resolves the configured executable using the same PATH semantics +// as os/exec. The returned path is suitable for diagnostics or preflight +// reporting. +func (r Runner) LookPath() (string, error) { + executable := r.executable() + path, err := exec.LookPath(executable) + if err != nil { + return "", fmt.Errorf("find git executable %q: %w", executable, err) + } + return path, nil +} + +// Preflight verifies that the configured executable can be resolved. It does +// not run a Git command and therefore has no side effects. The optional +// context is accepted for compatibility with callers that make all checks +// context-aware; LookPath itself is necessarily synchronous. +func (r Runner) Preflight(contexts ...context.Context) error { + if len(contexts) > 0 { + if ctx := contexts[0]; ctx != nil { + if err := ctx.Err(); err != nil { + return err + } + } + } + _, err := r.LookPath() + return err +} + +// Result is the complete outcome of one Git process. +// +// Stdout and Stderr remain separate even when Err is non-nil. ExitCode is -1 +// when the process could not be started or was terminated before it produced +// an exit status (for example, because its context was cancelled). +type Result struct { + Stdout string + Stderr string + Err error + ExitCode int + Duration time.Duration +} + +// CommandError describes a command that could not be started or exited with +// an error. It unwraps the original process error, allowing callers to use +// errors.Is/errors.As for context and *exec.ExitError checks. +type CommandError struct { + Executable string + Args []string + Dir string + ExitCode int + Err error +} + +func (e *CommandError) Error() string { + if e == nil { + return "" + } + command := e.Executable + if len(e.Args) > 0 { + command += " " + strings.Join(e.Args, " ") + } + if e.Dir != "" { + return fmt.Sprintf("run %s in %s: %v", command, e.Dir, e.Err) + } + return fmt.Sprintf("run %s: %v", command, e.Err) +} + +func (e *CommandError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// Run executes args in dir and buffers stdout and stderr independently. stdin +// may be nil. The returned Result is populated for both successful and failed +// commands; callers should inspect Result.Err instead of losing the output +// when Git exits non-zero. +func (r Runner) Run(ctx context.Context, dir string, args []string, stdin io.Reader) Result { + started := time.Now() + result := Result{ExitCode: -1} + if ctx == nil { + ctx = context.Background() + } + + command := exec.CommandContext(ctx, r.executable(), args...) + command.Dir = dir + command.Stdin = stdin + command.WaitDelay = defaultWaitDelay + command.Env = mergedEnvironment(r.Env) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + + err := command.Run() + result.Duration = time.Since(started) + result.Stdout = stdout.String() + result.Stderr = stderr.String() + result.ExitCode = exitCode(err, ctx) + result.Err = commandError(r.executable(), args, dir, result.ExitCode, err, ctx) + return result +} + +// Stream executes args in dir while copying stdout and stderr directly to +// their respective writers. Unlike Run, it does not retain output in memory; +// Result.Stdout and Result.Stderr are empty. A nil writer discards that stream. +// stdin may be nil. +func (r Runner) Stream(ctx context.Context, dir string, args []string, stdin io.Reader, stdout, stderr io.Writer) Result { + started := time.Now() + result := Result{ExitCode: -1} + if ctx == nil { + ctx = context.Background() + } + + command := exec.CommandContext(ctx, r.executable(), args...) + command.Dir = dir + command.Stdin = stdin + command.WaitDelay = defaultWaitDelay + command.Env = mergedEnvironment(r.Env) + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + command.Stdout = stdout + command.Stderr = stderr + + err := command.Run() + result.Duration = time.Since(started) + result.ExitCode = exitCode(err, ctx) + result.Err = commandError(r.executable(), args, dir, result.ExitCode, err, ctx) + return result +} + +func mergedEnvironment(overrides []string) []string { + if len(overrides) == 0 { + return nil + } + environment := os.Environ() + for _, override := range overrides { + key, _, ok := strings.Cut(override, "=") + if !ok || key == "" { + continue + } + filtered := environment[:0] + for _, existing := range environment { + existingKey, _, _ := strings.Cut(existing, "=") + equal := existingKey == key + if runtime.GOOS == "windows" { + equal = strings.EqualFold(existingKey, key) + } + if !equal { + filtered = append(filtered, existing) + } + } + environment = append(filtered, override) + } + return environment +} + +func exitCode(err error, ctx context.Context) int { + if err == nil { + return 0 + } + if ctx != nil && ctx.Err() != nil { + return -1 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return -1 +} + +func commandError(executable string, args []string, dir string, code int, err error, ctx context.Context) error { + if err == nil { + return nil + } + // CommandContext returns an os.Process error after killing a command. The + // context error is the actionable one and preserves errors.Is semantics. + if ctx != nil { + if contextErr := ctx.Err(); contextErr != nil { + err = contextErr + } + } + return &CommandError{ + Executable: executable, + Args: append([]string(nil), args...), + Dir: dir, + ExitCode: code, + Err: err, + } +} diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go new file mode 100644 index 0000000..47addce --- /dev/null +++ b/internal/gitexec/gitexec_test.go @@ -0,0 +1,207 @@ +package gitexec + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestRunCapturesSeparateStreamsAndStdin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +printf 'out:%s\n' "$1" +printf 'err:%s\n' "$2" >&2 +cat +`) + + result := (Runner{Executable: script}).Run(context.Background(), t.TempDir(), []string{"first", "second"}, strings.NewReader("input\n")) + if result.Err != nil { + t.Fatalf("Run returned error: %v", result.Err) + } + if result.Stdout != "out:first\ninput\n" { + t.Fatalf("stdout = %q, want %q", result.Stdout, "out:first\ninput\n") + } + if result.Stderr != "err:second\n" { + t.Fatalf("stderr = %q, want %q", result.Stderr, "err:second\n") + } + if result.ExitCode != 0 { + t.Fatalf("exit code = %d, want 0", result.ExitCode) + } + if result.Duration <= 0 { + t.Fatal("duration should be positive") + } +} + +func TestRunReturnsOutputAndExitCodeOnFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +echo expected-output +echo expected-error >&2 +exit 7 +`) + + result := (Runner{Executable: script}).Run(context.Background(), "", nil, nil) + if result.Err == nil { + t.Fatal("Run should return an error for a non-zero command") + } + if result.ExitCode != 7 { + t.Fatalf("exit code = %d, want 7", result.ExitCode) + } + if result.Stdout != "expected-output\n" || result.Stderr != "expected-error\n" { + t.Fatalf("unexpected output: stdout=%q stderr=%q", result.Stdout, result.Stderr) + } + var commandErr *CommandError + if !errors.As(result.Err, &commandErr) { + t.Fatalf("error type = %T, want *CommandError", result.Err) + } + if commandErr.ExitCode != 7 { + t.Fatalf("command error exit code = %d, want 7", commandErr.ExitCode) + } +} + +func TestStreamWritesToSeparateWriters(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +printf stream-out +printf stream-err >&2 +`) + var stdout, stderr bytes.Buffer + result := (Runner{Executable: script}).Stream(context.Background(), "", nil, nil, &stdout, &stderr) + if result.Err != nil { + t.Fatalf("Stream returned error: %v", result.Err) + } + if stdout.String() != "stream-out" || result.Stdout != "" { + t.Fatalf("stdout writer/result = %q/%q", stdout.String(), result.Stdout) + } + if stderr.String() != "stream-err" || result.Stderr != "" { + t.Fatalf("stderr writer/result = %q/%q", stderr.String(), result.Stderr) + } +} + +func TestRunHonorsContextCancellation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +exec sleep 10 +`) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + result := (Runner{Executable: script}).Run(ctx, "", nil, nil) + if !errors.Is(result.Err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want context deadline exceeded", result.Err) + } + if result.ExitCode != -1 { + t.Fatalf("exit code = %d, want -1", result.ExitCode) + } +} + +func TestRunTimeoutIsBoundedWhenDescendantHoldsOutputPipe(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +sleep 10 & +sleep 10 +`) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + started := time.Now() + result := (Runner{Executable: script}).Run(ctx, "", nil, nil) + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("Run remained blocked by an inherited output pipe for %v", elapsed) + } + if !errors.Is(result.Err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want context deadline exceeded", result.Err) + } +} + +func TestStreamTimeoutIsBoundedWhenDescendantHoldsOutputPipe(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +sleep 10 & +sleep 10 +`) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + started := time.Now() + result := (Runner{Executable: script}).Stream(ctx, "", nil, nil, &bytes.Buffer{}, &bytes.Buffer{}) + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("Stream remained blocked by an inherited output pipe for %v", elapsed) + } + if !errors.Is(result.Err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want context deadline exceeded", result.Err) + } +} + +func TestLookPathAndPreflight(t *testing.T) { + runner := Runner{Executable: "definitely-not-a-git-executable"} + if _, err := runner.LookPath(); err == nil { + t.Fatal("LookPath should fail for a missing executable") + } + if err := runner.Preflight(); err == nil { + t.Fatal("Preflight should fail for a missing executable") + } + + if runtime.GOOS != "windows" { + script := writeExecutable(t, "#!/bin/sh\nexit 0\n") + runner = Runner{Executable: script} + resolved, err := runner.LookPath() + if err != nil { + t.Fatalf("LookPath returned error: %v", err) + } + if resolved != script { + t.Fatalf("resolved path = %q, want %q", resolved, script) + } + if err := runner.Preflight(); err != nil { + t.Fatalf("Preflight returned error: %v", err) + } + } +} + +func TestRunnerWithEnvOverridesChildEnvironment(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + t.Setenv("GITMULTI_TEST_ENV", "inherited") + script := writeExecutable(t, "#!/bin/sh\nprintf '%s' \"$GITMULTI_TEST_ENV\"\n") + runner := (Runner{Executable: script}).WithEnv("GITMULTI_TEST_ENV=overridden") + + result := runner.Run(context.Background(), "", nil, nil) + if result.Err != nil { + t.Fatalf("Run returned error: %v", result.Err) + } + if result.Stdout != "overridden" { + t.Fatalf("environment value = %q, want overridden", result.Stdout) + } +} + +func writeExecutable(t *testing.T, contents string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "fake-git") + if err := os.WriteFile(path, []byte(contents), 0700); err != nil { + t.Fatalf("write fake executable: %v", err) + } + if err := os.Chmod(path, 0700); err != nil { + t.Fatalf("chmod fake executable: %v", err) + } + return path +} diff --git a/internal/gitstatus/status.go b/internal/gitstatus/status.go new file mode 100644 index 0000000..83a1318 --- /dev/null +++ b/internal/gitstatus/status.go @@ -0,0 +1,289 @@ +// Package gitstatus collects a compact, machine-readable summary of a Git +// working tree using porcelain v2 output. +package gitstatus + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/ramonzx6/gitmulti/internal/gitexec" +) + +const ( + statusCommand = "status" + porcelainFlag = "--porcelain=v2" + branchFlag = "--branch" + zeroFlag = "-z" + noAheadBehindFlag = "--no-ahead-behind" + configFlag = "-c" + disableFSMonitor = "core.fsmonitor=false" +) + +// State is the stable summary emitted for one repository. Name and Path are +// intentionally owned here so callers do not need to import a discovery +// package merely to render status results. +type State struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Head string `json:"head"` + Upstream string `json:"upstream"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + AheadBehindKnown bool `json:"aheadBehindKnown"` + Dirty bool `json:"dirty"` + Changed bool `json:"changed"` + Untracked bool `json:"untracked"` + Detached bool `json:"detached"` + Unborn bool `json:"unborn"` + NoUpstream bool `json:"no_upstream"` + Error string `json:"error,omitempty"` + + // Err contains the original collection or parsing error for callers that + // need errors.Is/errors.As. It is excluded from JSON to keep the output + // stable and serializable. + Err error `json:"-"` +} + +// Collector runs Git status commands. The zero value uses git from PATH. +type Collector struct { + Runner gitexec.Runner +} + +// CollectOptions controls optional work performed by Git while collecting a +// repository status. +type CollectOptions struct { + NoAheadBehind bool +} + +// NewCollector returns a collector backed by runner. +func NewCollector(runner gitexec.Runner) Collector { + return Collector{Runner: runner} +} + +// Collect gathers status for path. name is retained separately from path so +// callers can use a relative display name while still preserving the actual +// working-tree path. Errors are stored in State.Err and State.Error because +// this method returns a state for every repository, including failed ones. +func (c Collector) Collect(ctx context.Context, name, path string) State { + return c.CollectWithOptions(ctx, name, path, CollectOptions{}) +} + +// CollectWithOptions gathers status while applying the requested Git status +// optimizations. When NoAheadBehind is true, AheadBehindKnown remains false +// rather than representing the omitted counts as zero. +func (c Collector) CollectWithOptions(ctx context.Context, name, path string, options CollectOptions) State { + state := State{ + Name: name, + Path: path, + NoUpstream: true, + } + args := []string{ + configFlag, + disableFSMonitor, + "--no-optional-locks", + statusCommand, + porcelainFlag, + branchFlag, + } + if options.NoAheadBehind { + args = append(args, noAheadBehindFlag) + } + args = append(args, zeroFlag) + result := c.Runner.Run(ctx, path, args, nil) + if result.Err != nil { + err := result.Err + if stderr := strings.TrimSpace(result.Stderr); stderr != "" { + err = fmt.Errorf("%w: %s", err, stderr) + } + setError(&state, fmt.Errorf("collect status for %q: %w", path, err)) + return state + } + + parsed, err := Parse([]byte(result.Stdout)) + if err != nil { + setError(&state, fmt.Errorf("parse status for %q: %w", path, err)) + return state + } + parsed.Name = name + parsed.Path = path + return parsed +} + +func setError(state *State, err error) { + state.Err = err + state.Error = err.Error() +} + +// Parse decodes NUL-delimited `git status --porcelain=v2 --branch -z` +// output. File paths are intentionally not retained: this package reports +// repository-level state and the command's record types are sufficient to +// determine its dirty/changed/untracked flags. +func Parse(data []byte) (State, error) { + var state State + records := strings.Split(string(data), "\x00") + for index := 0; index < len(records); index++ { + record := records[index] + if record == "" { + continue + } + if strings.HasPrefix(record, "# ") { + if err := parseHeader(&state, record); err != nil { + return State{}, err + } + continue + } + + consumedExtra, err := parseEntry(&state, record) + if err != nil { + return State{}, err + } + // A type-2 rename/copy record is followed by its original path as a + // separate NUL-delimited record in -z mode. + if consumedExtra { + index++ + if index >= len(records) || records[index] == "" { + return State{}, fmt.Errorf("malformed rename record: missing original path") + } + } + } + + if state.Head == "" { + return State{}, fmt.Errorf("status output is missing branch.oid") + } + if state.Branch == "" { + return State{}, fmt.Errorf("status output is missing branch.head") + } + state.NoUpstream = state.Upstream == "" + if state.Detached { + state.NoUpstream = true + } + state.Dirty = state.Changed || state.Untracked + return state, nil +} + +// ParseString is a convenience wrapper for callers that already have text. +func ParseString(data string) (State, error) { + return Parse([]byte(data)) +} + +func parseHeader(state *State, record string) error { + parts := strings.SplitN(record, " ", 3) + if len(parts) != 3 || parts[0] != "#" || parts[1] == "" || parts[2] == "" { + return fmt.Errorf("malformed status header %q", record) + } + value := parts[2] + switch parts[1] { + case "branch.oid": + state.Head = value + state.Unborn = value == "(initial)" + case "branch.head": + state.Branch = value + state.Detached = value == "(detached)" + case "branch.upstream": + state.Upstream = value + case "branch.ab": + fields := strings.Fields(value) + if len(fields) != 2 { + return fmt.Errorf("malformed branch.ab header %q", record) + } + ahead, err := parseSignedCount(fields[0], '+') + if err != nil { + return fmt.Errorf("malformed branch.ab ahead count %q: %w", fields[0], err) + } + behind, err := parseSignedCount(fields[1], '-') + if err != nil { + return fmt.Errorf("malformed branch.ab behind count %q: %w", fields[1], err) + } + state.Ahead = ahead + state.Behind = behind + state.AheadBehindKnown = true + } + return nil +} + +func parseSignedCount(value string, expectedSign byte) (int, error) { + if len(value) < 2 || value[0] != expectedSign { + return 0, fmt.Errorf("expected %c-prefixed count", expectedSign) + } + count, err := strconv.Atoi(value[1:]) + if err != nil { + return 0, err + } + if count < 0 { + return 0, fmt.Errorf("count cannot be negative") + } + return count, nil +} + +// parseEntry returns true when the record has an additional NUL-delimited +// original path (type 2 rename/copy record). +func parseEntry(state *State, record string) (bool, error) { + if len(record) < 2 || record[1] != ' ' { + return false, fmt.Errorf("malformed status record %q", record) + } + switch record[0] { + case '1': + fields := strings.SplitN(record, " ", 9) + if len(fields) != 9 { + return false, fmt.Errorf("malformed ordinary status record %q", record) + } + if err := validateStatusFields(fields, 1, 8); err != nil { + return false, fmt.Errorf("malformed ordinary status record: %w", err) + } + state.Changed = true + return false, nil + case '2': + fields := strings.SplitN(record, " ", 10) + if len(fields) != 10 { + return false, fmt.Errorf("malformed rename status record %q", record) + } + if err := validateStatusFields(fields, 1, 9); err != nil { + return false, fmt.Errorf("malformed rename status record: %w", err) + } + state.Changed = true + return true, nil + case 'u': + fields := strings.SplitN(record, " ", 11) + if len(fields) != 11 { + return false, fmt.Errorf("malformed unmerged status record %q", record) + } + if err := validateStatusFields(fields, 1, 10); err != nil { + return false, fmt.Errorf("malformed unmerged status record: %w", err) + } + state.Changed = true + return false, nil + case '?': + if strings.TrimPrefix(record, "? ") == record || strings.TrimPrefix(record, "? ") == "" { + return false, fmt.Errorf("malformed untracked status record %q", record) + } + state.Untracked = true + return false, nil + case '!': + if strings.TrimPrefix(record, "! ") == record || strings.TrimPrefix(record, "! ") == "" { + return false, fmt.Errorf("malformed ignored status record %q", record) + } + // Ignored records are only emitted when --ignored is supplied and do + // not make a working tree dirty. + return false, nil + default: + return false, fmt.Errorf("unknown status record type %q", record[0]) + } +} + +func validateStatusFields(fields []string, xy, path int) error { + if len(fields) <= path || fields[path] == "" { + return fmt.Errorf("missing path") + } + if len(fields) <= xy || len(fields[xy]) != 2 { + return fmt.Errorf("missing status metadata") + } + for index := xy + 1; index < path; index++ { + if fields[index] == "" { + return fmt.Errorf("missing status metadata") + } + } + return nil +} diff --git a/internal/gitstatus/status_test.go b/internal/gitstatus/status_test.go new file mode 100644 index 0000000..1fe8d0f --- /dev/null +++ b/internal/gitstatus/status_test.go @@ -0,0 +1,228 @@ +package gitstatus + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/gitexec" +) + +func TestParseCleanBranch(t *testing.T) { + state, err := Parse([]byte("# branch.oid abc123\x00# branch.head main\x00# branch.upstream origin/main\x00# branch.ab +2 -1\x00")) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if state.Head != "abc123" || state.Branch != "main" || state.Upstream != "origin/main" { + t.Fatalf("branch state = %#v", state) + } + if state.Ahead != 2 || state.Behind != 1 || state.NoUpstream { + t.Fatalf("tracking state = %#v", state) + } + if !state.AheadBehindKnown { + t.Fatal("ahead/behind should be marked known when branch.ab is present") + } + if state.Dirty || state.Changed || state.Untracked || state.Detached { + t.Fatalf("clean state flags = %#v", state) + } +} + +func TestParseUnbornBranchAndWhitespaceFilename(t *testing.T) { + state, err := Parse([]byte("# branch.oid (initial)\x00# branch.head main\x00? \x00")) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if !state.Unborn || !state.Untracked || !state.Dirty { + t.Fatalf("unborn whitespace-path state = %#v", state) + } + if state.AheadBehindKnown { + t.Fatal("ahead/behind should be unknown without branch.ab") + } +} + +func TestParseChangedAndUntrackedRecords(t *testing.T) { + fixture := "# branch.oid abc123\x00# branch.head main\x001 .M N... 100644 100644 100644 abc abc tracked.txt\x00? new file.txt\x00" + state, err := Parse([]byte(fixture)) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if !state.Dirty || !state.Changed || !state.Untracked { + t.Fatalf("dirty flags = %#v", state) + } + if state.NoUpstream != true { + t.Fatal("missing upstream should set no_upstream") + } +} + +func TestParseRenameAndUnmergedRecords(t *testing.T) { + fixture := "# branch.oid abc123\x00# branch.head main\x002 R. N... 100644 100644 100644 oldhash newhash R100 new.txt\x00old.txt\x00u UU N... 100644 100644 100644 100644 a b c file.txt\x00" + state, err := Parse([]byte(fixture)) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if !state.Dirty || !state.Changed || state.Untracked { + t.Fatalf("rename/unmerged flags = %#v", state) + } +} + +func TestParseDetachedAndIgnored(t *testing.T) { + fixture := "# branch.oid abc123\x00# branch.head (detached)\x00! ignored.txt\x00" + state, err := Parse([]byte(fixture)) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if !state.Detached || !state.NoUpstream || state.Dirty { + t.Fatalf("detached flags = %#v", state) + } +} + +func TestParseRejectsMalformedRecords(t *testing.T) { + tests := []struct { + name string + data string + }{ + {name: "header", data: "# branch.ab +1\x00"}, + {name: "empty", data: ""}, + {name: "missing head", data: "# branch.oid abc123\x00"}, + {name: "reversed ahead sign", data: "# branch.oid abc123\x00# branch.head main\x00# branch.ab -1 -0\x00"}, + {name: "reversed behind sign", data: "# branch.oid abc123\x00# branch.head main\x00# branch.ab +0 +1\x00"}, + {name: "ordinary", data: "1 .M N... only-two-fields\x00"}, + {name: "untracked", data: "? \x00"}, + {name: "rename", data: "2 R. N... 100644 100644 100644 old new R100 new.txt\x00"}, + {name: "unknown", data: "x unknown\x00"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := Parse([]byte(test.data)); err == nil { + t.Fatal("Parse should reject malformed fixture") + } + }) + } +} + +func TestStateHasStableJSONShape(t *testing.T) { + state := State{Name: "repo", Path: "/tmp/repo", Branch: "main", Head: "abc", Upstream: "origin/main", Ahead: 1, Behind: 2, Dirty: true, Changed: true, NoUpstream: false} + encoded, err := json.Marshal(state) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + for _, key := range []string{"name", "path", "branch", "head", "upstream", "ahead", "behind", "dirty", "changed", "untracked", "detached", "no_upstream"} { + if _, ok := decoded[key]; !ok { + t.Errorf("JSON missing %q: %s", key, encoded) + } + } + if _, ok := decoded["Err"]; ok { + t.Error("JSON should not expose the internal error field") + } +} + +func TestCollectorUsesPorcelainCommandAndPreservesRepositoryFields(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +if [ "$1" != "-c" ] || [ "$2" != "core.fsmonitor=false" ] || [ "$3" != "--no-optional-locks" ] || [ "$4" != "status" ] || [ "$5" != "--porcelain=v2" ] || [ "$6" != "--branch" ] || [ "$7" != "-z" ]; then + echo "unexpected arguments" >&2 + exit 9 +fi +printf '# branch.oid abc123\000# branch.head main\000# branch.ab +0 -0\000? pending.txt\000' +`) + path := t.TempDir() + state := (Collector{Runner: gitexecRunner(script)}).Collect(context.Background(), "repo-name", path) + if state.Err != nil { + t.Fatalf("Collect returned error: %v", state.Err) + } + if state.Name != "repo-name" || state.Path != path || state.Branch != "main" || !state.Untracked || !state.Dirty { + t.Fatalf("collected state = %#v", state) + } +} + +func TestCollectorCanSkipAheadBehindCalculation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, `#!/bin/sh +if [ "$1" != "-c" ] || [ "$2" != "core.fsmonitor=false" ] || [ "$3" != "--no-optional-locks" ] || [ "$4" != "status" ] || [ "$5" != "--porcelain=v2" ] || [ "$6" != "--branch" ] || [ "$7" != "--no-ahead-behind" ] || [ "$8" != "-z" ]; then + echo "unexpected arguments" >&2 + exit 9 +fi +printf '# branch.oid abc123\000# branch.head main\000# branch.upstream origin/main\000' +`) + path := t.TempDir() + state := (Collector{Runner: gitexecRunner(script)}).CollectWithOptions(context.Background(), "repo", path, CollectOptions{NoAheadBehind: true}) + if state.Err != nil { + t.Fatalf("CollectWithOptions returned error: %v", state.Err) + } + if state.AheadBehindKnown || state.Ahead != 0 || state.Behind != 0 { + t.Fatalf("omitted ahead/behind state = %#v", state) + } +} + +func TestCollectorDisablesRepositoryFSMonitor(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + repo := t.TempDir() + if output, err := exec.Command("git", "-C", repo, "init", "--quiet").CombinedOutput(); err != nil { + t.Fatalf("git init: %v (%s)", err, output) + } + invoked := filepath.Join(t.TempDir(), "fsmonitor-invoked") + hook := writeExecutable(t, "#!/bin/sh\nprintf invoked > \""+invoked+"\"\nprintf '0\\n'\n") + if output, err := exec.Command("git", "-C", repo, "config", "core.fsmonitor", hook).CombinedOutput(); err != nil { + t.Fatalf("configure core.fsmonitor: %v (%s)", err, output) + } + + state := (Collector{Runner: gitexec.Runner{}}).Collect(context.Background(), "repo", repo) + if state.Err != nil { + t.Fatalf("Collect returned error: %v", state.Err) + } + if _, err := os.Stat(invoked); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("repository-configured fsmonitor ran; stat error = %v", err) + } +} + +func TestCollectorReturnsUsefulErrorState(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + script := writeExecutable(t, "#!/bin/sh\necho git-failure >&2\nexit 3\n") + state := (Collector{Runner: gitexecRunner(script)}).Collect(context.Background(), "repo", t.TempDir()) + if state.Err == nil || state.Error == "" { + t.Fatalf("error state = %#v", state) + } + if !strings.Contains(state.Error, "git-failure") { + t.Fatalf("error = %q, want stderr context", state.Error) + } +} + +// Keep the test's Runner construction local to this package's public API +// without importing implementation details from the collector. +func gitexecRunner(executable string) gitexec.Runner { + return gitexec.Runner{Executable: executable} +} + +func writeExecutable(t *testing.T, contents string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "fake-git") + if err := os.WriteFile(path, []byte(contents), 0700); err != nil { + t.Fatalf("write fake executable: %v", err) + } + if err := os.Chmod(path, 0700); err != nil { + t.Fatalf("chmod fake executable: %v", err) + } + return path +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go new file mode 100644 index 0000000..21b0a0b --- /dev/null +++ b/internal/repository/repository.go @@ -0,0 +1,548 @@ +// Package repository discovers Git repositories below a filesystem root. +// +// Discovery is deliberately independent of the command-line package. It +// deals only with filesystem traversal and optional validation by Git, which +// makes its results useful to both reporting and execution commands. +package repository + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" +) + +// Repository is a discovered Git repository. +// +// Name is the slash-normalized path relative to Options.Root. The root itself +// is represented by "." when IncludeRoot is enabled. Path is an absolute, +// cleaned path suitable for passing as a process working directory. +type Repository struct { + Name string `json:"name"` + Path string `json:"path"` + Bare bool `json:"bare,omitempty"` +} + +// Warning describes a non-fatal problem encountered while walking the tree. +// Discovery continues after warnings whenever it can do so safely. +type Warning struct { + Path string `json:"path"` + Message string `json:"message"` +} + +// Result contains repositories found and non-fatal problems encountered while +// finding them. Both slices are deterministically ordered by path. +type Result struct { + Repositories []Repository `json:"repositories"` + Warnings []Warning `json:"warnings,omitempty"` +} + +// Options controls Discover. +// +// Root is required. By default only direct children of Root are inspected; +// Recursive enables traversal below those children. MaxDepth is measured +// from Root (a value of zero means unlimited). IncludeRoot considers Root as +// a result, while IncludeNested continues below a discovered repository. +// Hidden directories are skipped unless IncludeHidden is set. Symlinked +// directories are skipped unless FollowSymlinks is set; followed links are +// cycle-safe. IncludeBare enables detection of bare repositories. Validate +// runs `git -C rev-parse --git-dir` for each candidate. +// +// Match and Exclude are repeated Go path.Match patterns applied to slash- +// normalized relative names. If Match is non-empty, at least one pattern +// must match. Exclude always takes precedence. +type Options struct { + Root string + + Recursive bool + IncludeRoot bool + IncludeNested bool + MaxDepth int + + IncludeHidden bool + FollowSymlinks bool + IncludeBare bool + Validate bool + + Match []string + Exclude []string + + // ValidateRepository overrides the default Git-based candidate + // validation. It is used only when Validate is true. + ValidateRepository func(context.Context, string) error +} + +type repositoryKind uint8 + +const maxGitFileSize = 4096 + +const ( + kindNone repositoryKind = iota + kindWorktree + kindBare +) + +// Discover finds Git repositories according to opts. Root and invalid glob +// patterns are fatal errors. Problems in descendants are reported as +// warnings, allowing useful partial results from a large tree. +func Discover(ctx context.Context, opts Options) (Result, error) { + var result Result + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return result, err + } + + root, err := validateRoot(opts.Root) + if err != nil { + return result, err + } + if opts.MaxDepth < 0 { + return result, fmt.Errorf("repository discovery: max depth must be non-negative, got %d", opts.MaxDepth) + } + + match, err := normalizePatterns(opts.Match, "match") + if err != nil { + return result, err + } + exclude, err := normalizePatterns(opts.Exclude, "exclude") + if err != nil { + return result, err + } + + d := &discoverer{ + ctx: ctx, + root: root, + rootIdentity: root, + options: opts, + match: match, + exclude: exclude, + visited: make(map[string]struct{}), + discovered: make(map[string]struct{}), + repositories: make([]Repository, 0), + warnings: make([]Warning, 0), + } + if opts.FollowSymlinks { + rootIdentity, err := filepath.EvalSymlinks(root) + if err != nil { + return result, fmt.Errorf("repository discovery: resolve canonical root %q: %w", root, err) + } + d.rootIdentity = filepath.Clean(rootIdentity) + } + + if err := d.scan(root, ".", 0, true); err != nil { + return Result{Repositories: d.repositories, Warnings: d.warnings}, err + } + + sort.Slice(d.repositories, func(i, j int) bool { + return d.repositories[i].Name < d.repositories[j].Name + }) + sort.Slice(d.warnings, func(i, j int) bool { + if d.warnings[i].Path == d.warnings[j].Path { + return d.warnings[i].Message < d.warnings[j].Message + } + return d.warnings[i].Path < d.warnings[j].Path + }) + + result.Repositories = d.repositories + result.Warnings = d.warnings + return result, nil +} + +type discoverer struct { + ctx context.Context + root string + rootIdentity string + options Options + match []string + exclude []string + + visited map[string]struct{} + discovered map[string]struct{} + + repositories []Repository + warnings []Warning +} + +func validateRoot(raw string) (string, error) { + if raw == "" { + return "", errors.New("repository discovery: root path is required") + } + root, err := filepath.Abs(raw) + if err != nil { + return "", fmt.Errorf("repository discovery: resolve root %q: %w", raw, err) + } + root = filepath.Clean(root) + info, err := os.Stat(root) + if err != nil { + return "", fmt.Errorf("repository discovery: stat root %q: %w", raw, err) + } + if !info.IsDir() { + return "", fmt.Errorf("repository discovery: root %q is not a directory", raw) + } + return root, nil +} + +func normalizePatterns(patterns []string, kind string) ([]string, error) { + result := make([]string, len(patterns)) + for i, pattern := range patterns { + // filepath.ToSlash keeps the API portable while path.Match gives every + // platform the same slash-oriented semantics. + pattern = filepath.ToSlash(pattern) + if _, err := path.Match(pattern, ""); err != nil { + return nil, fmt.Errorf("repository discovery: invalid %s pattern %q: %w", kind, patterns[i], err) + } + result[i] = pattern + } + return result, nil +} + +func (d *discoverer) scan(dir, relative string, depth int, root bool) error { + if err := d.ctx.Err(); err != nil { + return err + } + + identity, err := d.identity(dir) + if err != nil { + if root { + return fmt.Errorf("repository discovery: identify root %q: %w", dir, err) + } + d.warn(dir, fmt.Sprintf("unable to identify directory: %v", err)) + return nil + } + if _, seen := d.visited[identity]; seen { + return nil + } + if d.options.FollowSymlinks && !withinRoot(d.rootIdentity, identity) { + d.warn(dir, "symlink target is outside the canonical discovery root; skipped") + return nil + } + d.visited[identity] = struct{}{} + + kind, err := detect(dir, d.options.IncludeBare, d.options.FollowSymlinks) + if err != nil { + if root { + return fmt.Errorf("repository discovery: inspect root %q: %w", dir, err) + } + d.warn(dir, fmt.Sprintf("unable to inspect directory: %v", err)) + kind = kindNone + } + + // Root is intentionally still traversed when IncludeRoot is false. This + // preserves the useful child-only behavior when Root is itself a checkout. + discoveredHere := kind != kindNone + if discoveredHere && (!root || d.options.IncludeRoot) { + name := normalizeRelative(relative) + if d.matches(name) { + if _, duplicate := d.discovered[identity]; !duplicate { + if d.options.Validate { + if err := d.validate(dir); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + d.warn(dir, fmt.Sprintf("Git validation failed: %v", err)) + } else { + d.addRepository(identity, name, dir, kind == kindBare) + } + } else { + d.addRepository(identity, name, dir, kind == kindBare) + } + } + } + } + + // A repository boundary is respected even when a match/exclude filter + // removes the repository from the result. Otherwise nested repositories + // could unexpectedly escape an excluded parent boundary. + if discoveredHere && (!root || d.options.IncludeRoot) && !d.options.IncludeNested { + return nil + } + if !d.options.Recursive && depth >= 1 { + return nil + } + if d.options.MaxDepth > 0 && depth >= d.options.MaxDepth { + return nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + if root { + return fmt.Errorf("repository discovery: read root %q: %w", dir, err) + } + d.warn(dir, fmt.Sprintf("unable to read directory: %v", err)) + return nil + } + for _, entry := range entries { + if err := d.ctx.Err(); err != nil { + return err + } + name := entry.Name() + // A worktree's .git directory is repository metadata, not another + // repository. Never descend into it, even when hidden directories, + // bare repositories, and nested repositories are explicitly enabled. + if kind == kindWorktree && name == ".git" { + continue + } + if !d.options.IncludeHidden && strings.HasPrefix(name, ".") { + continue + } + + child := filepath.Join(dir, name) + isDir := entry.IsDir() + if entry.Type()&os.ModeSymlink != 0 { + if !d.options.FollowSymlinks { + continue + } + info, statErr := os.Stat(child) + if statErr != nil { + if !errors.Is(statErr, os.ErrNotExist) { + d.warn(child, fmt.Sprintf("unable to follow symlink: %v", statErr)) + } + continue + } + isDir = info.IsDir() + } + if !isDir { + continue + } + + childRelative := name + if relative != "." { + childRelative = filepath.Join(relative, name) + } + if err := d.scan(child, childRelative, depth+1, false); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + // Child scan errors are already represented by warnings wherever + // possible; retain a warning if a future filesystem error escapes. + d.warn(child, err.Error()) + } + } + return nil +} + +func (d *discoverer) identity(dir string) (string, error) { + if !d.options.FollowSymlinks { + return filepath.Clean(dir), nil + } + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", err + } + return filepath.Clean(resolved), nil +} + +func (d *discoverer) matches(name string) bool { + for _, pattern := range d.exclude { + matched, _ := path.Match(pattern, name) + if matched { + return false + } + } + if len(d.match) == 0 { + return true + } + for _, pattern := range d.match { + matched, _ := path.Match(pattern, name) + if matched { + return true + } + } + return false +} + +func (d *discoverer) validate(dir string) error { + if d.options.ValidateRepository != nil { + return d.options.ValidateRepository(d.ctx, dir) + } + cmd := exec.CommandContext(d.ctx, "git", "-C", dir, "rev-parse", "--git-dir") + if output, err := cmd.CombinedOutput(); err != nil { + if ctxErr := d.ctx.Err(); ctxErr != nil { + return ctxErr + } + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return err + } + return fmt.Errorf("%w (%s)", err, trimmed) + } + return nil +} + +func (d *discoverer) addRepository(identity, name, dir string, bare bool) { + d.discovered[identity] = struct{}{} + storedPath := dir + if d.options.FollowSymlinks { + // Store the already-checked canonical identity so retargeting a + // followed symlink after discovery cannot redirect later Git commands. + storedPath = identity + } + d.repositories = append(d.repositories, Repository{ + Name: normalizeRelative(name), + Path: filepath.Clean(storedPath), + Bare: bare, + }) +} + +func (d *discoverer) warn(path, message string) { + d.warnings = append(d.warnings, Warning{Path: filepath.Clean(path), Message: message}) +} + +func normalizeRelative(name string) string { + name = filepath.ToSlash(name) + if name == "" { + return "." + } + return name +} + +func withinRoot(root, candidate string) bool { + if root == candidate { + return true + } + relative, err := filepath.Rel(root, candidate) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return false + } + return !filepath.IsAbs(relative) +} + +func detect(dir string, includeBare, followSymlinks bool) (repositoryKind, error) { + marker := filepath.Join(dir, ".git") + info, err := os.Lstat(marker) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 { + if !followSymlinks { + return kindNone, nil + } + info, err = os.Stat(marker) + } + if err != nil { + return kindNone, err + } + if info.IsDir() { + return kindWorktree, nil + } + if info.Mode().IsRegular() { + if err := validateGitFile(marker); err != nil { + return kindNone, err + } + return kindWorktree, nil + } + return kindNone, fmt.Errorf("malformed .git marker: expected a directory or regular file, got %s", info.Mode().Type()) + } else if !errors.Is(err, os.ErrNotExist) { + return kindNone, err + } + + if !includeBare { + return kindNone, nil + } + head, err := regularEntry(filepath.Join(dir, "HEAD"), followSymlinks) + if err != nil { + return kindNone, err + } + if !head { + return kindNone, nil + } + objects, err := directoryEntry(filepath.Join(dir, "objects"), followSymlinks) + if err != nil { + return kindNone, err + } + if !objects { + return kindNone, nil + } + refs, err := directoryEntry(filepath.Join(dir, "refs"), followSymlinks) + if err != nil { + return kindNone, err + } + if refs { + return kindBare, nil + } + return kindNone, nil +} + +func validateGitFile(marker string) error { + file, err := os.Open(marker) + if err != nil { + return err + } + contents, readErr := io.ReadAll(io.LimitReader(file, maxGitFileSize+1)) + closeErr := file.Close() + if readErr != nil { + return readErr + } + if closeErr != nil { + return closeErr + } + if len(contents) > maxGitFileSize { + return fmt.Errorf("malformed .git file: marker exceeds %d bytes", maxGitFileSize) + } + + value := string(contents) + if strings.ContainsRune(value, '\x00') { + return fmt.Errorf("malformed .git file: marker contains a NUL byte") + } + value = strings.TrimSuffix(value, "\n") + value = strings.TrimSuffix(value, "\r") + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("malformed .git file: expected exactly one gitdir line") + } + if !strings.HasPrefix(value, "gitdir:") || strings.TrimSpace(strings.TrimPrefix(value, "gitdir:")) == "" { + return fmt.Errorf("malformed .git file: expected a non-empty gitdir target") + } + return nil +} + +func regularEntry(name string, followSymlinks bool) (bool, error) { + info, err := os.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + if !followSymlinks { + return false, nil + } + info, err = os.Stat(name) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + } + return info.Mode().IsRegular(), nil +} + +func directoryEntry(name string, followSymlinks bool) (bool, error) { + info, err := os.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + if !followSymlinks { + return false, nil + } + info, err = os.Stat(name) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + } + return info.IsDir(), nil +} diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go new file mode 100644 index 0000000..c5cf883 --- /dev/null +++ b/internal/repository/repository_test.go @@ -0,0 +1,382 @@ +package repository + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestDiscoverValidatesRoot(t *testing.T) { + ctx := context.Background() + if _, err := Discover(ctx, Options{}); err == nil { + t.Fatal("Discover accepted an empty root") + } + file := filepath.Join(t.TempDir(), "file") + mustWrite(t, file, "") + if _, err := Discover(ctx, Options{Root: file}); err == nil { + t.Fatal("Discover accepted a file as root") + } + if _, err := Discover(ctx, Options{Root: filepath.Join(t.TempDir(), "missing")}); err == nil { + t.Fatal("Discover accepted a missing root") + } +} + +func TestDiscoverChildOnlyAndIncludeRoot(t *testing.T) { + root := t.TempDir() + makeWorktree(t, root) + makeWorktree(t, filepath.Join(root, "child")) + makeWorktree(t, filepath.Join(root, "child", "nested")) + + result, err := Discover(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, "child") + + result, err = Discover(context.Background(), Options{Root: root, IncludeRoot: true}) + if err != nil { + t.Fatalf("Discover with IncludeRoot returned error: %v", err) + } + assertNames(t, result, ".") +} + +func TestDiscoverNeverTreatsWorktreeMetadataAsBareRepository(t *testing.T) { + root := t.TempDir() + makeBareLikeWorktree(t, root) + makeBareLikeWorktree(t, filepath.Join(root, "child")) + + options := Options{ + Root: root, + Recursive: true, + IncludeNested: true, + IncludeHidden: true, + IncludeBare: true, + FollowSymlinks: false, + } + result, err := Discover(context.Background(), options) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, "child") + + options.IncludeRoot = true + result, err = Discover(context.Background(), options) + if err != nil { + t.Fatalf("Discover with IncludeRoot returned error: %v", err) + } + assertNames(t, result, ".", "child") +} + +func TestDiscoverRecursiveStopsAtRepositoryBoundary(t *testing.T) { + root := t.TempDir() + makeWorktree(t, filepath.Join(root, "one")) + makeWorktree(t, filepath.Join(root, "one", "nested")) + makeWorktree(t, filepath.Join(root, "two", "deep")) + + result, err := Discover(context.Background(), Options{Root: root, Recursive: true}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, filepath.ToSlash(filepath.Join("one")), filepath.ToSlash(filepath.Join("two", "deep"))) + + result, err = Discover(context.Background(), Options{Root: root, Recursive: true, IncludeNested: true}) + if err != nil { + t.Fatalf("Discover with IncludeNested returned error: %v", err) + } + assertNames(t, result, "one", "one/nested", "two/deep") +} + +func TestDiscoverMaxDepth(t *testing.T) { + root := t.TempDir() + makeWorktree(t, filepath.Join(root, "a", "b")) + makeWorktree(t, filepath.Join(root, "a", "b", "c")) + + result, err := Discover(context.Background(), Options{Root: root, Recursive: true, MaxDepth: 1}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if len(result.Repositories) != 0 { + t.Fatalf("MaxDepth=1 returned %v, want no repositories at depth 2+", result.Repositories) + } + + result, err = Discover(context.Background(), Options{Root: root, Recursive: true, MaxDepth: 2}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, "a/b") +} + +func TestDiscoverHiddenDirectories(t *testing.T) { + root := t.TempDir() + makeWorktree(t, filepath.Join(root, ".hidden")) + makeWorktree(t, filepath.Join(root, "visible")) + + result, err := Discover(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, "visible") + + result, err = Discover(context.Background(), Options{Root: root, IncludeHidden: true}) + if err != nil { + t.Fatalf("Discover with IncludeHidden returned error: %v", err) + } + assertNames(t, result, ".hidden", "visible") +} + +func TestDiscoverFiltersUseSlashRelativeNames(t *testing.T) { + root := t.TempDir() + makeWorktree(t, filepath.Join(root, "services", "api")) + makeWorktree(t, filepath.Join(root, "services", "web")) + makeWorktree(t, filepath.Join(root, "tools")) + + result, err := Discover(context.Background(), Options{ + Root: root, + Recursive: true, + Match: []string{"services/*", "tools"}, + Exclude: []string{"services/web"}, + }) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, "services/api", "tools") +} + +func TestDiscoverRejectsInvalidPatterns(t *testing.T) { + root := t.TempDir() + for _, options := range []Options{ + {Root: root, Match: []string{"["}}, + {Root: root, Exclude: []string{"["}}, + } { + if _, err := Discover(context.Background(), options); err == nil { + t.Fatalf("Discover accepted invalid patterns: %+v", options) + } + } +} + +func TestDiscoverBareRepositories(t *testing.T) { + root := t.TempDir() + bare := filepath.Join(root, "bare.git") + mustMkdir(t, filepath.Join(bare, "objects")) + mustMkdir(t, filepath.Join(bare, "refs")) + mustWrite(t, filepath.Join(bare, "HEAD"), "ref: refs/heads/main\n") + + result, err := Discover(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if len(result.Repositories) != 0 { + t.Fatalf("bare repository discovered without IncludeBare: %v", result.Repositories) + } + result, err = Discover(context.Background(), Options{Root: root, IncludeBare: true}) + if err != nil { + t.Fatalf("Discover with IncludeBare returned error: %v", err) + } + assertNames(t, result, "bare.git") + if !result.Repositories[0].Bare { + t.Fatalf("bare repository has Bare=%v, want true", result.Repositories[0].Bare) + } +} + +func TestDiscoverWarnsAndSkipsMalformedGitFile(t *testing.T) { + for name, contents := range map[string]string{ + "missing-prefix": "not a gitdir marker", + "multiple-lines": "gitdir: ../real.git\nuntrusted trailing data\n", + "nul-byte": "gitdir: ../real.git\x00ignored", + "oversized": "gitdir: " + strings.Repeat("x", maxGitFileSize), + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + fake := filepath.Join(root, "fake") + mustMkdir(t, fake) + mustWrite(t, filepath.Join(fake, ".git"), contents) + + result, err := Discover(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if len(result.Repositories) != 0 { + t.Fatalf("malformed marker discovered as repository: %#v", result.Repositories) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0].Message, "malformed .git file") { + t.Fatalf("warnings = %#v, want malformed marker warning", result.Warnings) + } + }) + } +} + +func TestDiscoverAcceptsSingleLineGitFile(t *testing.T) { + root := t.TempDir() + repo := filepath.Join(root, "worktree") + mustMkdir(t, repo) + mustWrite(t, filepath.Join(repo, ".git"), "gitdir: ../real.git\r\n") + + result, err := Discover(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, "worktree") +} + +func TestDiscoverFollowSymlinksAndPreventsCycles(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory symlink tests require elevated privileges on Windows") + } + root := t.TempDir() + makeWorktree(t, filepath.Join(root, "repo")) + external := t.TempDir() + makeWorktree(t, filepath.Join(external, "outside")) + if err := os.Symlink(filepath.Join(root, "repo"), filepath.Join(root, "alias")); err != nil { + t.Fatalf("create alias symlink: %v", err) + } + if err := os.Symlink(filepath.Join(external, "outside"), filepath.Join(root, "outside-link")); err != nil { + t.Fatalf("create outside symlink: %v", err) + } + if err := os.Symlink(root, filepath.Join(root, "repo", "cycle")); err != nil { + t.Fatalf("create cycle symlink: %v", err) + } + + result, err := Discover(context.Background(), Options{Root: root, Recursive: true}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + assertNames(t, result, "repo") + + result, err = Discover(context.Background(), Options{Root: root, Recursive: true, FollowSymlinks: true}) + if err != nil { + t.Fatalf("Discover with FollowSymlinks returned error: %v", err) + } + assertNames(t, result, "alias") + if result.Repositories[0].Path != filepath.Join(root, "repo") { + t.Fatalf("followed symlink path = %q, want canonical repository path", result.Repositories[0].Path) + } + if len(result.Warnings) != 1 || result.Warnings[0].Path != filepath.Join(root, "outside-link") { + t.Fatalf("outside symlink warnings = %+v, want one warning for outside-link", result.Warnings) + } +} + +func TestDiscoverCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Discover(ctx, Options{Root: t.TempDir()}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Discover error = %v, want context.Canceled", err) + } +} + +func TestDiscoverValidate(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + root := t.TempDir() + repo := filepath.Join(root, "repo") + mustMkdir(t, repo) + if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v (%s)", err, output) + } + makeWorktree(t, filepath.Join(root, "fake")) + + result, err := Discover(context.Background(), Options{Root: root, Validate: true}) + if err != nil { + t.Fatalf("Discover with Validate returned error: %v", err) + } + assertNames(t, result, "repo") + if len(result.Warnings) != 1 || result.Warnings[0].Path != filepath.Join(root, "fake") { + t.Fatalf("validation warnings = %+v, want one warning for fake repository", result.Warnings) + } +} + +func TestDiscoverUsesInjectedValidator(t *testing.T) { + root := t.TempDir() + makeWorktree(t, filepath.Join(root, "accepted")) + makeWorktree(t, filepath.Join(root, "rejected")) + var validated []string + + result, err := Discover(context.Background(), Options{ + Root: root, + Validate: true, + ValidateRepository: func(_ context.Context, path string) error { + validated = append(validated, filepath.Base(path)) + if filepath.Base(path) == "rejected" { + return errors.New("rejected by test validator") + } + return nil + }, + }) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if want := []string{"accepted", "rejected"}; !reflect.DeepEqual(validated, want) { + t.Fatalf("validated repositories = %v, want %v", validated, want) + } + assertNames(t, result, "accepted") + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0].Message, "rejected by test validator") { + t.Fatalf("validation warnings = %#v", result.Warnings) + } +} + +func TestDiscoverOutputIsDeterministic(t *testing.T) { + root := t.TempDir() + makeWorktree(t, filepath.Join(root, "z")) + makeWorktree(t, filepath.Join(root, "a")) + makeWorktree(t, filepath.Join(root, "m")) + result, err := Discover(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + got := make([]string, 0, len(result.Repositories)) + for _, repo := range result.Repositories { + got = append(got, repo.Name) + } + if want := []string{"a", "m", "z"}; !reflect.DeepEqual(got, want) { + t.Fatalf("repository order = %v, want %v", got, want) + } +} + +func assertNames(t *testing.T, result Result, want ...string) { + t.Helper() + got := make([]string, 0, len(result.Repositories)) + for _, repo := range result.Repositories { + got = append(got, repo.Name) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("repository names = %v, want %v (warnings: %+v)", got, want, result.Warnings) + } +} + +func makeWorktree(t *testing.T, dir string) { + t.Helper() + mustMkdir(t, dir) + mustMkdir(t, filepath.Join(dir, ".git")) +} + +func makeBareLikeWorktree(t *testing.T, dir string) { + t.Helper() + makeWorktree(t, dir) + mustMkdir(t, filepath.Join(dir, ".git", "objects")) + mustMkdir(t, filepath.Join(dir, ".git", "refs")) + mustWrite(t, filepath.Join(dir, ".git", "HEAD"), "ref: refs/heads/main\n") +} + +func mustMkdir(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", dir, err) + } +} + +func mustWrite(t *testing.T, name, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", filepath.Dir(name), err) + } + if err := os.WriteFile(name, []byte(contents), 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", name, err) + } +} diff --git a/main.go b/main.go index d1eb115..e5cedda 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,11 @@ package main -import "gitmulti/cmd" +import ( + "os" + + "github.com/ramonzx6/gitmulti/cmd" +) func main() { - cmd.Execute() + os.Exit(cmd.Execute()) } From f10195a1fe2fa7a093e9c358578123b1c5e664e6 Mon Sep 17 00:00:00 2001 From: ramonzx6 Date: Mon, 10 Aug 2026 08:19:30 +0000 Subject: [PATCH 2/7] docs: document expanded CLI workflows --- CONTRIBUTING.md | 72 +++++++++- README.md | 352 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 319 insertions(+), 105 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7385b1a..c9cf103 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,73 @@ # Contributing -By submitting an issue, pull request, patch, suggestion, or other contribution to this project, you agree that your contribution may be used, modified, and distributed by the project owner as part of this project. +GitMulti is source-available under the [PolyForm Strict License 1.0.0](LICENSE). +Before contributing, make sure your proposed use and contribution are allowed +by that license. -You grant the project owner a perpetual, worldwide, non-exclusive, royalty-free license to use, reproduce, modify, distribute, sublicense, and otherwise incorporate your contribution into this project. +## Before opening a change -Contributions do not give you ownership of the project or permission to use the project outside the terms of the project license. +1. Read the applicable AGENTS.md instructions. +2. Keep changes focused and preserve unrelated work. +3. Add or update tests for behavior changes and regressions. +4. Update the README when command flags, output, or exit behavior changes. +5. Do not include credentials, private repository URLs, or generated binaries. -This project is licensed under the PolyForm Strict License 1.0.0. See [LICENSE](LICENSE) for details. +The CLI is intentionally shell-free: pass Git arguments as positional values +after -- and avoid introducing shell parsing or command interpolation. Changes +that execute Git should preserve cancellation, timeouts, and per-repository +error reporting. + +## Local validation + +Go 1.24 or newer and Git are required. Run the complete project gate before +submitting: + +~~~sh +make check +~~~ + +The gate runs: + +- gofmt validation (make fmt-check) +- go vet ./... (make vet) +- go test ./... (make test) +- go test -race ./... (make race) +- a metadata-bearing native build (make build) +- --help and version --json smoke tests (make smoke) + +Individual commands are available when iterating: + +~~~sh +make fmt # format Go files in place +make fmt-check +make vet +make test +make race +make build +make smoke +~~~ + +If a required tool or platform-specific check is unavailable, report it as +skipped in the change description rather than treating it as passed. The +GitHub Actions workflow repeats formatting, vet, tests, race tests, and native +and Windows builds. + +## Change and commit conventions + +Use Conventional Commit subjects such as: + +~~~text +feat(status): add upstream diagnostics +fix(exec): preserve cancellation errors +test(repository): cover nested boundaries +docs: describe snapshot redaction +~~~ + +Review the final diff and test output. Do not commit unrelated changes. The +repository owner handles integration, publishing, and releases. + +## Reporting a vulnerability + +Do not disclose secrets or a suspected vulnerability in a public issue. Contact +the project owner privately with a minimal reproduction and the affected +version. Do not include credentials in logs, screenshots, or patches. diff --git a/README.md b/README.md index c9bd4bf..c0ff209 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,308 @@ # GitMulti CLI -A command-line tool to perform git operations on multiple repositories. Built with Go and Cobra. +GitMulti discovers Git repositories below a directory and reports on or runs +Git operations across them. It is a single, shell-free Go binary built with +Cobra. -## Features +## Requirements and installation -- List child Git repositories in a directory -- Execute Git commands across child repositories -- Optional recursive repository scanning -- Support for custom paths -- Cross-platform compatibility +- Go 1.24 or newer (only needed to build or install GitMulti) +- Git available on `PATH` when a command inspects or operates on repositories -## Installation +Install the latest version with Go: -### Prerequisites - -- Go 1.24 or higher -- Git +```sh +go install github.com/ramonzx6/gitmulti@latest +``` -### Build and Install +The binary is placed in `go env GOBIN`, or `go env GOPATH`/`bin` when +`GOBIN` is unset. Add that directory to `PATH` if `gitmulti` is not +found. This works from PowerShell as well as Unix shells. -#### Option 1: Install globally +To build from a checkout: -```bash +```sh git clone https://github.com/ramonzx6/gitmulti.git cd gitmulti -go install +go build -o gitmulti . +./gitmulti --help ``` -#### Option 2: Build locally +On Windows, use `go build -o gitmulti.exe .` and run `./gitmulti.exe`. -```bash -go build -o gitmulti main.go -./gitmulti list -``` +## Quick start -### Add to PATH on Windows +```sh +# Discover direct child repositories. +gitmulti list -If `go install` does not automatically add the binary to your PATH: +# Include repositories below the selected directory. +gitmulti list --path ~/src --recursive -1. Find your Go bin directory: - ```bash - go env GOPATH - ``` -2. Add the `bin` directory inside that path to your system PATH environment variable. -3. Restart your terminal or PowerShell. -4. Verify installation: - ```bash - gitmulti --help - ``` +# Show branch, dirty state, upstream, and ahead/behind counts. +gitmulti status --path ~/src --recursive -## Usage +# Run Git with argument boundaries preserved; do not include the word git. +gitmulti exec --path ~/src --recursive -- status --short -```bash -# Get help -gitmulti --help +# Preview a fetch without changing any repository. +gitmulti sync --path ~/src --recursive --dry-run +``` -# Print version -gitmulti version -gitmulti -v +Run `gitmulti --help` or `gitmulti --help` for the complete flag +list of the installed binary. + +## Repository discovery + +All repository-oriented commands accept these discovery flags: + +| Flag | Meaning | +| --- | --- | +| `-p, --path DIR` | Root directory to scan; defaults to `.`. The root must be a directory. | +| `-r, --recursive` | Traverse below direct children. Without it, only direct children are inspected. | +| `--include-root` | Include `DIR` itself when it is a repository. It is excluded by default. | +| `--include-nested` | Continue traversing below a discovered repository. Requires `--recursive`. | +| `--max-depth N` | Limit recursive depth measured from `DIR`; `0` means unlimited. Requires `--recursive`. | +| `--match GLOB` | Include only repository names matching this repeatable pattern. | +| `--exclude GLOB` | Exclude names matching this repeatable pattern; exclusion wins over inclusion. | +| `--include-hidden` | Visit dot-prefixed directories, which are skipped by default. | +| `--follow-symlinks` | Follow directory symlinks with cycle protection; targets outside the canonical root are skipped. | +| `--include-bare` | Include bare repositories; worktrees are detected by default. | +| `--require-match` | Exit with an error when no repositories match (where supported). | + +Repository names are slash-normalized paths relative to `DIR`; the selected +root is named `.` when included. `--match` and `--exclude` use Go's +`path.Match` syntax (`*`, `?`, and bracket classes). A pattern's `*` +does not cross `/`, and `**` has no special recursive meaning. Repeat the +flag for multiple patterns. + +Finding a repository creates a traversal boundary by default, so recursive +scans do not unexpectedly enter its working tree or discover nested clones. +Use `--include-nested` when that is intentional. Hidden directories, +symlinks, and bare repositories are opt-in. Filesystem problems below the root +are warnings and discovery continues where possible; an invalid or unreadable +root is fatal. Warnings are written to stderr and are included in status JSON +diagnostics. + +## Commands -# List child repositories in the current directory -gitmulti list +### `list` / `ls` -# Execute a Git command in each child repository -gitmulti exec -- status --short -``` +List discovered repositories. The default output is one relative name per +line. -`gitmulti` scans child directories of the selected path. The selected path itself is not included, even if it is a Git repository. +```sh +gitmulti list --recursive --format relative +gitmulti list --count +gitmulti list --format json > repositories.json +gitmulti list --format ndjson +``` -## Command Reference +Command-specific flags: -### `list` / `ls` +- `--format name|relative|absolute|json|ndjson` (default `relative`) +- `--absolute`, equivalent to `--format absolute` for text output +- `-0, --null`, terminate text records with NUL for safe pipelines +- `--count`, print only the number of repositories +- `--validate`, ask Git to validate each repository marker +- `--require-match`, exit 1 if no repositories match -Lists Git repositories inside the selected directory. +Validation failures (including malformed `.git` files) are warnings; the +candidate is skipped and the remaining scan continues. -Flags: +### `exec` / `ex` -- `-p, --path`: Path to scan for repositories. Defaults to the current working directory. -- `-r, --recursive`: Scan nested directories recursively. +Run one Git command in each discovered repository. Positional arguments after +`--` are preferred and preserve argument boundaries: -Examples: +```sh +gitmulti exec -- status --short +gitmulti exec --jobs 4 --output prefixed -- fetch --prune +gitmulti exec --path ~/src --recursive --match 'services/*' -- test +``` -```bash -gitmulti list -gitmulti list -p C:\Users\username\Documents\projects -gitmulti list -p .. -gitmulti list --recursive -gitmulti ls +`-c, --command 'status --short'` is a deprecated compatibility form; do not +combine it with positional arguments. GitMulti invokes Git directly, without a +shell, so shell operators and expansion are not interpreted. + +Execution flags are `-j, --jobs N` (default `1`), `--fail-fast`, +`--timeout DURATION`, `--dry-run`, +`--output grouped|prefixed|raw|json|ndjson` (default `grouped`), +`--interactive`, `-q, --quiet`, and `--require-match`. Interactive mode +inherits stdin and forces sequential raw output (`--jobs=1`). Raw output +also requires `--jobs=1`. JSON is a `schemaVersion`/`results` document; +NDJSON emits one `type: execution` record per repository. Both retain +stdout, stderr, exit code, duration, and error details. Grouped output buffers +each repository's output and prints it in discovery order; prefixed and raw +output stream while Git runs. Git failures do not stop remaining repositories +unless `--fail-fast` is set; any failure exits 1. + +### `status` + +Show repository, branch, clean/dirty/detached/error state, upstream, and +ahead/behind counts. Use `--dirty-only`, `--format +table|json|ndjson` (default `table`), `--no-ahead-behind`, +`-j, --jobs N` (default `4`), and `--require-match`. Status failures are +rendered where possible and exit 1. `--no-ahead-behind` asks Git to skip the +tracking-count calculation; machine output marks those counts as unknown. + +```sh +gitmulti status --recursive --dirty-only +gitmulti status --format json > status.json +gitmulti status --format ndjson --no-ahead-behind ``` -### `exec` / `ex` +### `check` -Executes a Git command in each repository inside the selected directory. +Apply repository-state policies for CI or preflight checks. Defaults are +`dirty,diverged`. -Flags: +```sh +gitmulti check --recursive --fail-on dirty,unpushed,behind,detached +gitmulti check --format json +gitmulti check --fetch --path ~/src --recursive +``` -- `-p, --path`: Path to scan for repositories. Defaults to the current working directory. -- `-r, --recursive`: Scan nested directories recursively. -- `-c, --command`: Git command to execute. +`--fail-on LIST` accepts `dirty`, `unpushed`, `behind`, `diverged`, +`detached`, and `no-upstream`; values may be comma-separated or repeated. +Other flags are `--fetch` (run `git fetch --all --prune` first), +`--format table|json|ndjson` (default `table`), `-q, --quiet`, +`-j, --jobs N` (default `4`), `--timeout DURATION` (for fetches), +`--no-prompt` (set `GIT_TERMINAL_PROMPT=0`), and `--require-match`. +Violations and inspection/fetch failures exit 1; invalid policies or flags +exit 2. Machine formats still emit a structured result when a fetch fails. + +### `doctor` + +Diagnose Git availability, discovery warnings, malformed markers, and, with +`--status`, detached or upstream-less repositories. Malformed `.git` files +and other discovery issues are reported as findings; they do not abort the +scan. + +```sh +gitmulti doctor --path ~/src --recursive --status +gitmulti doctor --format json --strict +``` -You can pass the Git command in either style: +Use `--format text|json` (default `text`), `--status`, `--strict` +(warnings also fail), and `-j, --jobs N` (default `4`). Errors exit 1; +`--strict` also turns warnings into exit 1. -```bash -# Positional style. Use -- before the Git command. -gitmulti exec -- status --short +### `sync` + +Synchronize repositories conservatively. The default `fetch` mode only +fetches. `ff-only` checks state first and runs `git pull --ff-only`; it +refuses dirty, detached, unborn, or upstream-less repositories rather than +merging or rebasing them. -# String style. -gitmulti exec -c "status --short" +```sh +gitmulti sync --recursive --dry-run +gitmulti sync --recursive --mode fetch --remote origin --prune +gitmulti sync --recursive --mode ff-only --output json --timeout 2m ``` -Do not include `git` in the command. `gitmulti` runs `git` for you. +Flags are `--mode fetch|ff-only` (default `fetch`), `--remote NAME` +(fetch defaults to `--all`), `--prune` (default true), `--tags`, +`--dry-run`, `-j, --jobs N` (default `1`), `--fail-fast`, +`--timeout DURATION`, `--no-prompt` (disable Git credential prompts), +`--output grouped|prefixed|raw|json|ndjson` +(default `grouped`), and `--require-match`. Raw output requires +`--jobs=1`. Sync failures and refused unsafe updates exit 1. + +Sync uses Git's configured credential helpers and environment. Do not put +credentials in command-line arguments or scripts, and treat command output as +sensitive. `--dry-run` does not contact remotes or read credentials. -More examples: +### `snapshot` -```bash -gitmulti exec -- fetch --all --prune -gitmulti exec -p C:\Users\username\Documents\projects -- pull --ff-only -gitmulti exec -p .. -r -- branch --show-current -gitmulti ex -c "log --oneline -5" +Export each repository's current commit and branch as JSON or newline-delimited +JSON. By default output goes to stdout; `--output FILE` creates a new file +with mode `0600`, installs it atomically, and refuses to overwrite it unless +`--force` is supplied. Symlink output destinations are refused. + +```sh +gitmulti snapshot --recursive --output snapshot.json +gitmulti snapshot --recursive --include-remotes --format ndjson ``` -If a Git command fails in one repository, `gitmulti` continues through the remaining repositories and exits with an error after all repositories have been attempted. +Use `--format json|ndjson` (default `json`), `-o, --output FILE`, +`--include-remotes`, `--force`, and `--require-match`. Remote URLs are +redacted when `--include-remotes` is enabled: HTTP(S) userinfo, passwords, and +common token, key, secret, signature, credential, and authorization query +parameters are replaced with `REDACTED`. Treat snapshots as sensitive +nevertheless. +Inspection and no-match failures exit 1; invalid arguments exit 2. + +### Trust boundaries + +GitMulti never passes `exec` arguments through a shell, and its fixed status +collector disables repository-configured filesystem-monitor hooks. Git itself +is not a sandbox, however. Git aliases, credential and remote helpers, hooks, +remote URLs, and other repository or user configuration can invoke external +programs. Run `exec`, `check --fetch`, and `sync` only on repositories and Git +configuration you trust. Scan roots must not be concurrently writable by an +untrusted user; static followed symlinks are canonicalized and confined to the +selected root, but GitMulti does not provide descriptor-level isolation from +hostile filesystem replacement races. + +Timeouts bound how long GitMulti waits for a command, including when a +descendant keeps an output pipe open. Cancellation is not guaranteed to +terminate every grandchild process on every operating system. + +### `completion` + +Generate completion scripts for `bash`, `zsh`, `fish`, or `powershell`: + +```sh +gitmulti completion bash > ~/.local/share/bash-completion/completions/gitmulti +gitmulti completion zsh > "${fpath[1]}/_gitmulti" +gitmulti completion fish > ~/.config/fish/completions/gitmulti.fish +gitmulti completion powershell > gitmulti.ps1 +``` ### `version` / `v` -Prints the version number. +Print version and build metadata. `gitmulti --version` and `gitmulti -v` +print the short version; `version --short` does the same. `version --json` +emits `version`, `commit`, `buildDate`, `dirty`, `goVersion`, and +`module`. +Release builds set version, commit, and UTC build date through linker flags; +local builds report `dev` and `unknown` when metadata is unavailable. -```bash -gitmulti version -gitmulti v -gitmulti -v -``` +## Output and exit status ---- +JSON documents use schema version `1` where a `schemaVersion` field is +present. NDJSON emits one complete JSON object per line. List JSON is a +`schemaVersion`/`repositories` document and list NDJSON records have +`type: repository` or `type: diagnostic`. Execution and sync JSON are +`schemaVersion`/`results` documents, while their NDJSON records have +`type: execution`. Status, check, and snapshot NDJSON records also include a +`type` field. JSON goes to stdout and diagnostics/warnings go to stderr. +Machine documents also retain discovery warnings as structured diagnostics. -#### `version` +- `0`: completed successfully and all requested checks passed +- `1`: Git execution, synchronization, status, policy, doctor, or + `--require-match` failure +- `2`: invalid arguments or options -Print the version number and build information of gitmulti. +## Development -**Usage:** -```bash -gitmulti version -gitmulti --version -gitmulti -v -``` +The repository's Makefile defines the complete project gate: -**Output:** -``` -gitmulti version 0.1.0 -https://github.com/ramonzx6/gitmulti +```sh +make check ``` -## License +It checks formatting, runs `go vet`, the full tests and race detector, builds +with metadata, and runs a help/version smoke test. Individual targets include +`make fmt`, `make fmt-check`, `make vet`, `make test`, `make race`, +`make build`, and `make smoke`. See [CONTRIBUTING.md](CONTRIBUTING.md) for +the contribution workflow and validation requirements. -This project is source-available under the [PolyForm Strict License 1.0.0](LICENSE). +## License -You may use the software for noncommercial purposes, but you may not distribute the software or make changes or new works based on it without permission. +This project is source-available under the +[PolyForm Strict License 1.0.0](LICENSE). Noncommercial and personal uses are +permitted under that license. It does not grant permission to distribute the +software or make changes or derivative works without permission. Read the +complete license before using or redistributing this source. From 038bc657c097c00c9429510169cf9cc94311a97d Mon Sep 17 00:00:00 2001 From: ramonzx6 Date: Mon, 10 Aug 2026 08:19:41 +0000 Subject: [PATCH 3/7] ci: add validation and release workflows --- .github/workflows/ci.yml | 110 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 108 +++++++++++++++++++++++++++++++++ Makefile | 39 ++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Makefile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6961765 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + format: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - name: Check formatting + shell: bash + run: test -z "$(gofmt -l $(find . -type f -name '*.go' -not -path './vendor/*'))" + + vet: + name: Vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - run: go vet ./... + + test: + name: Tests (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-latest + - name: Windows + os: windows-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - run: go test ./... + + race: + name: Race tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - run: go test -race ./... + + build: + name: Build (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-latest + - name: Windows + os: windows-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - name: Build + run: go build -trimpath ./... + + smoke: + name: CLI smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - run: go run . --help + - run: go run . version --json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33d3f23 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + verify: + name: Verify release source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - run: make check + + package: + name: Package ${{ matrix.goos }}/${{ matrix.goarch }} + needs: verify + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - name: Build archive + env: + VERSION: ${{ github.ref_name }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + shell: bash + run: | + set -eu + mkdir -p dist + binary=gitmulti + if [ "$GOOS" = windows ]; then + binary=gitmulti.exe + fi + build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) + GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X github.com/ramonzx6/gitmulti/cmd.Version=$VERSION -X github.com/ramonzx6/gitmulti/cmd.Commit=$GITHUB_SHA -X github.com/ramonzx6/gitmulti/cmd.BuildDate=$build_date" -o "dist/$binary" . + cp LICENSE README.md dist/ + archive=$(printf 'gitmulti_%s_%s_%s' "$VERSION" "$GOOS" "$GOARCH") + if [ "$GOOS" = windows ]; then + (cd dist && zip -q "../dist/$archive.zip" "$binary" LICENSE README.md) + (cd dist && sha256sum "$archive.zip" > "$archive.zip.sha256") + else + (cd dist && tar -czf "../dist/$archive.tar.gz" "$binary" LICENSE README.md) + (cd dist && sha256sum "$archive.tar.gz" > "$archive.tar.gz.sha256") + fi + rm -f "dist/$binary" dist/LICENSE dist/README.md + - name: Attest release archive + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: dist/gitmulti_* + - name: Upload archive + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: gitmulti-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/* + if-no-files-found: error + + publish: + name: Publish GitHub release + needs: package + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + path: dist + merge-multiple: true + - name: Create release and upload archives + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes --title "$GITHUB_REF_NAME" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ce37443 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +GO ?= go +GOFMT ?= gofmt +BINARY ?= gitmulti +VERSION ?= dev +COMMIT ?= unknown +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +LDFLAGS ?= -s -w -X github.com/ramonzx6/gitmulti/cmd.Version=$(VERSION) -X github.com/ramonzx6/gitmulti/cmd.Commit=$(COMMIT) -X github.com/ramonzx6/gitmulti/cmd.BuildDate=$(BUILD_DATE) + +.PHONY: all fmt fmt-check vet test race build smoke check clean + +all: check + +fmt: + $(GOFMT) -w $$(find . -type f -name '*.go' -not -path './vendor/*') + +fmt-check: + @test -z "$$($(GOFMT) -l $$(find . -type f -name '*.go' -not -path './vendor/*'))" + +vet: + $(GO) vet ./... + +test: + $(GO) test ./... + +race: + $(GO) test -race ./... + +build: + $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BINARY)" . + +smoke: build + @./$(BINARY) --help >/dev/null + @./$(BINARY) version --json >/dev/null + +check: fmt-check vet test race build smoke + +clean: + rm -f "$(BINARY)" "$(BINARY).exe" From 4a7246860d3311a05a1ce983dd45f8da287d65fb Mon Sep 17 00:00:00 2001 From: ramonzx6 Date: Thu, 13 Aug 2026 03:00:49 +0000 Subject: [PATCH 4/7] refactor(cli): organize commands by package --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 2 +- .gitignore | 4 +- CONTRIBUTING.md | 5 + Makefile | 4 +- README.md | 24 +- cmd/command_core_test.go | 155 ----- cmd/command_test_helpers_test.go | 63 -- cmd/doctor.go | 119 ---- cmd/exec_command_test.go | 98 ---- cmd/extended_command_test.go | 545 ------------------ cmd/gitmulti/main.go | 11 + cmd/list_command_test.go | 177 ------ cmd/policy_command_test.go | 46 -- cmd/root.go | 144 ----- cmd/snapshot.go | 365 ------------ cmd/snapshot_command_test.go | 202 ------- cmd/sync_completion_test.go | 96 --- cmd/utils.go | 81 --- internal/cli/app/app.go | 53 ++ internal/cli/app/app_test.go | 33 ++ internal/cli/buildinfo/buildinfo.go | 45 ++ internal/cli/buildinfo/buildinfo_test.go | 14 + internal/cli/clierr/errors.go | 65 +++ internal/cli/clierr/errors_test.go | 39 ++ .../cli/command/check/command.go | 110 ++-- internal/cli/command/check/command_test.go | 224 +++++++ .../cli/command/completion/command.go | 15 +- .../cli/command/completion/command_test.go | 49 ++ internal/cli/command/doctor/command.go | 128 ++++ internal/cli/command/doctor/command_test.go | 155 +++++ internal/cli/command/exec/command.go | 117 ++++ internal/cli/command/exec/command_test.go | 263 +++++++++ internal/cli/command/exec/fixture_test.go | 96 +++ internal/cli/command/exec/parse.go | 30 + .../cli/command/list/command.go | 47 +- internal/cli/command/list/command_test.go | 209 +++++++ internal/cli/command/snapshot/command.go | 186 ++++++ internal/cli/command/snapshot/command_test.go | 274 +++++++++ internal/cli/command/snapshot/fixture_test.go | 94 +++ internal/cli/command/snapshot/remotes.go | 79 +++ internal/cli/command/snapshot/writer.go | 124 ++++ .../cli/command/status/command.go | 100 +--- internal/cli/command/status/command_test.go | 211 +++++++ .../cli/command/sync/command.go | 113 ++-- internal/cli/command/sync/command_test.go | 212 +++++++ internal/cli/command/sync/fixture_test.go | 109 ++++ internal/cli/command/sync/safety.go | 62 ++ .../cli/command/version/command.go | 13 +- internal/cli/command/version/command_test.go | 102 ++++ .../cli/execution/execution.go | 183 ++---- internal/cli/execution/execution_test.go | 461 +++++++++++++++ internal/cli/output/output.go | 6 + internal/cli/output/output_test.go | 9 + internal/cli/root.go | 89 +++ internal/cli/root_test.go | 87 +++ .../cli/scanflags/scanflags.go | 62 +- internal/cli/scanflags/scanflags_test.go | 85 +++ internal/cli/statusset/statusset.go | 63 ++ internal/cli/statusset/statusset_test.go | 250 ++++++++ main.go | 11 - 61 files changed, 4351 insertions(+), 2471 deletions(-) delete mode 100644 cmd/command_core_test.go delete mode 100644 cmd/command_test_helpers_test.go delete mode 100644 cmd/doctor.go delete mode 100644 cmd/exec_command_test.go delete mode 100644 cmd/extended_command_test.go create mode 100644 cmd/gitmulti/main.go delete mode 100644 cmd/list_command_test.go delete mode 100644 cmd/policy_command_test.go delete mode 100644 cmd/root.go delete mode 100644 cmd/snapshot.go delete mode 100644 cmd/snapshot_command_test.go delete mode 100644 cmd/sync_completion_test.go delete mode 100644 cmd/utils.go create mode 100644 internal/cli/app/app.go create mode 100644 internal/cli/app/app_test.go create mode 100644 internal/cli/buildinfo/buildinfo.go create mode 100644 internal/cli/buildinfo/buildinfo_test.go create mode 100644 internal/cli/clierr/errors.go create mode 100644 internal/cli/clierr/errors_test.go rename cmd/check.go => internal/cli/command/check/command.go (59%) create mode 100644 internal/cli/command/check/command_test.go rename cmd/completion.go => internal/cli/command/completion/command.go (59%) create mode 100644 internal/cli/command/completion/command_test.go create mode 100644 internal/cli/command/doctor/command.go create mode 100644 internal/cli/command/doctor/command_test.go create mode 100644 internal/cli/command/exec/command.go create mode 100644 internal/cli/command/exec/command_test.go create mode 100644 internal/cli/command/exec/fixture_test.go create mode 100644 internal/cli/command/exec/parse.go rename cmd/list.go => internal/cli/command/list/command.go (66%) create mode 100644 internal/cli/command/list/command_test.go create mode 100644 internal/cli/command/snapshot/command.go create mode 100644 internal/cli/command/snapshot/command_test.go create mode 100644 internal/cli/command/snapshot/fixture_test.go create mode 100644 internal/cli/command/snapshot/remotes.go create mode 100644 internal/cli/command/snapshot/writer.go rename cmd/status.go => internal/cli/command/status/command.go (57%) create mode 100644 internal/cli/command/status/command_test.go rename cmd/sync.go => internal/cli/command/sync/command.go (52%) create mode 100644 internal/cli/command/sync/command_test.go create mode 100644 internal/cli/command/sync/fixture_test.go create mode 100644 internal/cli/command/sync/safety.go rename cmd/version.go => internal/cli/command/version/command.go (67%) create mode 100644 internal/cli/command/version/command_test.go rename cmd/exec.go => internal/cli/execution/execution.go (60%) create mode 100644 internal/cli/execution/execution_test.go create mode 100644 internal/cli/output/output.go create mode 100644 internal/cli/output/output_test.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go rename cmd/options.go => internal/cli/scanflags/scanflags.go (52%) create mode 100644 internal/cli/scanflags/scanflags_test.go create mode 100644 internal/cli/statusset/statusset.go create mode 100644 internal/cli/statusset/statusset_test.go delete mode 100644 main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6961765..52b9116 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,5 +106,5 @@ jobs: with: go-version-file: go.mod cache: true - - run: go run . --help - - run: go run . version --json + - run: go run ./cmd/gitmulti --help + - run: go run ./cmd/gitmulti version --json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33d3f23..8b6a176 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: binary=gitmulti.exe fi build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) - GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X github.com/ramonzx6/gitmulti/cmd.Version=$VERSION -X github.com/ramonzx6/gitmulti/cmd.Commit=$GITHUB_SHA -X github.com/ramonzx6/gitmulti/cmd.BuildDate=$build_date" -o "dist/$binary" . + GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.Version=$VERSION -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.Commit=$GITHUB_SHA -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.BuildDate=$build_date" -o "dist/$binary" ./cmd/gitmulti cp LICENSE README.md dist/ archive=$(printf 'gitmulti_%s_%s_%s' "$VERSION" "$GOOS" "$GOARCH") if [ "$GOOS" = windows ]; then diff --git a/.gitignore b/.gitignore index 8e1e017..83a137a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .env -gitmulti +/gitmulti test/ -gitmulti.exe +/gitmulti.exe reviewtmp/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9cf103..d9fe64a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,11 @@ after -- and avoid introducing shell parsing or command interpolation. Changes that execute Git should preserve cancellation, timeouts, and per-repository error reporting. +User-facing commands live in `internal/cli/command/`. Keep each command's +constructor, private rendering or policy helpers, and focused tests in that +package. Shared behavior belongs in a neutral `internal/cli` subpackage; leaf +commands must not import the CLI root or sibling commands. + ## Local validation Go 1.24 or newer and Git are required. Run the complete project gate before diff --git a/Makefile b/Makefile index ce37443..fd9ccb3 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ VERSION ?= dev COMMIT ?= unknown BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -LDFLAGS ?= -s -w -X github.com/ramonzx6/gitmulti/cmd.Version=$(VERSION) -X github.com/ramonzx6/gitmulti/cmd.Commit=$(COMMIT) -X github.com/ramonzx6/gitmulti/cmd.BuildDate=$(BUILD_DATE) +LDFLAGS ?= -s -w -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.Version=$(VERSION) -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.Commit=$(COMMIT) -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.BuildDate=$(BUILD_DATE) .PHONY: all fmt fmt-check vet test race build smoke check clean @@ -27,7 +27,7 @@ race: $(GO) test -race ./... build: - $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BINARY)" . + $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BINARY)" ./cmd/gitmulti smoke: build @./$(BINARY) --help >/dev/null diff --git a/README.md b/README.md index c0ff209..74fa589 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Cobra. Install the latest version with Go: ```sh -go install github.com/ramonzx6/gitmulti@latest +go install github.com/ramonzx6/gitmulti/cmd/gitmulti@latest ``` The binary is placed in `go env GOBIN`, or `go env GOPATH`/`bin` when @@ -24,11 +24,12 @@ To build from a checkout: ```sh git clone https://github.com/ramonzx6/gitmulti.git cd gitmulti -go build -o gitmulti . +go build -o gitmulti ./cmd/gitmulti ./gitmulti --help ``` -On Windows, use `go build -o gitmulti.exe .` and run `./gitmulti.exe`. +On Windows, use `go build -o gitmulti.exe ./cmd/gitmulti` and run +`./gitmulti.exe`. ## Quick start @@ -299,6 +300,23 @@ with metadata, and runs a help/version smoke test. Individual targets include `make build`, and `make smoke`. See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow and validation requirements. +The Go source follows the standard command/internal split: + +```text +cmd/gitmulti/ executable entry point only +internal/cli/ root construction and shared CLI adapters +internal/cli/command// one package per user-facing command +internal/gitexec/ direct Git process execution +internal/gitstatus/ porcelain status parsing +internal/repository/ repository discovery +``` + +Command packages expose a small `New(...)` constructor and keep their flag +state, renderers, and focused tests together. Reusable scheduling, discovery +flags, exit errors, build metadata, and status batching live in neutral +`internal/cli` subpackages so leaf commands do not import the root or each +other. + ## License This project is source-available under the diff --git a/cmd/command_core_test.go b/cmd/command_core_test.go deleted file mode 100644 index f4f5973..0000000 --- a/cmd/command_core_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - - "github.com/ramonzx6/gitmulti/internal/repository" -) - -func TestRunReturnsTypedExitCodesAndPrintsOneError(t *testing.T) { - var stdout, stderr bytes.Buffer - app := discardApp() - app.Out = &stdout - app.Err = &stderr - code := Run(context.Background(), []string{"exec"}, app) - if code != 2 { - t.Fatalf("usage exit code = %d, want 2", code) - } - if got := strings.Count(stderr.String(), "Error:"); got != 1 { - t.Fatalf("stderr has %d error prefixes, want 1: %q", got, stderr.String()) - } - if strings.Contains(stderr.String(), "Usage:") { - t.Fatalf("runtime stderr unexpectedly includes usage: %q", stderr.String()) - } - - stdout.Reset() - stderr.Reset() - code = Run(context.Background(), []string{"list", "--require-match"}, app) - if code != 1 { - t.Fatalf("require-match exit code = %d, want 1", code) - } -} - -func TestParseGitCommandSupportsBothInvocationStyles(t *testing.T) { - tests := []struct { - name string - command string - args []string - want []string - }{ - { - name: "legacy command string", - command: `commit -m "hello world"`, - want: []string{"commit", "-m", "hello world"}, - }, - { - name: "positional arguments", - args: []string{"status", "--short"}, - want: []string{"status", "--short"}, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := parseGitCommand(test.command, test.args) - if err != nil { - t.Fatalf("parseGitCommand returned error: %v", err) - } - if !reflect.DeepEqual(got, test.want) { - t.Fatalf("parsed args = %#v, want %#v", got, test.want) - } - }) - } -} - -func TestParseGitCommandRejectsEmptyMixedAndMalformedInput(t *testing.T) { - tests := []struct { - name string - command string - args []string - want string - }{ - {name: "empty", want: "Git command cannot be empty"}, - {name: "mixed", command: "status", args: []string{"log"}, want: "either --command or positional"}, - {name: "malformed quoting", command: `commit -m "unterminated`, want: "invalid Git command"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := parseGitCommand(test.command, test.args) - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("error = %v, want substring %q", err, test.want) - } - }) - } -} - -func TestNewRootCommandDoesNotLeakFlagsBetweenInstances(t *testing.T) { - var options []string - app := App{ - Discover: func(_ context.Context, opts repository.Options) (repository.Result, error) { - options = append(options, opts.Root) - return repository.Result{}, nil - }, - } - - first := runCLI(t, app, "list", "--path", "/first-root") - if first.err != nil { - t.Fatalf("first command returned error: %v", first.err) - } - second := runCLI(t, app, "list") - if second.err != nil { - t.Fatalf("second command returned error: %v", second.err) - } - if !reflect.DeepEqual(options, []string{"/first-root", "."}) { - t.Fatalf("discovery roots = %#v, want %#v", options, []string{"/first-root", "."}) - } -} - -func TestVersionJSONAndArguments(t *testing.T) { - result := runCLI(t, discardApp(), "version", "--json") - if result.err != nil { - t.Fatalf("version --json returned error: %v", result.err) - } - var got buildInfo - if err := json.Unmarshal([]byte(result.stdout), &got); err != nil { - t.Fatalf("version output is not JSON: %v\n%s", err, result.stdout) - } - if got.Version == "" || got.Commit == "" || got.BuildDate == "" { - t.Fatalf("version JSON omitted required fields: %#v", got) - } - - short := runCLI(t, discardApp(), "version", "--short") - if short.err != nil { - t.Fatalf("version --short returned error: %v", short.err) - } - if strings.TrimSpace(short.stdout) != got.Version { - t.Fatalf("short version = %q, JSON version = %q", strings.TrimSpace(short.stdout), got.Version) - } - - unexpected := runCLI(t, discardApp(), "version", "unexpected-argument") - if unexpected.err == nil { - t.Fatalf("unexpected version argument error = %v", unexpected.err) - } - - conflicting := runCLI(t, discardApp(), "version", "--short", "--json") - var coded interface{ ExitCode() int } - if conflicting.err == nil || !strings.Contains(conflicting.err.Error(), "short") || !strings.Contains(conflicting.err.Error(), "json") || !errors.As(conflicting.err, &coded) || coded.ExitCode() != 2 { - t.Fatalf("conflicting version flags error = %v", conflicting.err) - } -} - -func TestExitErrorExposesExitCode(t *testing.T) { - err := errorsForFlag("invalid flag value") - var coded interface{ ExitCode() int } - if !errors.As(err, &coded) { - t.Fatalf("error %v does not expose an exit code", err) - } - if coded.ExitCode() != 2 { - t.Fatalf("exit code = %d, want 2", coded.ExitCode()) - } -} diff --git a/cmd/command_test_helpers_test.go b/cmd/command_test_helpers_test.go deleted file mode 100644 index d042810..0000000 --- a/cmd/command_test_helpers_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "io" - "path/filepath" - "testing" - - "github.com/ramonzx6/gitmulti/internal/gitexec" - "github.com/ramonzx6/gitmulti/internal/repository" -) - -type commandResult struct { - stdout string - stderr string - err error -} - -func runCLI(t *testing.T, app App, args ...string) commandResult { - t.Helper() - var stdout, stderr bytes.Buffer - app.Out = &stdout - app.Err = &stderr - if app.In == nil { - app.In = bytes.NewReader(nil) - } - root := NewRootCommand(app) - root.SetArgs(args) - err := root.Execute() - return commandResult{stdout: stdout.String(), stderr: stderr.String(), err: err} -} - -func fixtureRepositories(root string, names ...string) repository.Result { - result := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} - for _, name := range names { - result.Repositories = append(result.Repositories, repository.Repository{ - Name: name, - Path: filepath.Join(root, filepath.FromSlash(name)), - }) - } - return result -} - -func appWithRepositories(result repository.Result) App { - return App{ - In: bytes.NewReader(nil), - Git: gitexec.Runner{Executable: "gitmulti-test-git-does-not-exist"}, - Discover: func(context.Context, repository.Options) (repository.Result, error) { - return result, nil - }, - } -} - -func discardApp() App { - return App{ - In: bytes.NewReader(nil), - Out: io.Discard, - Err: io.Discard, - Git: gitexec.Runner{Executable: "gitmulti-test-git-does-not-exist"}, - Discover: func(context.Context, repository.Options) (repository.Result, error) { return repository.Result{}, nil }, - } -} diff --git a/cmd/doctor.go b/cmd/doctor.go deleted file mode 100644 index 87ca5d1..0000000 --- a/cmd/doctor.go +++ /dev/null @@ -1,119 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/spf13/cobra" -) - -type doctorFinding struct { - Severity string `json:"severity"` - Check string `json:"check"` - Path string `json:"path,omitempty"` - Message string `json:"message"` -} - -type doctorDocument struct { - SchemaVersion int `json:"schemaVersion"` - Findings []doctorFinding `json:"findings"` -} - -func newDoctorCommand(app App) *cobra.Command { - var scan scanFlags - var format string - var includeStatus bool - var strict bool - var jobs int - - command := &cobra.Command{ - Use: "doctor", - Short: "Diagnose Git and repository discovery problems", - Args: noArgs, - RunE: func(command *cobra.Command, _ []string) error { - if format != "text" && format != "json" { - return errorsForFlag(fmt.Sprintf("unsupported doctor format %q", format)) - } - if jobs < 1 { - return errorsForFlag("--jobs must be at least 1") - } - findings := make([]doctorFinding, 0) - gitAvailable := true - gitPath, err := app.Git.LookPath() - if err != nil { - gitAvailable = false - findings = append(findings, doctorFinding{Severity: "error", Check: "git", Message: err.Error()}) - } else { - version := app.Git.Run(command.Context(), "", []string{"--version"}, nil) - if version.Err != nil { - findings = append(findings, doctorFinding{Severity: "error", Check: "git", Path: gitPath, Message: version.Err.Error()}) - } else { - findings = append(findings, doctorFinding{Severity: "info", Check: "git", Path: gitPath, Message: strings.TrimSpace(version.Stdout)}) - } - } - - scan.validate = gitAvailable - discovered, discoverErr := discoverRepositories(command.Context(), app, command, scan) - if discoverErr != nil { - findings = append(findings, doctorFinding{Severity: "error", Check: "discovery", Path: scan.path, Message: discoverErr.Error()}) - } else { - findings = append(findings, doctorFinding{Severity: "info", Check: "discovery", Path: scan.path, Message: fmt.Sprintf("found %d repositories", len(discovered.Repositories))}) - for _, warning := range discovered.Warnings { - findings = append(findings, doctorFinding{Severity: "warning", Check: "discovery", Path: warning.Path, Message: warning.Message}) - } - if includeStatus && gitAvailable { - states := collectStatuses(command.Context(), app, discovered.Repositories, jobs) - for _, state := range states { - switch { - case state.Err != nil: - findings = append(findings, doctorFinding{Severity: "error", Check: "status", Path: state.Path, Message: state.Error}) - case state.Detached: - findings = append(findings, doctorFinding{Severity: "warning", Check: "status", Path: state.Path, Message: "detached HEAD"}) - case state.NoUpstream: - findings = append(findings, doctorFinding{Severity: "warning", Check: "status", Path: state.Path, Message: "branch has no upstream"}) - } - } - } - } - - if err := renderDoctor(command, findings, format); err != nil { - return err - } - failed := 0 - for _, finding := range findings { - if finding.Severity == "error" || strict && finding.Severity == "warning" { - failed++ - } - } - if failed > 0 { - return newExitError(1, "doctor found %d problems", failed) - } - return nil - }, - } - scan.bind(command) - command.Flags().StringVar(&format, "format", "text", "output format: text or json") - command.Flags().BoolVar(&includeStatus, "status", false, "check detached HEAD and upstream state") - command.Flags().BoolVar(&strict, "strict", false, "treat warnings as failures") - command.Flags().IntVarP(&jobs, "jobs", "j", 4, "maximum concurrent status checks") - return command -} - -func renderDoctor(command *cobra.Command, findings []doctorFinding, format string) error { - if format == "json" { - encoder := json.NewEncoder(command.OutOrStdout()) - encoder.SetIndent("", " ") - return encoder.Encode(doctorDocument{SchemaVersion: outputSchemaVersion, Findings: findings}) - } - for _, finding := range findings { - location := "" - if finding.Path != "" { - location = " (" + finding.Path + ")" - } - if _, err := fmt.Fprintf(command.OutOrStdout(), "%s %s%s: %s\n", strings.ToUpper(finding.Severity), finding.Check, location, finding.Message); err != nil { - return err - } - } - return nil -} diff --git a/cmd/exec_command_test.go b/cmd/exec_command_test.go deleted file mode 100644 index f82e48c..0000000 --- a/cmd/exec_command_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "errors" - "path/filepath" - "strings" - "testing" - - "github.com/ramonzx6/gitmulti/internal/repository" -) - -func TestExecAcceptsPositionalGitArgumentsWithoutExecutingGit(t *testing.T) { - root := t.TempDir() - fixture := fixtureRepositories(root, "repo") - result := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run", "--", "status", "--short") - if result.err != nil { - t.Fatalf("positional exec returned error: %v", result.err) - } - want := "git -C " + quoteForTest(filepath.Join(root, "repo")) + " \"status\" \"--short\"\n" - if result.stdout != want { - t.Fatalf("dry-run output = %q, want %q", result.stdout, want) - } -} - -func TestExecSupportsLegacyCommandStringAndRejectsMissingOrMixedStyles(t *testing.T) { - fixture := fixtureRepositories(t.TempDir(), "repo") - legacy := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run", "--command", `status --short`) - if legacy.err != nil { - t.Fatalf("legacy -c exec returned error: %v", legacy.err) - } - if !strings.Contains(legacy.stdout, `"status" "--short"`) { - t.Fatalf("legacy dry-run output = %q, want parsed arguments", legacy.stdout) - } - - missing := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run") - if missing.err == nil || !strings.Contains(missing.err.Error(), "Git command cannot be empty") { - t.Fatalf("missing command error = %v", missing.err) - } - - mixed := runCLI(t, appWithRepositories(fixture), "exec", "--dry-run", "--command", "status", "--", "log") - if mixed.err == nil || !strings.Contains(mixed.err.Error(), "either --command or positional") { - t.Fatalf("mixed command styles error = %v", mixed.err) - } -} - -func TestExecDryRunDoesNotRequireGitExecutable(t *testing.T) { - fixture := fixtureRepositories(t.TempDir(), "repo") - app := appWithRepositories(fixture) - app.Git.Executable = "definitely-not-installed-gitmulti-test-git" - result := runCLI(t, app, "exec", "--dry-run", "--", "status") - if result.err != nil { - t.Fatalf("dry-run unexpectedly resolved Git: %v", result.err) - } -} - -func TestExecValidatesExecutionFlags(t *testing.T) { - fixture := fixtureRepositories(t.TempDir(), "repo") - tests := []struct { - name string - args []string - want string - }{ - {name: "zero jobs", args: []string{"exec", "--jobs", "0", "--dry-run", "--", "status"}, want: "--jobs must be at least 1"}, - {name: "negative timeout", args: []string{"exec", "--timeout=-1s", "--dry-run", "--", "status"}, want: "--timeout cannot be negative"}, - {name: "interactive parallel", args: []string{"exec", "--interactive", "--jobs", "2", "--dry-run", "--", "status"}, want: "--interactive requires --jobs=1"}, - {name: "raw parallel", args: []string{"exec", "--output", "raw", "--jobs", "2", "--dry-run", "--", "status"}, want: "raw output requires --jobs=1"}, - {name: "invalid output", args: []string{"exec", "--output", "invalid", "--dry-run", "--", "status"}, want: "unsupported output mode"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := runCLI(t, appWithRepositories(fixture), test.args...) - if result.err == nil || !strings.Contains(result.err.Error(), test.want) { - t.Fatalf("error = %v, want substring %q", result.err, test.want) - } - var coded interface{ ExitCode() int } - if !errors.As(result.err, &coded) || coded.ExitCode() != 2 { - t.Fatalf("error = %v, want usage exit code 2", result.err) - } - }) - } -} - -func TestExecRequiresMatchWhenRequested(t *testing.T) { - result := runCLI(t, appWithRepositories(repository.Result{}), "exec", "--dry-run", "--require-match", "--", "status") - if result.err == nil || !strings.Contains(result.err.Error(), "no Git repositories matched") { - t.Fatalf("require-match error = %v", result.err) - } - var coded interface{ ExitCode() int } - if !errors.As(result.err, &coded) { - t.Fatalf("require-match error = %v, want an exit code", result.err) - } -} - -func quoteForTest(value string) string { - // displayCommand uses strconv.Quote, whose output is deterministic for - // paths produced by TempDir on every supported platform. - return `"` + strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `"`, `\"`) + `"` -} diff --git a/cmd/extended_command_test.go b/cmd/extended_command_test.go deleted file mode 100644 index 1fb1570..0000000 --- a/cmd/extended_command_test.go +++ /dev/null @@ -1,545 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "runtime" - "strings" - "sync" - "testing" - - "github.com/ramonzx6/gitmulti/internal/gitexec" - "github.com/ramonzx6/gitmulti/internal/repository" -) - -func TestStatusCommandRendersTableJSONAndDirtyOnly(t *testing.T) { - app, _ := fakeStatusApp(t, "clean", "dirty") - - table := runCLI(t, app, "status", "--format", "table", "--jobs", "1") - if table.err != nil { - t.Fatalf("status table returned error: %v", table.err) - } - if !strings.Contains(table.stdout, "REPOSITORY\tBRANCH\tSTATE\tUPSTREAM\tAHEAD\tBEHIND") || - !strings.Contains(table.stdout, "clean\tmain\tclean\torigin/main\t0\t0") || - !strings.Contains(table.stdout, "dirty\tmain\tdirty\torigin/main\t0\t0") { - t.Fatalf("status table output = %q", table.stdout) - } - - dirty := runCLI(t, app, "status", "--dirty-only", "--format", "table") - if dirty.err != nil { - t.Fatalf("status --dirty-only returned error: %v", dirty.err) - } - if strings.Contains(dirty.stdout, "clean\tmain") || !strings.Contains(dirty.stdout, "dirty\tmain\tdirty") { - t.Fatalf("dirty-only output = %q", dirty.stdout) - } - - jsonResult := runCLI(t, app, "status", "--format", "json") - if jsonResult.err != nil { - t.Fatalf("status JSON returned error: %v", jsonResult.err) - } - var document statusDocument - if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { - t.Fatalf("status JSON decode failed: %v\n%s", err, jsonResult.stdout) - } - if document.SchemaVersion != outputSchemaVersion || len(document.Repositories) != 2 { - t.Fatalf("status document = %#v", document) - } -} - -func TestStatusCommandReportsCollectionErrors(t *testing.T) { - app, _ := fakeStatusApp(t, "error") - result := runCLI(t, app, "status", "--format", "table") - if result.err == nil || !strings.Contains(result.err.Error(), "status collection failed") { - t.Fatalf("status error = %v", result.err) - } - if !strings.Contains(result.stdout, "error\t-") || !strings.Contains(result.stderr, "error:") { - t.Fatalf("status collection output stdout=%q stderr=%q", result.stdout, result.stderr) - } -} - -func TestStatusNoAheadBehindRequestsUnknownCounts(t *testing.T) { - app, _ := fakeStatusApp(t, "unknown-noahead") - result := runCLI(t, app, "status", "--no-ahead-behind", "--format", "json") - if result.err != nil { - t.Fatalf("status --no-ahead-behind returned error: %v", result.err) - } - var document statusDocument - if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { - t.Fatalf("status JSON decode failed: %v\n%s", err, result.stdout) - } - if len(document.Repositories) != 1 || document.Repositories[0].AheadBehindKnown || document.Repositories[0].Ahead != 0 || document.Repositories[0].Behind != 0 { - t.Fatalf("unknown ahead/behind state = %#v", document.Repositories) - } - - ndjson := runCLI(t, app, "status", "--no-ahead-behind", "--format", "ndjson") - if ndjson.err != nil { - t.Fatalf("status NDJSON returned error: %v", ndjson.err) - } - var line statusLine - if err := json.Unmarshal([]byte(strings.TrimSpace(ndjson.stdout)), &line); err != nil { - t.Fatalf("status NDJSON decode failed: %v", err) - } - if line.Type != "status" || line.Status == nil || line.Status.AheadBehindKnown { - t.Fatalf("status NDJSON line = %#v", line) - } - - unknownApp, _ := fakeStatusApp(t, "unknown") - table := runCLI(t, unknownApp, "status", "--format", "table") - if table.err != nil || !strings.Contains(table.stdout, "unknown\tmain\tclean\torigin/main\t-\t-") { - t.Fatalf("unknown table result = %#v", table) - } -} - -func TestCheckCommandPassFailQuietJSONAndAllPolicies(t *testing.T) { - passApp, _ := fakeStatusApp(t, "clean") - pass := runCLI(t, passApp, "check", "--fail-on", "dirty,unpushed,behind,diverged,detached,no-upstream") - if pass.err != nil || !strings.Contains(pass.stdout, "clean\tok") { - t.Fatalf("passing check result = %#v", pass) - } - - failApp, _ := fakeStatusApp(t, "all") - fail := runCLI(t, failApp, "check", "--fail-on", "dirty,unpushed,behind,diverged,detached,no-upstream") - if fail.err == nil || !strings.Contains(fail.err.Error(), "policy check failed") { - t.Fatalf("failing check error = %v", fail.err) - } - for _, policy := range []string{"dirty", "diverged", "detached", "no-upstream", "unpushed", "behind"} { - if !strings.Contains(fail.stdout, policy) { - t.Fatalf("failing check output lacks %q: %q", policy, fail.stdout) - } - } - if !strings.Contains(fail.stdout, "all\tfail\t") { - t.Fatalf("failing check output = %q", fail.stdout) - } - - quiet := runCLI(t, failApp, "check", "--quiet", "--fail-on", "dirty") - if quiet.err == nil || quiet.stdout != "" || quiet.stderr != "" { - t.Fatalf("quiet check result = %#v, want failure with no output", quiet) - } - - jsonResult := runCLI(t, failApp, "check", "--format", "json", "--fail-on", "dirty") - if jsonResult.err == nil { - t.Fatal("JSON check should retain failing exit status") - } - var document checkDocument - if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { - t.Fatalf("check JSON decode failed: %v\n%s", err, jsonResult.stdout) - } - if document.SchemaVersion != outputSchemaVersion || len(document.Results) != 1 || !reflect.DeepEqual(document.Results[0].Violations, []string{"dirty"}) { - t.Fatalf("check document = %#v", document) - } -} - -func TestCheckFetchNoPromptSetsGitTerminalPromptAndTimeoutOption(t *testing.T) { - app, _ := fakeStatusApp(t, "prompt-check") - result := runCLI(t, app, "check", "--fetch", "--no-prompt", "--timeout", "1s", "--fail-on", "dirty") - if result.err != nil { - t.Fatalf("check --fetch --no-prompt returned error: %v\nstdout=%q\nstderr=%q", result.err, result.stdout, result.stderr) - } -} - -func TestCheckFetchFailureStillRendersStructuredResults(t *testing.T) { - app, _ := fakeStatusApp(t, "fetch-fail") - result := runCLI(t, app, "check", "--fetch", "--format", "json", "--fail-on", "dirty") - if result.err == nil || !strings.Contains(result.err.Error(), "fetch failed") { - t.Fatalf("check fetch failure error = %v", result.err) - } - var document checkDocument - if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { - t.Fatalf("fetch failure output is not valid JSON: %v\n%s", err, result.stdout) - } - if len(document.Results) != 1 || document.Results[0].FetchError == "" { - t.Fatalf("fetch failure result = %#v", document.Results) - } - - ndjson := runCLI(t, app, "check", "--fetch", "--format", "ndjson", "--fail-on", "dirty") - if ndjson.err == nil { - t.Fatal("NDJSON fetch failure should retain a failing exit status") - } - lines := strings.Split(strings.TrimSpace(ndjson.stdout), "\n") - if len(lines) != 1 || !strings.Contains(lines[0], "fetchError") { - t.Fatalf("NDJSON fetch failure output = %q", ndjson.stdout) - } -} - -func TestDoctorCommandTextJSONErrorsAndStrictWarnings(t *testing.T) { - app, _ := fakeStatusApp(t, "clean") - textResult := runCLI(t, app, "doctor") - if textResult.err != nil || !strings.Contains(textResult.stdout, "INFO git") || !strings.Contains(textResult.stdout, "INFO discovery") { - t.Fatalf("doctor text result = %#v", textResult) - } - - jsonResult := runCLI(t, app, "doctor", "--format", "json") - if jsonResult.err != nil { - t.Fatalf("doctor JSON returned error: %v", jsonResult.err) - } - var document doctorDocument - if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { - t.Fatalf("doctor JSON decode failed: %v\n%s", err, jsonResult.stdout) - } - if document.SchemaVersion != outputSchemaVersion || len(document.Findings) < 2 { - t.Fatalf("doctor document = %#v", document) - } - - missing := app - missing.Git = gitexec.Runner{Executable: "gitmulti-doctor-command-missing"} - errorResult := runCLI(t, missing, "doctor") - if errorResult.err == nil || !strings.Contains(errorResult.stdout, "ERROR git") { - t.Fatalf("doctor missing-git result = %#v", errorResult) - } - - warningApp := app - warningApp.Discover = func(context.Context, repository.Options) (repository.Result, error) { - return repository.Result{Warnings: []repository.Warning{{Path: "/tmp/warn", Message: "inaccessible"}}}, nil - } - strict := runCLI(t, warningApp, "doctor", "--strict") - if strict.err == nil || !strings.Contains(strict.err.Error(), "doctor found") || !strings.Contains(strict.stdout, "WARNING discovery") { - t.Fatalf("doctor strict result = %#v", strict) - } - quietWarning := runCLI(t, warningApp, "check", "--quiet") - if quietWarning.err != nil || quietWarning.stdout != "" || quietWarning.stderr != "" { - t.Fatalf("quiet check warning result = %#v", quietWarning) - } - invalidTimeout := runCLI(t, app, "check", "--timeout=-1s") - var coded interface{ ExitCode() int } - if invalidTimeout.err == nil || !errors.As(invalidTimeout.err, &coded) || coded.ExitCode() != 2 { - t.Fatalf("check timeout error = %v, want exit code 2", invalidTimeout.err) - } -} - -func TestExecRendersAllOutputModes(t *testing.T) { - app, fixture := fakeStatusApp(t, "clean") - for _, test := range []struct { - name string - args []string - }{ - {name: "grouped", args: []string{"exec", "--output", "grouped", "--", "emit"}}, - {name: "prefixed", args: []string{"exec", "--output", "prefixed", "--", "emit"}}, - {name: "raw", args: []string{"exec", "--output", "raw", "--", "emit"}}, - } { - t.Run(test.name, func(t *testing.T) { - result := runCLI(t, app, test.args...) - if result.err != nil { - t.Fatalf("exec %s returned error: %v", test.name, result.err) - } - if !strings.Contains(result.stdout, "out-clean") || !strings.Contains(result.stderr, "err-clean") { - t.Fatalf("exec %s output stdout=%q stderr=%q", test.name, result.stdout, result.stderr) - } - if test.name == "grouped" && !strings.Contains(result.stdout, "== clean ==") { - t.Fatalf("grouped output lacks heading: %q", result.stdout) - } - if test.name == "prefixed" && !strings.Contains(result.stdout, "[clean] out-clean") { - t.Fatalf("prefixed output lacks label: %q", result.stdout) - } - }) - } - - jsonResult := runCLI(t, app, "exec", "--output", "json", "--", "emit") - if jsonResult.err != nil { - t.Fatalf("exec JSON returned error: %v", jsonResult.err) - } - var document execDocument - if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { - t.Fatalf("exec JSON decode failed: %v\n%s", err, jsonResult.stdout) - } - if len(document.Results) != 1 || document.Results[0].ExitCode != 0 || document.Results[0].Stdout != "out-clean\n" { - t.Fatalf("exec JSON document = %#v", document) - } - - ndjsonResult := runCLI(t, app, "exec", "--output", "ndjson", "--", "emit") - if ndjsonResult.err != nil { - t.Fatalf("exec NDJSON returned error: %v", ndjsonResult.err) - } - var record execRecord - if err := json.Unmarshal([]byte(strings.TrimSpace(ndjsonResult.stdout)), &record); err != nil { - t.Fatalf("exec NDJSON decode failed: %v\n%s", err, ndjsonResult.stdout) - } - if record.Repository != fixture.Repositories[0].Name || record.ExitCode != 0 { - t.Fatalf("exec NDJSON record = %#v", record) - } -} - -func TestExecAggregatesFailuresTimeoutsAndRunsWithConcurrency(t *testing.T) { - app, fixture := fakeStatusApp(t, "fail", "clean", "fail", "clean") - failure := runCLI(t, app, "exec", "--output", "json", "--jobs", "2", "--", "fail") - if failure.err == nil || !strings.Contains(failure.err.Error(), "failed in 4") { - t.Fatalf("failure aggregation error = %v", failure.err) - } - var document execDocument - if err := json.Unmarshal([]byte(failure.stdout), &document); err != nil { - t.Fatalf("failure JSON decode failed: %v", err) - } - if len(document.Results) != len(fixture.Repositories) { - t.Fatalf("failure records = %d, want %d", len(document.Results), len(fixture.Repositories)) - } - - timeout := runCLI(t, app, "exec", "--output", "json", "--timeout", "20ms", "--", "hang") - if timeout.err == nil || !strings.Contains(timeout.err.Error(), "failed in") { - t.Fatalf("timeout error = %v", timeout.err) - } - if !strings.Contains(timeout.stdout, "context deadline exceeded") { - t.Fatalf("timeout JSON = %q", timeout.stdout) - } -} - -func TestWorkerCountsAreCappedToRepositoryCount(t *testing.T) { - app, fixture := fakeStatusApp(t, "clean") - command := newExecCommand(app) - command.SetContext(context.Background()) - maxJobs := int(^uint(0) >> 1) - records := executeAcross(command, app, fixture.Repositories, []string{"emit"}, maxJobs, 0, false, "json", false) - if len(records) != 1 || records[0].Error != "" || records[0].ExitCode != 0 { - t.Fatalf("executeAcross with huge jobs = %#v", records) - } - states := collectStatuses(command.Context(), app, fixture.Repositories, maxJobs) - if len(states) != 1 || states[0].Err != nil || states[0].Branch != "main" { - t.Fatalf("collectStatuses with huge jobs = %#v", states) - } -} - -func TestLinePrefixWriterHandlesPartialLinesAndControlLabels(t *testing.T) { - var output bytes.Buffer - shared := &sync.Mutex{} - writer := newLinePrefixWriter(&output, "repo\n\t\x01", shared) - if n, err := writer.Write([]byte("first")); err != nil || n != len("first") || output.Len() != 0 { - t.Fatalf("partial write = n:%d err:%v output:%q", n, err, output.String()) - } - if n, err := writer.Write([]byte(" line\nsecond")); err != nil || n != len(" line\nsecond") { - t.Fatalf("line write = n:%d err:%v", n, err) - } - if want := "[repo\\n\\t\\x01] first line\n"; output.String() != want { - t.Fatalf("line output = %q, want %q", output.String(), want) - } - if err := writer.Flush(); err != nil { - t.Fatalf("Flush returned error: %v", err) - } - if want := "[repo\\n\\t\\x01] first line\n[repo\\n\\t\\x01] second\n"; output.String() != want { - t.Fatalf("flushed output = %q, want %q", output.String(), want) - } -} - -func TestLinePrefixWriterBoundsNewlineFreeOutput(t *testing.T) { - const size = 2*1024*1024 + 17 - input := bytes.Repeat([]byte{'x'}, size) - var output bytes.Buffer - writer := newLinePrefixWriter(&output, "large", &sync.Mutex{}) - if n, err := writer.Write(input); err != nil || n != len(input) { - t.Fatalf("large write = n:%d err:%v, want %d bytes", n, err, len(input)) - } - if err := writer.Flush(); err != nil { - t.Fatalf("Flush returned error: %v", err) - } - want := append([]byte("[large] "), input...) - want = append(want, '\n') - if !bytes.Equal(output.Bytes(), want) { - t.Fatalf("large output length = %d, want %d", output.Len(), len(want)) - } -} - -func TestSafeFastForwardRefusesUnsafeRepositories(t *testing.T) { - app, fixture := fakeStatusApp(t, "dirty", "detached", "noupstream", "clean") - result := runCLI(t, app, "sync", "--mode", "ff-only", "--output", "json", "--jobs", "2") - if result.err == nil || !strings.Contains(result.err.Error(), "unsafe") { - t.Fatalf("ff-only refusal error = %v", result.err) - } - var document execDocument - if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { - t.Fatalf("ff-only JSON decode failed: %v\n%s", err, result.stdout) - } - if len(document.Results) != len(fixture.Repositories) { - t.Fatalf("ff-only records = %d, want %d", len(document.Results), len(fixture.Repositories)) - } - refused := 0 - for _, record := range document.Results { - if strings.Contains(record.Error, "refusing ff-only sync") { - refused++ - } - } - if refused != 3 { - t.Fatalf("ff-only refused %d repositories, want 3: %#v", refused, document.Results) - } -} - -func TestSnapshotCommandJSONIncludesRedactedRemotes(t *testing.T) { - app, fixture := fakeStatusApp(t, "clean") - result := runCLI(t, app, "snapshot", "--format", "json", "--include-remotes") - if result.err != nil { - t.Fatalf("snapshot command returned error: %v", result.err) - } - var document snapshotDocument - if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { - t.Fatalf("snapshot JSON decode failed: %v\n%s", err, result.stdout) - } - if document.SchemaVersion != outputSchemaVersion || len(document.Repositories) != 1 { - t.Fatalf("snapshot document = %#v", document) - } - record := document.Repositories[0] - if record.Head != "deadbeef" || record.Branch != "main" || record.Remotes["origin"][0] != "https://REDACTED:REDACTED@example.com/project.git" || strings.Contains(result.stdout, "password") { - t.Fatalf("snapshot record = %#v; output=%q", record, result.stdout) - } - _ = fixture -} - -func TestMachineOutputsRetainDiscoveryDiagnostics(t *testing.T) { - app, _ := fakeStatusApp(t, "clean") - baseDiscover := app.Discover - app.Discover = func(ctx context.Context, options repository.Options) (repository.Result, error) { - result, err := baseDiscover(ctx, options) - result.Warnings = []repository.Warning{{Path: "/tmp/diagnostic", Message: "skipped entry"}} - return result, err - } - - execResult := runCLI(t, app, "exec", "--output", "json", "--", "emit") - var executions execDocument - if execResult.err != nil || json.Unmarshal([]byte(execResult.stdout), &executions) != nil || len(executions.Diagnostics) != 1 { - t.Fatalf("exec diagnostics result = %#v", execResult) - } - - checkResult := runCLI(t, app, "check", "--format", "json", "--fail-on", "dirty") - var checks checkDocument - if checkResult.err != nil || json.Unmarshal([]byte(checkResult.stdout), &checks) != nil || len(checks.Diagnostics) != 1 { - t.Fatalf("check diagnostics result = %#v", checkResult) - } - - snapshotResult := runCLI(t, app, "snapshot", "--format", "json") - var snapshot snapshotDocument - if snapshotResult.err != nil || json.Unmarshal([]byte(snapshotResult.stdout), &snapshot) != nil || len(snapshot.Diagnostics) != 1 { - t.Fatalf("snapshot diagnostics result = %#v", snapshotResult) - } -} - -func fakeStatusApp(t *testing.T, names ...string) (App, repository.Result) { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("deterministic fake Git fixture uses a POSIX executable") - } - root := t.TempDir() - fixture := fixtureRepositories(root, names...) - for _, repo := range fixture.Repositories { - if err := os.MkdirAll(repo.Path, 0o755); err != nil { - t.Fatalf("create fake repository %q: %v", repo.Path, err) - } - } - script := writeFakeGit(t) - app := appWithRepositories(fixture) - app.Git = gitexec.Runner{Executable: script} - return app, fixture -} - -func writeFakeGit(t *testing.T) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "fake-git") - script := `#!/bin/sh -repo=${PWD##*/} -if [ "$1" = "--version" ]; then - echo "git version fake-1.0" - exit 0 -fi -if [ "$1" = "-c" ] && [ "$2" = "core.fsmonitor=false" ]; then - shift 2 -fi -if [ "$1" = "--no-optional-locks" ]; then - case "$repo" in - clean) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' - ;; - dirty) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' '1 .M N... 100644 100644 100644 abc abc changed' '? untracked' - ;; - all) - printf '%s\0' '# branch.oid abc' '# branch.head (detached)' '# branch.ab +2 -3' '1 .M N... 100644 100644 100644 abc abc changed' - ;; - ahead) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +2 -0' - ;; - behind) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -2' - ;; - diverged) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +2 -3' - ;; - detached) - printf '%s\0' '# branch.oid abc' '# branch.head (detached)' - ;; - noupstream) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.ab +0 -0' - ;; - unknown) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' - ;; - unknown-noahead) - if [ "$5" != "--no-ahead-behind" ]; then - echo "missing --no-ahead-behind" >&2 - exit 11 - fi - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' - ;; - prompt-check) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' - ;; - fetch-fail) - printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' - ;; - error) - echo 'synthetic status failure' >&2 - exit 9 - ;; - *) - echo "unknown fake repository $repo" >&2 - exit 10 - ;; - esac - exit 0 -fi -case "$1" in - emit) - printf 'out-%s\n' "$repo" - printf 'err-%s\n' "$repo" >&2 - ;; - fail) - printf 'out-%s\n' "$repo" - printf 'err-%s\n' "$repo" >&2 - exit 7 - ;; - hang) - while :; do sleep 1; done - ;; - rev-parse) - printf 'deadbeef\n' - ;; - symbolic-ref) - printf 'main\n' - ;; - remote) - printf 'origin\thttps://alice:password@example.com/project.git (fetch)\n' - printf 'origin\thttps://alice:password@example.com/project.git (push)\n' - ;; - pull|fetch) - if [ "$repo" = "fetch-fail" ]; then - echo 'synthetic fetch failure' >&2 - exit 13 - fi - if [ "$repo" = "prompt-check" ] && [ "${GIT_TERMINAL_PROMPT:-}" != "0" ]; then - echo 'credential prompt was not disabled' >&2 - exit 12 - fi - printf 'synced-%s\n' "$repo" - ;; - *) - printf 'out-%s\n' "$repo" - ;; -esac -` - if err := os.WriteFile(path, []byte(script), 0o700); err != nil { - t.Fatalf("write fake Git: %v", err) - } - if err := os.Chmod(path, 0o700); err != nil { - t.Fatalf("chmod fake Git: %v", err) - } - return path -} diff --git a/cmd/gitmulti/main.go b/cmd/gitmulti/main.go new file mode 100644 index 0000000..e06e807 --- /dev/null +++ b/cmd/gitmulti/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/ramonzx6/gitmulti/internal/cli" +) + +func main() { + os.Exit(cli.Execute()) +} diff --git a/cmd/list_command_test.go b/cmd/list_command_test.go deleted file mode 100644 index 3ed7cea..0000000 --- a/cmd/list_command_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "errors" - "path/filepath" - "reflect" - "runtime" - "strings" - "testing" - - "github.com/ramonzx6/gitmulti/internal/gitexec" - "github.com/ramonzx6/gitmulti/internal/repository" -) - -func TestListRendersTextFormatsAndCount(t *testing.T) { - root := t.TempDir() - fixture := fixtureRepositories(root, "alpha", "nested/beta") - for _, test := range []struct { - name string - args []string - want string - }{ - {name: "name", args: []string{"list", "--format", "name"}, want: "alpha\nbeta\n"}, - {name: "relative", args: []string{"list", "--format", "relative"}, want: "alpha\nnested/beta\n"}, - {name: "null", args: []string{"list", "--format", "name", "--null"}, want: "alpha\x00beta\x00"}, - {name: "count", args: []string{"list", "--count"}, want: "2\n"}, - } { - t.Run(test.name, func(t *testing.T) { - result := runCLI(t, appWithRepositories(fixture), test.args...) - if result.err != nil { - t.Fatalf("list returned error: %v", result.err) - } - if result.stdout != test.want { - t.Fatalf("stdout = %q, want %q", result.stdout, test.want) - } - }) - } - - absolute := runCLI(t, appWithRepositories(fixture), "list", "--format", "absolute") - if absolute.err != nil { - t.Fatalf("absolute list returned error: %v", absolute.err) - } - wantAbsolute := filepath.Join(root, "alpha") + "\n" + filepath.Join(root, "nested", "beta") + "\n" - if absolute.stdout != wantAbsolute { - t.Fatalf("absolute stdout = %q, want %q", absolute.stdout, wantAbsolute) - } - - alias := runCLI(t, appWithRepositories(fixture), "ls", "--format", "name") - if alias.err != nil || alias.stdout != "alpha\nbeta\n" { - t.Fatalf("ls result = %#v, want list output", alias) - } -} - -func TestListRendersJSONAndNDJSONContracts(t *testing.T) { - root := t.TempDir() - fixture := fixtureRepositories(root, "alpha", "beta") - - jsonResult := runCLI(t, appWithRepositories(fixture), "list", "--format", "json") - if jsonResult.err != nil { - t.Fatalf("list --format json returned error: %v", jsonResult.err) - } - var document struct { - SchemaVersion int `json:"schemaVersion"` - Repositories []repository.Repository `json:"repositories"` - } - if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { - t.Fatalf("list JSON decode failed: %v\n%s", err, jsonResult.stdout) - } - if document.SchemaVersion != outputSchemaVersion || !reflect.DeepEqual(document.Repositories, fixture.Repositories) { - t.Fatalf("JSON document = %#v, want schema %d and repositories %#v", document, outputSchemaVersion, fixture.Repositories) - } - - ndjsonResult := runCLI(t, appWithRepositories(fixture), "list", "--format", "ndjson") - if ndjsonResult.err != nil { - t.Fatalf("list --format ndjson returned error: %v", ndjsonResult.err) - } - lines := strings.Split(strings.TrimSpace(ndjsonResult.stdout), "\n") - if len(lines) != len(fixture.Repositories) { - t.Fatalf("NDJSON line count = %d, want %d", len(lines), len(fixture.Repositories)) - } - for index, line := range lines { - var got repository.Repository - if err := json.Unmarshal([]byte(line), &got); err != nil { - t.Fatalf("NDJSON line %d decode failed: %v", index, err) - } - if !reflect.DeepEqual(got, fixture.Repositories[index]) { - t.Errorf("NDJSON repository %d = %#v, want %#v", index, got, fixture.Repositories[index]) - } - } -} - -func TestListRequireMatchAndFlagValidation(t *testing.T) { - empty := appWithRepositories(repository.Result{}) - result := runCLI(t, empty, "list", "--require-match") - if result.err == nil { - t.Fatal("--require-match should fail when no repositories match") - } - var coded interface{ ExitCode() int } - if !errors.As(result.err, &coded) { - t.Fatalf("require-match error = %v, want an exit code", result.err) - } - - for _, args := range [][]string{ - {"list", "--format", "json", "--absolute"}, - {"list", "--format", "json", "--null"}, - {"list", "--format", "invalid"}, - {"list", "--count", "--format", "json"}, - {"list", "--count", "--null"}, - {"list", "--max-depth", "1"}, - {"list", "--include-nested"}, - } { - result := runCLI(t, empty, args...) - var coded interface{ ExitCode() int } - if result.err == nil || !errors.As(result.err, &coded) || coded.ExitCode() != 2 { - t.Errorf("args %v unexpectedly succeeded", args) - } - } -} - -func TestListPassesScanOptionsToDiscoverer(t *testing.T) { - var got repository.Options - app := App{ - Discover: func(_ context.Context, options repository.Options) (repository.Result, error) { - got = options - return repository.Result{}, nil - }, - } - result := runCLI(t, app, "list", "--path", "/workspace", "--recursive", "--include-root", "--include-nested", "--max-depth", "3", "--match", "services/*", "--exclude", "services/archive", "--include-hidden", "--follow-symlinks", "--include-bare") - if result.err != nil { - t.Fatalf("list with scan options returned error: %v", result.err) - } - want := repository.Options{ - Root: "/workspace", - Recursive: true, - IncludeRoot: true, - IncludeNested: true, - MaxDepth: 3, - Match: []string{"services/*"}, - Exclude: []string{"services/archive"}, - IncludeHidden: true, - FollowSymlinks: true, - IncludeBare: true, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("discovery options = %#v, want %#v", got, want) - } -} - -func TestListValidationUsesConfiguredGitRunner(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("fake Git fixture uses a POSIX executable") - } - validated := false - app := App{ - Git: gitexec.Runner{Executable: writeFakeGit(t)}, - Discover: func(ctx context.Context, options repository.Options) (repository.Result, error) { - if options.ValidateRepository == nil { - t.Fatal("validation callback was not injected") - } - if err := options.ValidateRepository(ctx, t.TempDir()); err != nil { - t.Fatalf("injected validation returned error: %v", err) - } - validated = true - return repository.Result{}, nil - }, - } - - result := runCLI(t, app, "list", "--validate") - if result.err != nil { - t.Fatalf("list --validate returned error: %v", result.err) - } - if !validated { - t.Fatal("configured Git runner was not used for validation") - } -} diff --git a/cmd/policy_command_test.go b/cmd/policy_command_test.go deleted file mode 100644 index a187e01..0000000 --- a/cmd/policy_command_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package cmd - -import ( - "errors" - "reflect" - "strings" - "testing" - - "github.com/ramonzx6/gitmulti/internal/gitstatus" -) - -func TestNormalizePoliciesSplitsDeduplicatesAndSorts(t *testing.T) { - got, err := normalizePolicies([]string{"dirty,behind", "DIRTY", "no-upstream"}) - if err != nil { - t.Fatalf("normalizePolicies returned error: %v", err) - } - want := []string{"behind", "dirty", "no-upstream"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("policies = %#v, want %#v", got, want) - } - - if _, err := normalizePolicies([]string{"dirty", "unknown"}); err == nil || !strings.Contains(err.Error(), "unsupported check policy") { - t.Fatalf("invalid policy error = %v", err) - } -} - -func TestEvaluatePoliciesCoversRepositoryConditions(t *testing.T) { - state := gitstatus.State{ - Dirty: true, - Ahead: 2, - Behind: 3, - Detached: true, - NoUpstream: true, - } - got := evaluatePolicies(state, []string{"dirty", "unpushed", "behind", "diverged", "detached", "no-upstream"}) - want := []string{"dirty", "unpushed", "behind", "diverged", "detached", "no-upstream"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("violations = %#v, want %#v", got, want) - } - - if got := evaluatePolicies(gitstatus.State{Err: errSentinel}, []string{"dirty"}); got != nil { - t.Fatalf("error state violations = %#v, want nil", got) - } -} - -var errSentinel = errors.New("sentinel") diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 5099b69..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,144 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "runtime/debug" - "strings" - - "github.com/ramonzx6/gitmulti/internal/gitexec" - "github.com/ramonzx6/gitmulti/internal/repository" - "github.com/spf13/cobra" -) - -var ( - Version = "dev" - Commit = "unknown" - BuildDate = "unknown" -) - -type App struct { - In io.Reader - Out io.Writer - Err io.Writer - Git gitexec.Runner - Discover func(context.Context, repository.Options) (repository.Result, error) -} - -func DefaultApp() App { - return App{ - In: os.Stdin, - Out: os.Stdout, - Err: os.Stderr, - Git: gitexec.Runner{}, - Discover: repository.Discover, - } -} - -func (app App) normalized() App { - if app.In == nil { - app.In = os.Stdin - } - if app.Out == nil { - app.Out = os.Stdout - } - if app.Err == nil { - app.Err = os.Stderr - } - if app.Discover == nil { - app.Discover = repository.Discover - } - return app -} - -func NewRootCommand(app App) *cobra.Command { - app = app.normalized() - info := currentBuildInfo() - - root := &cobra.Command{ - Use: "gitmulti", - Short: "Run Git operations across multiple repositories", - Long: "GitMulti discovers repositories under a directory and reports on or operates across them.", - SilenceErrors: true, - SilenceUsage: true, - Version: info.Version, - } - root.SetIn(app.In) - root.SetOut(app.Out) - root.SetErr(app.Err) - root.SetVersionTemplate("gitmulti version {{.Version}}\n") - root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { - return exitError{code: 2, message: err.Error()} - }) - - root.AddCommand( - newListCommand(app), - newExecCommand(app), - newStatusCommand(app), - newCheckCommand(app), - newDoctorCommand(app), - newSyncCommand(app), - newSnapshotCommand(app), - newCompletionCommand(app), - newVersionCommand(app, info), - ) - - return root -} - -func Execute() int { - return Run(context.Background(), os.Args[1:], DefaultApp()) -} - -func Run(ctx context.Context, args []string, app App) int { - root := NewRootCommand(app) - root.SetArgs(args) - if err := root.ExecuteContext(ctx); err != nil { - if err.Error() != "" { - fmt.Fprintf(root.ErrOrStderr(), "Error: %v\n", err) - } - var exitErr interface{ ExitCode() int } - if errors.As(err, &exitErr) { - return exitErr.ExitCode() - } - if strings.HasPrefix(err.Error(), "unknown command") { - return 2 - } - return 1 - } - return 0 -} - -type buildInfo struct { - Version string `json:"version"` - Commit string `json:"commit"` - BuildDate string `json:"buildDate"` - Dirty bool `json:"dirty"` - GoVersion string `json:"goVersion,omitempty"` - Module string `json:"module,omitempty"` -} - -func currentBuildInfo() buildInfo { - info := buildInfo{Version: Version, Commit: Commit, BuildDate: BuildDate} - if bi, ok := debug.ReadBuildInfo(); ok { - info.GoVersion = bi.GoVersion - info.Module = bi.Main.Path - if info.Version == "dev" && bi.Main.Version != "" && bi.Main.Version != "(devel)" { - info.Version = bi.Main.Version - } - if info.Commit == "unknown" { - for _, setting := range bi.Settings { - if setting.Key == "vcs.revision" { - info.Commit = setting.Value - } - if setting.Key == "vcs.modified" && setting.Value == "true" { - info.Dirty = true - } - } - } - } - return info -} diff --git a/cmd/snapshot.go b/cmd/snapshot.go deleted file mode 100644 index eb74528..0000000 --- a/cmd/snapshot.go +++ /dev/null @@ -1,365 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "io" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/ramonzx6/gitmulti/internal/repository" - "github.com/spf13/cobra" -) - -type snapshotRepository struct { - Name string `json:"name"` - Path string `json:"path"` - Head string `json:"head,omitempty"` - Branch string `json:"branch,omitempty"` - Remotes map[string][]string `json:"remotes,omitempty"` - Error string `json:"error,omitempty"` -} - -type snapshotDocument struct { - SchemaVersion int `json:"schemaVersion"` - Repositories []snapshotRepository `json:"repositories"` - Diagnostics []repository.Warning `json:"diagnostics,omitempty"` -} - -func newSnapshotCommand(app App) *cobra.Command { - var scan scanFlags - var outputPath string - var format string - var includeRemotes bool - var force bool - var requireMatch bool - - command := &cobra.Command{ - Use: "snapshot", - Short: "Export repository revisions and remotes", - Args: noArgs, - RunE: func(command *cobra.Command, _ []string) error { - if format != "json" && format != "ndjson" { - return errorsForFlag(fmt.Sprintf("unsupported snapshot format %q", format)) - } - discovered, err := discoverRepositories(command.Context(), app, command, scan) - if err != nil { - return err - } - if len(discovered.Repositories) == 0 && requireMatch { - return newExitError(1, "no Git repositories matched") - } - if len(discovered.Repositories) > 0 { - if _, err := app.Git.LookPath(); err != nil { - return err - } - } - - records := make([]snapshotRepository, 0, len(discovered.Repositories)) - failures := 0 - for _, repo := range discovered.Repositories { - record := snapshotRepository{Name: repo.Name, Path: repo.Path} - head := app.Git.Run(command.Context(), repo.Path, []string{"rev-parse", "HEAD"}, nil) - if head.Err != nil { - record.Error = head.Err.Error() - failures++ - } else { - record.Head = strings.TrimSpace(head.Stdout) - } - branch := app.Git.Run(command.Context(), repo.Path, []string{"symbolic-ref", "--quiet", "--short", "HEAD"}, nil) - if branch.Err == nil { - record.Branch = strings.TrimSpace(branch.Stdout) - } - if includeRemotes { - remotes := app.Git.Run(command.Context(), repo.Path, []string{"remote", "-v"}, nil) - if remotes.Err != nil && record.Error == "" { - record.Error = remotes.Err.Error() - failures++ - } else { - record.Remotes = parseRemotes(remotes.Stdout) - } - } - records = append(records, record) - } - - writer, closeWriter, err := snapshotWriter(command.OutOrStdout(), outputPath, force) - if err != nil { - return err - } - encoder := json.NewEncoder(writer) - var encodeErr error - if format == "json" { - encoder.SetIndent("", " ") - encodeErr = encoder.Encode(snapshotDocument{SchemaVersion: outputSchemaVersion, Repositories: records, Diagnostics: discovered.Warnings}) - } else { - for _, record := range records { - if err := encoder.Encode(struct { - Type string `json:"type"` - snapshotRepository - }{Type: "repository", snapshotRepository: record}); err != nil { - encodeErr = err - break - } - } - for _, warning := range discovered.Warnings { - if err := encoder.Encode(struct { - Type string `json:"type"` - repository.Warning - }{Type: "diagnostic", Warning: warning}); err != nil { - encodeErr = err - break - } - } - } - if closeWriter != nil { - if encodeErr != nil { - if aborter, ok := writer.(interface{ Abort() error }); ok { - _ = aborter.Abort() - } - } else if closeErr := closeWriter(); closeErr != nil { - encodeErr = closeErr - } - } - if encodeErr != nil { - return encodeErr - } - if failures > 0 { - return newExitError(1, "snapshot failed for %d repositories", failures) - } - return nil - }, - } - scan.bind(command) - command.Flags().StringVarP(&outputPath, "output", "o", "", "write the snapshot to a file instead of stdout") - command.Flags().StringVar(&format, "format", "json", "output format: json or ndjson") - command.Flags().BoolVar(&includeRemotes, "include-remotes", false, "include configured remote URLs with credentials redacted") - command.Flags().BoolVar(&force, "force", false, "overwrite an existing output file") - command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") - return command -} - -type snapshotTempFile struct { - file *os.File - tempPath string - destination string - force bool -} - -func (writer *snapshotTempFile) Write(data []byte) (int, error) { - return writer.file.Write(data) -} - -func (writer *snapshotTempFile) Commit() error { - if writer.file == nil { - return nil - } - if err := writer.file.Sync(); err != nil { - return writer.fail(err) - } - if err := writer.file.Close(); err != nil { - return writer.fail(err) - } - writer.file = nil - - if err := validateSnapshotDestination(writer.destination, writer.force); err != nil { - return writer.fail(err) - } - if writer.force { - if err := os.Rename(writer.tempPath, writer.destination); err != nil { - return writer.fail(err) - } - writer.tempPath = "" - } else { - // A hard link is an atomic, no-overwrite install on the same - // filesystem. Unlike rename, it cannot replace a destination that - // appeared after the initial validation. - if err := os.Link(writer.tempPath, writer.destination); err != nil { - return writer.fail(err) - } - tempPath := writer.tempPath - if err := os.Remove(tempPath); err != nil { - // The destination is already installed. Retry cleanup, but never - // remove the successfully installed destination if cleanup fails. - cleanupErr := writer.Abort() - if cleanupErr != nil { - return fmt.Errorf("remove temporary snapshot %s after install: %w (cleanup: %v)", tempPath, err, cleanupErr) - } - return fmt.Errorf("remove temporary snapshot %s after install: %w", tempPath, err) - } - writer.tempPath = "" - } - return nil -} - -func (writer *snapshotTempFile) Abort() error { - if writer.file != nil { - _ = writer.file.Close() - writer.file = nil - } - if writer.tempPath == "" { - return nil - } - err := os.Remove(writer.tempPath) - writer.tempPath = "" - if os.IsNotExist(err) { - return nil - } - return err -} - -func (writer *snapshotTempFile) fail(err error) error { - _ = writer.Abort() - return err -} - -func validateSnapshotDestination(path string, force bool) error { - info, err := os.Lstat(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("inspect snapshot output %s: %w", path, err) - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("refusing symlink snapshot output %s", path) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("snapshot output %s is not a regular file", path) - } - if !force { - return fmt.Errorf("snapshot output %s already exists", path) - } - return nil -} - -func snapshotWriter(stdout io.Writer, path string, force bool) (io.Writer, func() error, error) { - if path == "" { - return stdout, nil, nil - } - if err := validateSnapshotDestination(path, force); err != nil { - return nil, nil, err - } - directory := filepath.Dir(path) - base := filepath.Base(path) - file, err := os.CreateTemp(directory, "."+base+".tmp-*") - if err != nil { - return nil, nil, fmt.Errorf("create temporary snapshot in %s: %w", directory, err) - } - if err := file.Chmod(0o600); err != nil { - _ = file.Close() - _ = os.Remove(file.Name()) - return nil, nil, fmt.Errorf("secure temporary snapshot %s: %w", file.Name(), err) - } - temporary := &snapshotTempFile{file: file, tempPath: file.Name(), destination: path, force: force} - return temporary, temporary.Commit, nil -} - -func parseRemotes(output string) map[string][]string { - remotes := make(map[string][]string) - for _, line := range strings.Split(output, "\n") { - fields := strings.SplitN(line, "\t", 2) - if len(fields) != 2 { - continue - } - name := strings.TrimSpace(fields[0]) - remoteURL := strings.TrimSpace(fields[1]) - remoteURL = strings.TrimSuffix(remoteURL, " (fetch)") - remoteURL = strings.TrimSuffix(remoteURL, " (push)") - if name == "" || remoteURL == "" { - continue - } - remoteURL = redactRemoteURL(remoteURL) - seen := false - for _, existing := range remotes[name] { - if existing == remoteURL { - seen = true - break - } - } - if !seen { - remotes[name] = append(remotes[name], remoteURL) - } - } - return remotes -} - -func redactRemoteURL(raw string) string { - if safe, handled := redactSCPRemote(raw); handled { - return safe - } - parsed, err := url.Parse(raw) - if err != nil { - // An invalid escape can make URL parsing fail before userinfo or - // query credentials are exposed. Never return the original value - // in that case: a snapshot must fail closed. - return "REDACTED" - } - if parsed.Scheme == "" { - if strings.Contains(raw, "?") { - return "REDACTED" - } - return raw - } - if parsed.User != nil { - username := parsed.User.Username() - _, hasPassword := parsed.User.Password() - if parsed.Scheme == "http" || parsed.Scheme == "https" { - username = "REDACTED" - } - if hasPassword { - parsed.User = url.UserPassword(username, "REDACTED") - } else { - parsed.User = url.User(username) - } - } - query := parsed.Query() - if parsed.Scheme == "file" && parsed.User == nil && len(query) == 0 { - // Keep local paths exactly as Git reported them; url.URL.String - // would percent-encode spaces in an otherwise non-sensitive path. - return raw - } - for key := range query { - normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_")) - if strings.Contains(normalized, "token") || - strings.Contains(normalized, "auth") || - strings.Contains(normalized, "password") || - strings.Contains(normalized, "passwd") || - strings.Contains(normalized, "secret") || - strings.Contains(normalized, "signature") || - strings.Contains(normalized, "credential") || - normalized == "key" || strings.HasSuffix(normalized, "_key") || normalized == "sig" { - query.Set(key, "REDACTED") - } - } - parsed.RawQuery = query.Encode() - return parsed.String() -} - -// redactSCPRemote handles Git's scp-like SSH syntax, which net/url rejects: -// user@host:path. There is no password field in this syntax, so an ordinary -// user/host/path remains useful. Suspicious userinfo or query material is -// replaced completely rather than returned unparsed. -func redactSCPRemote(raw string) (string, bool) { - if strings.Contains(raw, "://") { - return "", false - } - at := strings.IndexByte(raw, '@') - if at <= 0 || at == len(raw)-1 { - return "", false - } - colon := strings.IndexByte(raw[at+1:], ':') - if colon < 1 { - return "", false - } - user := raw[:at] - hostPath := raw[at+1:] - if strings.ContainsAny(user, "/?#") { - return "", false - } - if strings.Contains(user, ":") || strings.Contains(hostPath, "?") { - return "REDACTED", true - } - return raw, true -} diff --git a/cmd/snapshot_command_test.go b/cmd/snapshot_command_test.go deleted file mode 100644 index 77fb960..0000000 --- a/cmd/snapshot_command_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package cmd - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "runtime" - "strings" - "testing" - - "github.com/ramonzx6/gitmulti/internal/repository" -) - -func TestParseRemotesRedactsCredentialsAndSecrets(t *testing.T) { - output := strings.Join([]string{ - "origin\thttps://alice:password@example.com/project.git (fetch)", - "origin\thttps://alice:password@example.com/project.git (push)", - "token\thttps://example.com/project.git?access_token=abc&mode=ro (fetch)", - "ssh\tgit@example.com:project.git (fetch)", - }, "\n") - got := parseRemotes(output) - want := map[string][]string{ - "origin": {"https://REDACTED:REDACTED@example.com/project.git"}, - "token": {"https://example.com/project.git?access_token=REDACTED&mode=ro"}, - "ssh": {"git@example.com:project.git"}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("parsed remotes = %#v, want %#v", got, want) - } - if strings.Contains(strings.Join(got["origin"], " "), "password") || strings.Contains(strings.Join(got["token"], " "), "abc") { - t.Fatal("remote credentials or token leaked") - } -} - -func TestParseRemotesPreservesSpacesInTabDelimitedURLs(t *testing.T) { - got := parseRemotes("origin\tfile:///tmp/project with spaces (fetch)\n") - want := map[string][]string{"origin": {"file:///tmp/project with spaces"}} - if !reflect.DeepEqual(got, want) { - t.Fatalf("parsed remotes = %#v, want %#v", got, want) - } -} - -func TestRedactRemoteURLHandlesQuerySecretVariants(t *testing.T) { - for _, input := range []string{ - "https://example.test/repo?token=abc", - "https://example.test/repo?AUTHORIZATION=abc", - "https://token-value@example.test/repo", - "https://example.test/repo?X-Amz-Credential=abc", - "https://user:password@example.test/repo", - } { - got := redactRemoteURL(input) - if strings.Contains(got, "abc") || strings.Contains(got, "password") || strings.Contains(got, "token-value") { - t.Errorf("redacted URL %q still contains secret: %q", input, got) - } - } -} - -func TestRedactRemoteURLFailsClosedOnInvalidEscapes(t *testing.T) { - raw := "https://alice:SENSITIVE_VALUE@example.invalid/%zz" - got := redactRemoteURL(raw) - if got == raw || strings.Contains(got, "SENSITIVE_VALUE") || strings.Contains(got, "alice") { - t.Fatalf("invalid remote URL was not redacted: %q", got) - } -} - -func TestRedactRemoteURLFailsClosedOnSchemeLessSecretQuery(t *testing.T) { - raw := "origin/path?token=SENSITIVE_VALUE" - got := redactRemoteURL(raw) - if strings.Contains(got, "SENSITIVE_VALUE") { - t.Fatalf("scheme-less remote query leaked secret: %q", got) - } -} - -func TestRedactRemoteURLPreservesSafeSCPRemotes(t *testing.T) { - const raw = "git@github.com:org/repo.git" - if got := redactRemoteURL(raw); got != raw { - t.Fatalf("safe SCP remote = %q, want %q", got, raw) - } - if got := redactRemoteURL("alice:secret@github.com:org/repo.git"); strings.Contains(got, "secret") { - t.Fatalf("SCP-like credential leaked: %q", got) - } -} - -func TestSnapshotWriterUsesPrivateModeAndRefusesOverwrite(t *testing.T) { - path := filepath.Join(t.TempDir(), "snapshot.json") - if err := os.WriteFile(path, []byte("original"), 0o644); err != nil { - t.Fatalf("write existing output: %v", err) - } - if _, closeWriter, err := snapshotWriter(nil, path, false); err == nil { - if closeWriter != nil { - _ = closeWriter() - } - t.Fatal("snapshotWriter unexpectedly overwrote existing file") - } - contents, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read existing output: %v", err) - } - if string(contents) != "original" { - t.Fatalf("existing output changed to %q", contents) - } - - newPath := filepath.Join(t.TempDir(), "new.json") - writer, closeWriter, err := snapshotWriter(nil, newPath, false) - if err != nil { - t.Fatalf("snapshotWriter returned error: %v", err) - } - temporary := writer.(*snapshotTempFile) - temporaryPath := temporary.tempPath - if _, err := writer.Write([]byte("new")); err != nil { - t.Fatalf("write snapshot: %v", err) - } - if err := closeWriter(); err != nil { - t.Fatalf("close snapshot: %v", err) - } - if temporary.tempPath != "" { - t.Fatalf("temporary path after commit = %q, want empty", temporary.tempPath) - } - if _, err := os.Stat(temporaryPath); !os.IsNotExist(err) { - t.Fatalf("temporary snapshot still exists: %v", err) - } - info, err := os.Stat(newPath) - if err != nil { - t.Fatalf("stat snapshot: %v", err) - } - if mode := info.Mode().Perm(); mode != 0o600 { - t.Fatalf("snapshot mode = %#o, want 0600", mode) - } - - writer, closeWriter, err = snapshotWriter(nil, newPath, true) - if err != nil { - t.Fatalf("force snapshotWriter returned error: %v", err) - } - if _, err := writer.Write([]byte("forced")); err != nil { - t.Fatalf("write forced snapshot: %v", err) - } - if err := closeWriter(); err != nil { - t.Fatalf("close forced snapshot: %v", err) - } - contents, err = os.ReadFile(newPath) - if err != nil { - t.Fatalf("read forced snapshot: %v", err) - } - if string(contents) != "forced" { - t.Fatalf("forced snapshot contents = %q, want forced", contents) - } -} - -func TestSnapshotOptionErrorsHaveExpectedExitCodes(t *testing.T) { - empty := appWithRepositories(repository.Result{}) - invalid := runCLI(t, empty, "snapshot", "--format", "invalid") - var coded interface{ ExitCode() int } - if invalid.err == nil || !errors.As(invalid.err, &coded) || coded.ExitCode() != 2 { - t.Fatalf("invalid snapshot format error = %v, want exit code 2", invalid.err) - } - - missing := runCLI(t, empty, "snapshot", "--require-match") - if missing.err == nil || !errors.As(missing.err, &coded) || coded.ExitCode() != 1 { - t.Fatalf("snapshot require-match error = %v, want exit code 1", missing.err) - } -} - -func TestSnapshotWithoutMatchesDoesNotRequireGit(t *testing.T) { - result := runCLI(t, appWithRepositories(repository.Result{}), "snapshot", "--format", "json") - if result.err != nil { - t.Fatalf("empty snapshot returned error: %v", result.err) - } - var document snapshotDocument - if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { - t.Fatalf("empty snapshot is not valid JSON: %v\n%s", err, result.stdout) - } - if document.SchemaVersion != outputSchemaVersion || len(document.Repositories) != 0 { - t.Fatalf("empty snapshot document = %#v", document) - } -} - -func TestSnapshotWriterRefusesSymlinkDestination(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink fixture requires platform support") - } - directory := t.TempDir() - target := filepath.Join(directory, "target.json") - link := filepath.Join(directory, "snapshot.json") - if err := os.WriteFile(target, []byte("target"), 0o600); err != nil { - t.Fatalf("write target: %v", err) - } - if err := os.Symlink(target, link); err != nil { - t.Fatalf("create destination symlink: %v", err) - } - if _, _, err := snapshotWriter(nil, link, true); err == nil { - t.Fatal("snapshotWriter accepted a symlink destination") - } - contents, err := os.ReadFile(target) - if err != nil { - t.Fatalf("read target: %v", err) - } - if string(contents) != "target" { - t.Fatalf("symlink target changed to %q", contents) - } -} diff --git a/cmd/sync_completion_test.go b/cmd/sync_completion_test.go deleted file mode 100644 index 0172cbc..0000000 --- a/cmd/sync_completion_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package cmd - -import ( - "errors" - "reflect" - "strings" - "testing" -) - -func TestSyncArgsBuildsSafeFetchAndFastForwardCommands(t *testing.T) { - tests := []struct { - name string - mode string - remote string - prune bool - tags bool - want []string - }{ - {name: "all fetch", mode: "fetch", prune: true, want: []string{"fetch", "--prune", "--all"}}, - {name: "remote fetch with tags", mode: "fetch", remote: "origin", prune: false, tags: true, want: []string{"fetch", "--tags", "origin"}}, - {name: "ff only", mode: "ff-only", prune: true, want: []string{"pull", "--ff-only", "--prune"}}, - {name: "ff only remote tags", mode: "ff-only", remote: "upstream", prune: false, tags: true, want: []string{"pull", "--ff-only", "--tags", "upstream"}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := syncArgs(test.mode, test.remote, test.prune, test.tags); !reflect.DeepEqual(got, test.want) { - t.Fatalf("syncArgs = %#v, want %#v", got, test.want) - } - }) - } -} - -func TestSyncDryRunDoesNotInvokeGit(t *testing.T) { - fixture := fixtureRepositories(t.TempDir(), "repo") - app := appWithRepositories(fixture) - app.Git.Executable = "definitely-not-installed-gitmulti-sync" - result := runCLI(t, app, "sync", "--dry-run", "--mode", "fetch") - if result.err != nil { - t.Fatalf("sync dry-run returned error: %v", result.err) - } - if !strings.Contains(result.stdout, `git -C "`) || !strings.Contains(result.stdout, `"fetch" "--prune" "--all"`) { - t.Fatalf("sync dry-run output = %q, want fetch command", result.stdout) - } -} - -func TestSyncValidatesModeAndOutput(t *testing.T) { - fixture := fixtureRepositories(t.TempDir(), "repo") - for _, test := range []struct { - args []string - want string - }{ - {args: []string{"sync", "--mode", "merge", "--dry-run"}, want: "unsupported sync mode"}, - {args: []string{"sync", "--output", "invalid", "--dry-run"}, want: "unsupported output mode"}, - {args: []string{"sync", "--jobs", "0", "--dry-run"}, want: "--jobs must be at least 1"}, - {args: []string{"sync", "--timeout=-1s", "--dry-run"}, want: "--timeout cannot be negative"}, - {args: []string{"sync", "--remote", "--all", "--dry-run"}, want: "--remote cannot begin"}, - } { - result := runCLI(t, appWithRepositories(fixture), test.args...) - if result.err == nil || !strings.Contains(result.err.Error(), test.want) { - t.Errorf("args %v error = %v, want %q", test.args, result.err, test.want) - } - } -} - -func TestSyncRawReportsFFOnlyRefusalsAndFailFastSkipsEligibleRepos(t *testing.T) { - app, _ := fakeStatusApp(t, "dirty", "clean") - result := runCLI(t, app, "sync", "--mode", "ff-only", "--output", "raw", "--fail-fast") - if result.err == nil || !strings.Contains(result.err.Error(), "unsafe") { - t.Fatalf("ff-only raw error = %v", result.err) - } - if !strings.Contains(result.stderr, "dirty: refusing ff-only sync") || !strings.Contains(result.stderr, "clean: not started after fail-fast") { - t.Fatalf("ff-only raw diagnostics = %q", result.stderr) - } - if strings.Contains(result.stdout, "synced-clean") { - t.Fatalf("fail-fast raw sync executed an eligible repository after refusal: %q", result.stdout) - } -} - -func TestSyncNoPromptSetsGitTerminalPrompt(t *testing.T) { - app, _ := fakeStatusApp(t, "prompt-check") - result := runCLI(t, app, "sync", "--no-prompt", "--mode", "fetch") - if result.err != nil { - t.Fatalf("sync --no-prompt returned error: %v\nstdout=%q\nstderr=%q", result.err, result.stdout, result.stderr) - } -} - -func TestCompletionRejectsUnsupportedShell(t *testing.T) { - result := runCLI(t, discardApp(), "completion", "tcsh") - if result.err == nil || !strings.Contains(result.err.Error(), "unsupported shell") { - t.Fatalf("unsupported completion shell error = %v", result.err) - } - var coded interface{ ExitCode() int } - if !errors.As(result.err, &coded) || coded.ExitCode() != 2 { - t.Fatalf("unsupported completion shell error = %v, want exit code 2", result.err) - } -} diff --git a/cmd/utils.go b/cmd/utils.go deleted file mode 100644 index b394e90..0000000 --- a/cmd/utils.go +++ /dev/null @@ -1,81 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "strconv" - "strings" - - "github.com/google/shlex" - "github.com/spf13/cobra" -) - -func parseGitCommand(command string, args []string) ([]string, error) { - trimmedCommand := strings.TrimSpace(command) - if trimmedCommand != "" && len(args) > 0 { - return nil, errors.New("use either --command or positional Git arguments, not both") - } - if trimmedCommand == "" { - if len(args) == 0 { - return nil, errors.New("Git command cannot be empty") - } - return append([]string(nil), args...), nil - } - parsedArgs, err := shlex.Split(trimmedCommand) - if err != nil { - return nil, fmt.Errorf("invalid Git command %q: %w", command, err) - } - if len(parsedArgs) == 0 { - return nil, errors.New("Git command cannot be empty") - } - return parsedArgs, nil -} - -type exitError struct { - code int - message string -} - -func (err exitError) Error() string { return err.message } -func (err exitError) ExitCode() int { return err.code } - -func newExitError(code int, format string, args ...any) error { - return exitError{code: code, message: fmt.Sprintf(format, args...)} -} - -func displayCommand(dir string, args []string) string { - parts := []string{"git", "-C", strconv.Quote(dir)} - for _, arg := range args { - parts = append(parts, strconv.Quote(arg)) - } - return strings.Join(parts, " ") -} - -func splitValues(values []string) []string { - var result []string - for _, value := range values { - for _, item := range strings.Split(value, ",") { - item = strings.TrimSpace(item) - if item != "" { - result = append(result, item) - } - } - } - return result -} - -func noArgs(command *cobra.Command, args []string) error { - if err := cobra.NoArgs(command, args); err != nil { - return errorsForFlag(err.Error()) - } - return nil -} - -func exactArgs(count int) cobra.PositionalArgs { - return func(command *cobra.Command, args []string) error { - if err := cobra.ExactArgs(count)(command, args); err != nil { - return errorsForFlag(err.Error()) - } - return nil - } -} diff --git a/internal/cli/app/app.go b/internal/cli/app/app.go new file mode 100644 index 0000000..d9d4a94 --- /dev/null +++ b/internal/cli/app/app.go @@ -0,0 +1,53 @@ +// Package app defines the dependencies shared by the CLI command packages. +package app + +import ( + "context" + "io" + "os" + + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +// App contains the process and service boundaries used by CLI commands. +// Supplying these dependencies explicitly keeps command construction free of +// package-level state and makes commands straightforward to test. +type App struct { + In io.Reader + Out io.Writer + Err io.Writer + Git gitexec.Runner + Discover func(context.Context, repository.Options) (repository.Result, error) +} + +// Default returns an App connected to the current process and production +// repository discovery implementation. +func Default() App { + return App{ + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Git: gitexec.Runner{}, + Discover: repository.Discover, + } +} + +// Normalized fills nil process streams and services with production defaults. +// Existing non-nil values are preserved. +func (application App) Normalized() App { + defaults := Default() + if application.In == nil { + application.In = defaults.In + } + if application.Out == nil { + application.Out = defaults.Out + } + if application.Err == nil { + application.Err = defaults.Err + } + if application.Discover == nil { + application.Discover = defaults.Discover + } + return application +} diff --git a/internal/cli/app/app_test.go b/internal/cli/app/app_test.go new file mode 100644 index 0000000..cfd825b --- /dev/null +++ b/internal/cli/app/app_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "bytes" + "testing" + + "github.com/ramonzx6/gitmulti/internal/gitexec" +) + +func TestNormalizedPreservesConfiguredDependencies(t *testing.T) { + in := &bytes.Buffer{} + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + configured := App{In: in, Out: out, Err: errOut, Git: gitexec.New("custom-git")} + + normalized := configured.Normalized() + if normalized.In != in || normalized.Out != out || normalized.Err != errOut { + t.Fatal("Normalized replaced configured streams") + } + if normalized.Git.Executable != "custom-git" { + t.Fatalf("Normalized Git executable = %q, want custom-git", normalized.Git.Executable) + } + if normalized.Discover == nil { + t.Fatal("Normalized did not supply repository discovery") + } +} + +func TestDefaultIsComplete(t *testing.T) { + application := Default() + if application.In == nil || application.Out == nil || application.Err == nil || application.Discover == nil { + t.Fatalf("Default returned incomplete dependencies: %#v", application) + } +} diff --git a/internal/cli/buildinfo/buildinfo.go b/internal/cli/buildinfo/buildinfo.go new file mode 100644 index 0000000..adb648a --- /dev/null +++ b/internal/cli/buildinfo/buildinfo.go @@ -0,0 +1,45 @@ +// Package buildinfo exposes the build metadata reported by the CLI. +package buildinfo + +import "runtime/debug" + +// These values may be replaced with -ldflags when producing release builds. +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) + +// Info is the stable build metadata rendered by the version command. +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"buildDate"` + Dirty bool `json:"dirty"` + GoVersion string `json:"goVersion,omitempty"` + Module string `json:"module,omitempty"` +} + +// Current returns build metadata from release variables and the Go build +// information embedded in the running binary. +func Current() Info { + info := Info{Version: Version, Commit: Commit, BuildDate: BuildDate} + if details, ok := debug.ReadBuildInfo(); ok { + info.GoVersion = details.GoVersion + info.Module = details.Main.Path + if info.Version == "dev" && details.Main.Version != "" && details.Main.Version != "(devel)" { + info.Version = details.Main.Version + } + if info.Commit == "unknown" { + for _, setting := range details.Settings { + switch setting.Key { + case "vcs.revision": + info.Commit = setting.Value + case "vcs.modified": + info.Dirty = setting.Value == "true" + } + } + } + } + return info +} diff --git a/internal/cli/buildinfo/buildinfo_test.go b/internal/cli/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..89c9bbf --- /dev/null +++ b/internal/cli/buildinfo/buildinfo_test.go @@ -0,0 +1,14 @@ +package buildinfo + +import "testing" + +func TestCurrentUsesConfiguredMetadata(t *testing.T) { + oldVersion, oldCommit, oldBuildDate := Version, Commit, BuildDate + t.Cleanup(func() { Version, Commit, BuildDate = oldVersion, oldCommit, oldBuildDate }) + Version, Commit, BuildDate = "v1.2.3", "abc123", "2026-08-10" + + info := Current() + if info.Version != Version || info.Commit != Commit || info.BuildDate != BuildDate { + t.Fatalf("Current() = %#v, want configured metadata", info) + } +} diff --git a/internal/cli/clierr/errors.go b/internal/cli/clierr/errors.go new file mode 100644 index 0000000..dbe72dd --- /dev/null +++ b/internal/cli/clierr/errors.go @@ -0,0 +1,65 @@ +// Package clierr defines errors that carry stable CLI exit codes. +package clierr + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" +) + +const ( + RuntimeExitCode = 1 + UsageExitCode = 2 +) + +type exitError struct { + code int + message string +} + +func (err exitError) Error() string { return err.message } +func (err exitError) ExitCode() int { return err.code } + +// New creates an error carrying code for the root command to return. +func New(code int, format string, args ...any) error { + return exitError{code: code, message: fmt.Sprintf(format, args...)} +} + +// Runtime creates a command failure with exit code 1. +func Runtime(format string, args ...any) error { + return New(RuntimeExitCode, format, args...) +} + +// Usage creates a command-line usage failure with exit code 2. +func Usage(format string, args ...any) error { + return New(UsageExitCode, format, args...) +} + +// ExitCode returns an error's requested exit code, falling back to 1. +func ExitCode(err error) int { + var coded interface{ ExitCode() int } + if errors.As(err, &coded) { + return coded.ExitCode() + } + return RuntimeExitCode +} + +// NoArgs adapts Cobra's validation error to the CLI's usage exit code. +func NoArgs(command *cobra.Command, args []string) error { + if err := cobra.NoArgs(command, args); err != nil { + return Usage("%s", err) + } + return nil +} + +// ExactArgs adapts Cobra's exact argument validator to the CLI's usage exit +// code while retaining Cobra's standard message. +func ExactArgs(count int) cobra.PositionalArgs { + return func(command *cobra.Command, args []string) error { + if err := cobra.ExactArgs(count)(command, args); err != nil { + return Usage("%s", err) + } + return nil + } +} diff --git a/internal/cli/clierr/errors_test.go b/internal/cli/clierr/errors_test.go new file mode 100644 index 0000000..6e014f4 --- /dev/null +++ b/internal/cli/clierr/errors_test.go @@ -0,0 +1,39 @@ +package clierr + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" +) + +func TestExitCodes(t *testing.T) { + for _, test := range []struct { + name string + err error + want int + }{ + {name: "runtime", err: Runtime("failed"), want: RuntimeExitCode}, + {name: "usage", err: Usage("bad flag"), want: UsageExitCode}, + {name: "ordinary", err: errors.New("failed"), want: RuntimeExitCode}, + } { + t.Run(test.name, func(t *testing.T) { + if got := ExitCode(test.err); got != test.want { + t.Fatalf("ExitCode() = %d, want %d", got, test.want) + } + }) + } +} + +func TestCobraArgumentAdapters(t *testing.T) { + command := &cobra.Command{Use: "test"} + if err := NoArgs(command, []string{"unexpected"}); ExitCode(err) != UsageExitCode { + t.Fatalf("NoArgs error = %v, want usage error", err) + } + if err := ExactArgs(1)(command, nil); ExitCode(err) != UsageExitCode { + t.Fatalf("ExactArgs error = %v, want usage error", err) + } + if err := ExactArgs(1)(command, []string{"value"}); err != nil { + t.Fatalf("ExactArgs valid input returned %v", err) + } +} diff --git a/cmd/check.go b/internal/cli/command/check/command.go similarity index 59% rename from cmd/check.go rename to internal/cli/command/check/command.go index 14e5892..5879f88 100644 --- a/cmd/check.go +++ b/internal/cli/command/check/command.go @@ -1,4 +1,5 @@ -package cmd +// Package check implements repository policy checks. +package check import ( "encoding/json" @@ -7,25 +8,33 @@ import ( "strings" "time" + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/execution" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/cli/scanflags" + "github.com/ramonzx6/gitmulti/internal/cli/statusset" "github.com/ramonzx6/gitmulti/internal/gitstatus" "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -type checkResult struct { +type result struct { Status gitstatus.State `json:"status"` Violations []string `json:"violations,omitempty"` FetchError string `json:"fetchError,omitempty"` } -type checkDocument struct { +type document struct { SchemaVersion int `json:"schemaVersion"` - Results []checkResult `json:"results"` + Results []result `json:"results"` Diagnostics []repository.Warning `json:"diagnostics,omitempty"` } -func newCheckCommand(app App) *cobra.Command { - var scan scanFlags +// New constructs a fresh check command and its private flag state. +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags var failOn []string var quiet bool var format string @@ -38,83 +47,84 @@ func newCheckCommand(app App) *cobra.Command { command := &cobra.Command{ Use: "check", Short: "Enforce repository state policies", - Args: noArgs, + Args: clierr.NoArgs, RunE: func(command *cobra.Command, _ []string) error { if jobs < 1 { - return errorsForFlag("--jobs must be at least 1") + return clierr.Usage("--jobs must be at least 1") } if timeout < 0 { - return errorsForFlag("--timeout cannot be negative") + return clierr.Usage("--timeout cannot be negative") } - runApp := app + runApp := application if noPrompt { runApp.Git = runApp.Git.WithEnv("GIT_TERMINAL_PROMPT=0") } if format != "table" && format != "json" && format != "ndjson" { - return errorsForFlag(fmt.Sprintf("unsupported check format %q", format)) + return clierr.Usage("unsupported check format %q", format) } policies, err := normalizePolicies(failOn) if err != nil { return err } - discovered, err := discoverRepositories(command.Context(), app, command, scan, quiet) + discovered, err := scan.Discover(command.Context(), application, command, quiet) if err != nil { return err } if len(discovered.Repositories) == 0 && requireMatch { - return newExitError(1, "no Git repositories matched") + return clierr.Runtime("no Git repositories matched") } + fetchFailures := 0 fetchErrors := make(map[string]string) if fetch && len(discovered.Repositories) > 0 { if _, err := runApp.Git.LookPath(); err != nil { return err } - fetchRecords := executeAcross(command, runApp, discovered.Repositories, []string{"fetch", "--all", "--prune"}, jobs, timeout, false, "grouped", false) + fetchRecords := execution.ExecuteAcross(command, runApp, discovered.Repositories, []string{"fetch", "--all", "--prune"}, jobs, timeout, false, "grouped", false) for _, record := range fetchRecords { if record.Error != "" { fetchFailures++ fetchErrors[record.Repository] = record.Error if !quiet { - fmt.Fprintf(command.ErrOrStderr(), "%s: fetch failed: %s\n", record.Repository, record.Error) + _, _ = fmt.Fprintf(command.ErrOrStderr(), "%s: fetch failed: %s\n", record.Repository, record.Error) } } } } - states := collectStatuses(command.Context(), runApp, discovered.Repositories, jobs) - results := make([]checkResult, 0, len(states)) + states := statusset.Collect(command.Context(), runApp, discovered.Repositories, jobs) + results := make([]result, 0, len(states)) violationCount := 0 for _, state := range states { violations := evaluatePolicies(state, policies) violationCount += len(violations) - results = append(results, checkResult{Status: state, Violations: violations, FetchError: fetchErrors[state.Name]}) + results = append(results, result{Status: state, Violations: violations, FetchError: fetchErrors[state.Name]}) } if !quiet { - if err := renderCheckResults(command, results, format, discovered.Warnings); err != nil { + if err := render(command, results, format, discovered.Warnings); err != nil { return err } } - collectionFailures := statusErrorCount(states) + collectionFailures := statusset.ErrorCount(states) if fetchFailures > 0 { - return newExitError(1, "fetch failed in %d repositories", fetchFailures) + return clierr.Runtime("fetch failed in %d repositories", fetchFailures) } if collectionFailures > 0 { if quiet { - return exitError{code: 1} + return clierr.New(clierr.RuntimeExitCode, "") } - return newExitError(1, "check could not inspect %d repositories", collectionFailures) + return clierr.Runtime("check could not inspect %d repositories", collectionFailures) } if violationCount > 0 { if quiet { - return exitError{code: 1} + return clierr.New(clierr.RuntimeExitCode, "") } - return newExitError(1, "repository policy check failed with %d violations", violationCount) + return clierr.Runtime("repository policy check failed with %d violations", violationCount) } return nil }, } - scan.bind(command) + scan.Bind(command) command.Flags().StringSliceVar(&failOn, "fail-on", []string{"dirty", "diverged"}, "conditions that fail the check: dirty, unpushed, behind, diverged, detached, no-upstream") command.Flags().BoolVarP(&quiet, "quiet", "q", false, "produce no output; use only the exit status") command.Flags().StringVar(&format, "format", "table", "output format: table, json, or ndjson") @@ -127,17 +137,14 @@ func newCheckCommand(app App) *cobra.Command { } func normalizePolicies(values []string) ([]string, error) { - allowed := map[string]bool{ - "dirty": true, "unpushed": true, "behind": true, "diverged": true, - "detached": true, "no-upstream": true, - } + allowed := map[string]bool{"dirty": true, "unpushed": true, "behind": true, "diverged": true, "detached": true, "no-upstream": true} values = splitValues(values) seen := make(map[string]bool) policies := make([]string, 0, len(values)) for _, value := range values { value = strings.ToLower(value) if !allowed[value] { - return nil, errorsForFlag(fmt.Sprintf("unsupported check policy %q", value)) + return nil, clierr.Usage("unsupported check policy %q", value) } if !seen[value] { seen[value] = true @@ -176,7 +183,7 @@ func evaluatePolicies(state gitstatus.State, policies []string) []string { return violations } -func renderCheckResults(command *cobra.Command, results []checkResult, format string, diagnostics ...[]repository.Warning) error { +func render(command *cobra.Command, results []result, format string, diagnostics ...[]repository.Warning) error { var warnings []repository.Warning if len(diagnostics) > 0 { warnings = diagnostics[0] @@ -185,14 +192,14 @@ func renderCheckResults(command *cobra.Command, results []checkResult, format st case "json": encoder := json.NewEncoder(command.OutOrStdout()) encoder.SetIndent("", " ") - return encoder.Encode(checkDocument{SchemaVersion: outputSchemaVersion, Results: results, Diagnostics: warnings}) + return encoder.Encode(document{SchemaVersion: output.SchemaVersion, Results: results, Diagnostics: warnings}) case "ndjson": encoder := json.NewEncoder(command.OutOrStdout()) - for _, result := range results { + for _, value := range results { if err := encoder.Encode(struct { Type string `json:"type"` - checkResult - }{Type: "check", checkResult: result}); err != nil { + result + }{Type: "check", result: value}); err != nil { return err } } @@ -209,24 +216,37 @@ func renderCheckResults(command *cobra.Command, results []checkResult, format st if _, err := fmt.Fprintln(command.OutOrStdout(), "REPOSITORY\tRESULT\tVIOLATIONS"); err != nil { return err } - for _, result := range results { + for _, value := range results { outcome := "ok" - violations := strings.Join(result.Violations, ",") - if result.FetchError != "" { + violations := strings.Join(value.Violations, ",") + if value.FetchError != "" { outcome = "error" - violations = "fetch: " + result.FetchError - } else if result.Status.Err != nil { + violations = "fetch: " + value.FetchError + } else if value.Status.Err != nil { outcome = "error" - violations = result.Status.Error - } else if len(result.Violations) > 0 { + violations = value.Status.Error + } else if len(value.Violations) > 0 { outcome = "fail" } - if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%s\n", result.Status.Name, outcome, violations); err != nil { + if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%s\n", value.Status.Name, outcome, violations); err != nil { return err } } return nil default: - return errorsForFlag(fmt.Sprintf("unsupported check format %q", format)) + return clierr.Usage("unsupported check format %q", format) + } +} + +func splitValues(values []string) []string { + var result []string + for _, value := range values { + for _, item := range strings.Split(value, ",") { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } } + return result } diff --git a/internal/cli/command/check/command_test.go b/internal/cli/command/check/command_test.go new file mode 100644 index 0000000..6884e99 --- /dev/null +++ b/internal/cli/command/check/command_test.go @@ -0,0 +1,224 @@ +package check + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/gitstatus" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type checkResultOutput struct { + stdout string + stderr string + err error +} + +func runCheck(t *testing.T, application app.App, args ...string) checkResultOutput { + t.Helper() + var stdout, stderr bytes.Buffer + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetIn(bytes.NewReader(nil)) + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.Execute() + return checkResultOutput{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func checkFixture(t *testing.T, names ...string) (app.App, repository.Result) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("deterministic fake Git fixture uses a POSIX executable") + } + root := t.TempDir() + fixture := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create fake repository %q: %v", path, err) + } + fixture.Repositories = append(fixture.Repositories, repository.Repository{Name: name, Path: path}) + } + application := app.App{ + In: bytes.NewReader(nil), + Git: gitexec.Runner{Executable: writeCheckGit(t)}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return fixture, nil + }, + } + return application, fixture +} + +func TestCheckRunEPassFailQuietAndAllPolicies(t *testing.T) { + passApplication, _ := checkFixture(t, "clean") + pass := runCheck(t, passApplication, "--fail-on", "dirty,unpushed,behind,diverged,detached,no-upstream") + if pass.err != nil || !strings.Contains(pass.stdout, "clean\tok") { + t.Fatalf("passing check result = %#v", pass) + } + + failApplication, _ := checkFixture(t, "all") + fail := runCheck(t, failApplication, "--fail-on", "dirty,unpushed,behind,diverged,detached,no-upstream") + if fail.err == nil || clierr.ExitCode(fail.err) != clierr.RuntimeExitCode || !strings.Contains(fail.err.Error(), "policy check failed") { + t.Fatalf("failing check error = %v", fail.err) + } + for _, policy := range []string{"dirty", "diverged", "detached", "no-upstream", "unpushed", "behind"} { + if !strings.Contains(fail.stdout, policy) { + t.Fatalf("failing check output lacks %q: %q", policy, fail.stdout) + } + } + if !strings.Contains(fail.stdout, "all\tfail\t") { + t.Fatalf("failing check output = %q", fail.stdout) + } + + quiet := runCheck(t, failApplication, "--quiet", "--fail-on", "dirty") + if quiet.err == nil || clierr.ExitCode(quiet.err) != clierr.RuntimeExitCode || quiet.stdout != "" || quiet.stderr != "" { + t.Fatalf("quiet check result = %#v, want failure with no output", quiet) + } +} + +func TestCheckRunERendersJSONNDJSONAndDiagnosticsOnFailure(t *testing.T) { + application, fixture := checkFixture(t, "all") + baseDiscover := application.Discover + application.Discover = func(ctx context.Context, options repository.Options) (repository.Result, error) { + result, err := baseDiscover(ctx, options) + result.Warnings = []repository.Warning{{Path: "/tmp/diagnostic", Message: "skipped entry"}} + return result, err + } + jsonResult := runCheck(t, application, "--format", "json", "--fail-on", "dirty") + if jsonResult.err == nil { + t.Fatal("JSON check should retain failing exit status") + } + var document document + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("check JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != output.SchemaVersion || len(document.Results) != len(fixture.Repositories) || len(document.Diagnostics) != 1 || !reflect.DeepEqual(document.Results[0].Violations, []string{"dirty"}) { + t.Fatalf("check document = %#v", document) + } + + ndjsonResult := runCheck(t, application, "--format", "ndjson", "--fail-on", "dirty") + if ndjsonResult.err == nil { + t.Fatal("NDJSON check should retain failing exit status") + } + lines := strings.Split(strings.TrimSpace(ndjsonResult.stdout), "\n") + if len(lines) != len(fixture.Repositories)+1 || !strings.Contains(lines[len(lines)-1], `"type":"diagnostic"`) { + t.Fatalf("check NDJSON output = %q", ndjsonResult.stdout) + } +} + +func TestCheckFetchNoPromptTimeoutAndStructuredFetchFailure(t *testing.T) { + application, _ := checkFixture(t, "prompt-check") + result := runCheck(t, application, "--fetch", "--no-prompt", "--timeout", "1s", "--fail-on", "dirty") + if result.err != nil { + t.Fatalf("check --fetch --no-prompt returned error: %v\nstdout=%q\nstderr=%q", result.err, result.stdout, result.stderr) + } + + failureApplication, _ := checkFixture(t, "fetch-fail") + jsonResult := runCheck(t, failureApplication, "--fetch", "--format", "json", "--fail-on", "dirty") + if jsonResult.err == nil || clierr.ExitCode(jsonResult.err) != clierr.RuntimeExitCode || !strings.Contains(jsonResult.err.Error(), "fetch failed") { + t.Fatalf("check fetch failure error = %v", jsonResult.err) + } + var document document + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("fetch failure JSON is invalid: %v", err) + } + if len(document.Results) != 1 || document.Results[0].FetchError == "" { + t.Fatalf("fetch failure result = %#v", document.Results) + } + + ndjson := runCheck(t, failureApplication, "--fetch", "--format", "ndjson", "--fail-on", "dirty") + if ndjson.err == nil || !strings.Contains(ndjson.stdout, "fetchError") { + t.Fatalf("NDJSON fetch failure = %#v", ndjson) + } +} + +func TestCheckRunEValidatesPoliciesAndFlags(t *testing.T) { + application, _ := checkFixture(t, "clean") + for _, test := range []struct { + args []string + code int + want string + }{ + {args: []string{"--jobs", "0"}, code: clierr.UsageExitCode, want: "--jobs must be at least 1"}, + {args: []string{"--timeout=-1s"}, code: clierr.UsageExitCode, want: "--timeout cannot be negative"}, + {args: []string{"--format", "invalid"}, code: clierr.UsageExitCode, want: "unsupported check format"}, + {args: []string{"--fail-on", "unknown"}, code: clierr.UsageExitCode, want: "unsupported check policy"}, + } { + result := runCheck(t, application, test.args...) + if result.err == nil || clierr.ExitCode(result.err) != test.code || !strings.Contains(result.err.Error(), test.want) { + t.Errorf("args %v result = %#v, want code %d and %q", test.args, result, test.code, test.want) + } + } + + emptyApplication, _ := checkFixture(t) + result := runCheck(t, emptyApplication, "--require-match") + if result.err == nil || clierr.ExitCode(result.err) != clierr.RuntimeExitCode { + t.Fatalf("require-match error = %v", result.err) + } +} + +func TestPolicyHelpersNormalizeAndEvaluateAllConditions(t *testing.T) { + policies, err := normalizePolicies([]string{"dirty,behind", "DIRTY", "no-upstream"}) + if err != nil { + t.Fatalf("normalizePolicies returned error: %v", err) + } + if want := []string{"behind", "dirty", "no-upstream"}; !reflect.DeepEqual(policies, want) { + t.Fatalf("policies = %#v, want %#v", policies, want) + } + + state := gitstatus.State{Dirty: true, Ahead: 2, Behind: 3, Detached: true, NoUpstream: true} + violations := evaluatePolicies(state, []string{"dirty", "unpushed", "behind", "diverged", "detached", "no-upstream"}) + want := []string{"dirty", "unpushed", "behind", "diverged", "detached", "no-upstream"} + if !reflect.DeepEqual(violations, want) { + t.Fatalf("violations = %#v, want %#v", violations, want) + } + if got := evaluatePolicies(gitstatus.State{Err: os.ErrInvalid}, []string{"dirty"}); got != nil { + t.Fatalf("error state violations = %#v, want nil", got) + } +} + +func writeCheckGit(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "fake-git") + script := `#!/bin/sh +repo=${PWD##*/} +if [ "$1" = "-c" ] && [ "$2" = "core.fsmonitor=false" ]; then shift 2; fi +if [ "$1" = "--no-optional-locks" ]; then + case "$repo" in + clean|prompt-check|fetch-fail) printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' ;; + all) printf '%s\0' '# branch.oid abc' '# branch.head (detached)' '# branch.ab +2 -3' '1 .M N... 100644 100644 100644 abc abc changed' ;; + *) echo "unknown fake repository $repo" >&2; exit 10 ;; + esac + exit 0 +fi +case "$1" in + fetch) + if [ "$repo" = "fetch-fail" ]; then echo 'synthetic fetch failure' >&2; exit 13; fi + if [ "$repo" = "prompt-check" ] && [ "${GIT_TERMINAL_PROMPT:-}" != "0" ]; then echo 'credential prompt was not disabled' >&2; exit 12; fi + printf 'synced-%s\n' "$repo" ;; + *) exit 0 ;; +esac +` + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + if err := os.Chmod(path, 0o700); err != nil { + t.Fatalf("chmod fake Git: %v", err) + } + return path +} diff --git a/cmd/completion.go b/internal/cli/command/completion/command.go similarity index 59% rename from cmd/completion.go rename to internal/cli/command/completion/command.go index 70ea24a..52b694e 100644 --- a/cmd/completion.go +++ b/internal/cli/command/completion/command.go @@ -1,16 +1,19 @@ -package cmd +// Package completion implements shell completion generation. +package completion import ( - "fmt" - + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" "github.com/spf13/cobra" ) -func newCompletionCommand(_ App) *cobra.Command { +// New constructs a fresh completion command. The application is accepted for +// a consistent command-constructor API; Cobra obtains streams from the root. +func New(_ app.App) *cobra.Command { return &cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate shell completion scripts", - Args: exactArgs(1), + Args: clierr.ExactArgs(1), ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, RunE: func(command *cobra.Command, args []string) error { switch args[0] { @@ -23,7 +26,7 @@ func newCompletionCommand(_ App) *cobra.Command { case "powershell": return command.Root().GenPowerShellCompletionWithDesc(command.OutOrStdout()) default: - return errorsForFlag(fmt.Sprintf("unsupported shell %q", args[0])) + return clierr.Usage("unsupported shell %q", args[0]) } }, } diff --git a/internal/cli/command/completion/command_test.go b/internal/cli/command/completion/command_test.go new file mode 100644 index 0000000..d912d8d --- /dev/null +++ b/internal/cli/command/completion/command_test.go @@ -0,0 +1,49 @@ +package completion + +import ( + "bytes" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" +) + +func TestCompletionRunEGeneratesEverySupportedShell(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + t.Run(shell, func(t *testing.T) { + var out, errOut bytes.Buffer + command := New(app.App{}) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetOut(&out) + command.SetErr(&errOut) + command.SetArgs([]string{shell}) + if err := command.Execute(); err != nil { + t.Fatalf("completion returned error: %v", err) + } + if out.Len() == 0 { + t.Fatalf("%s completion output is empty", shell) + } + if errOut.Len() != 0 { + t.Fatalf("%s completion stderr = %q", shell, errOut.String()) + } + }) + } +} + +func TestCompletionRunERejectsUnsupportedShellAndWrongArity(t *testing.T) { + for _, args := range [][]string{{"tcsh"}, {}, {"bash", "zsh"}} { + command := New(app.App{}) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetArgs(args) + err := command.Execute() + if err == nil || clierr.ExitCode(err) != clierr.UsageExitCode { + t.Errorf("args %v error = %v, want usage exit code", args, err) + } + if len(args) == 1 && !strings.Contains(err.Error(), "unsupported shell") { + t.Errorf("unsupported shell error = %v", err) + } + } +} diff --git a/internal/cli/command/doctor/command.go b/internal/cli/command/doctor/command.go new file mode 100644 index 0000000..a575580 --- /dev/null +++ b/internal/cli/command/doctor/command.go @@ -0,0 +1,128 @@ +// Package doctor implements the doctor command. +package doctor + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/cli/scanflags" + "github.com/ramonzx6/gitmulti/internal/cli/statusset" + "github.com/spf13/cobra" +) + +type finding struct { + Severity string `json:"severity"` + Check string `json:"check"` + Path string `json:"path,omitempty"` + Message string `json:"message"` +} + +type document struct { + SchemaVersion int `json:"schemaVersion"` + Findings []finding `json:"findings"` +} + +// New constructs a fresh doctor command and its private flag state. +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags + var format string + var includeStatus bool + var strict bool + var jobs int + + command := &cobra.Command{ + Use: "doctor", + Short: "Diagnose Git and repository discovery problems", + Args: clierr.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if format != "text" && format != "json" { + return clierr.Usage("unsupported doctor format %q", format) + } + if jobs < 1 { + return clierr.Usage("--jobs must be at least 1") + } + findings := make([]finding, 0) + gitAvailable := true + gitPath, err := application.Git.LookPath() + if err != nil { + gitAvailable = false + findings = append(findings, finding{Severity: "error", Check: "git", Message: err.Error()}) + } else { + version := application.Git.Run(command.Context(), "", []string{"--version"}, nil) + if version.Err != nil { + findings = append(findings, finding{Severity: "error", Check: "git", Path: gitPath, Message: version.Err.Error()}) + } else { + findings = append(findings, finding{Severity: "info", Check: "git", Path: gitPath, Message: strings.TrimSpace(version.Stdout)}) + } + } + + scan.SetValidation(gitAvailable) + discovered, discoverErr := scan.Discover(command.Context(), application, command) + path, _ := command.Flags().GetString("path") + if discoverErr != nil { + findings = append(findings, finding{Severity: "error", Check: "discovery", Path: path, Message: discoverErr.Error()}) + } else { + findings = append(findings, finding{Severity: "info", Check: "discovery", Path: path, Message: fmt.Sprintf("found %d repositories", len(discovered.Repositories))}) + for _, warning := range discovered.Warnings { + findings = append(findings, finding{Severity: "warning", Check: "discovery", Path: warning.Path, Message: warning.Message}) + } + if includeStatus && gitAvailable { + states := statusset.Collect(command.Context(), application, discovered.Repositories, jobs) + for _, state := range states { + switch { + case state.Err != nil: + findings = append(findings, finding{Severity: "error", Check: "status", Path: state.Path, Message: state.Error}) + case state.Detached: + findings = append(findings, finding{Severity: "warning", Check: "status", Path: state.Path, Message: "detached HEAD"}) + case state.NoUpstream: + findings = append(findings, finding{Severity: "warning", Check: "status", Path: state.Path, Message: "branch has no upstream"}) + } + } + } + } + + if err := render(command, findings, format); err != nil { + return err + } + failed := 0 + for _, value := range findings { + if value.Severity == "error" || strict && value.Severity == "warning" { + failed++ + } + } + if failed > 0 { + return clierr.Runtime("doctor found %d problems", failed) + } + return nil + }, + } + scan.Bind(command) + command.Flags().StringVar(&format, "format", "text", "output format: text or json") + command.Flags().BoolVar(&includeStatus, "status", false, "check detached HEAD and upstream state") + command.Flags().BoolVar(&strict, "strict", false, "treat warnings as failures") + command.Flags().IntVarP(&jobs, "jobs", "j", 4, "maximum concurrent status checks") + return command +} + +func render(command *cobra.Command, findings []finding, format string) error { + if format == "json" { + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(document{SchemaVersion: output.SchemaVersion, Findings: findings}) + } + for _, value := range findings { + location := "" + if value.Path != "" { + location = " (" + value.Path + ")" + } + if _, err := fmt.Fprintf(command.OutOrStdout(), "%s %s%s: %s\n", strings.ToUpper(value.Severity), value.Check, location, value.Message); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/command/doctor/command_test.go b/internal/cli/command/doctor/command_test.go new file mode 100644 index 0000000..d92b6fc --- /dev/null +++ b/internal/cli/command/doctor/command_test.go @@ -0,0 +1,155 @@ +package doctor + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type doctorResult struct { + stdout string + stderr string + err error +} + +func runDoctor(t *testing.T, application app.App, args ...string) doctorResult { + t.Helper() + var stdout, stderr bytes.Buffer + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetIn(bytes.NewReader(nil)) + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.Execute() + return doctorResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func doctorFixture(t *testing.T, names ...string) (app.App, repository.Result) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("deterministic fake Git fixture uses a POSIX executable") + } + root := t.TempDir() + fixture := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create fake repository %q: %v", path, err) + } + fixture.Repositories = append(fixture.Repositories, repository.Repository{Name: name, Path: path}) + } + return app.App{ + In: bytes.NewReader(nil), + Git: gitexec.Runner{Executable: writeDoctorGit(t)}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return fixture, nil + }, + }, fixture +} + +func TestDoctorRunERendersTextJSONAndStatusFindings(t *testing.T) { + application, _ := doctorFixture(t, "clean") + textResult := runDoctor(t, application) + if textResult.err != nil || !strings.Contains(textResult.stdout, "INFO git") || !strings.Contains(textResult.stdout, "INFO discovery") { + t.Fatalf("doctor text result = %#v", textResult) + } + + jsonResult := runDoctor(t, application, "--format", "json") + if jsonResult.err != nil { + t.Fatalf("doctor JSON returned error: %v", jsonResult.err) + } + var document document + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("doctor JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != output.SchemaVersion || len(document.Findings) < 2 { + t.Fatalf("doctor document = %#v", document) + } + + statusApplication, _ := doctorFixture(t, "detached", "noupstream") + statusResult := runDoctor(t, statusApplication, "--status") + if statusResult.err != nil || !strings.Contains(statusResult.stdout, "WARNING status") || !strings.Contains(statusResult.stdout, "detached HEAD") || !strings.Contains(statusResult.stdout, "no upstream") { + t.Fatalf("doctor status result = %#v", statusResult) + } +} + +func TestDoctorRunEReportsMissingGitAndStrictWarnings(t *testing.T) { + application, _ := doctorFixture(t, "clean") + missing := application + missing.Git = gitexec.Runner{Executable: "gitmulti-doctor-command-missing"} + missingResult := runDoctor(t, missing) + if missingResult.err == nil || clierr.ExitCode(missingResult.err) != clierr.RuntimeExitCode || !strings.Contains(missingResult.stdout, "ERROR git") { + t.Fatalf("doctor missing-git result = %#v", missingResult) + } + + warningApplication, _ := doctorFixture(t, "clean") + warningApplication.Discover = func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{Warnings: []repository.Warning{{Path: "/tmp/warn", Message: "inaccessible"}}}, nil + } + strict := runDoctor(t, warningApplication, "--strict") + if strict.err == nil || clierr.ExitCode(strict.err) != clierr.RuntimeExitCode || !strings.Contains(strict.err.Error(), "doctor found") || !strings.Contains(strict.stdout, "WARNING discovery") { + t.Fatalf("doctor strict result = %#v", strict) + } + if !strings.Contains(strict.stderr, "warning: /tmp/warn: inaccessible") { + t.Fatalf("doctor discovery warning stderr = %q", strict.stderr) + } +} + +func TestDoctorRunEValidatesFlagsAndJSONContract(t *testing.T) { + application, _ := doctorFixture(t, "clean") + for _, test := range []struct { + args []string + want string + }{ + {args: []string{"--format", "invalid"}, want: "unsupported doctor format"}, + {args: []string{"--jobs", "0"}, want: "--jobs must be at least 1"}, + {args: []string{"unexpected"}, want: "unknown command"}, + } { + result := runDoctor(t, application, test.args...) + if result.err == nil || clierr.ExitCode(result.err) != clierr.UsageExitCode || !strings.Contains(result.err.Error(), test.want) { + t.Errorf("args %v result = %#v, want usage error containing %q", test.args, result, test.want) + } + } +} + +func writeDoctorGit(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "fake-git") + script := `#!/bin/sh +repo=${PWD##*/} +if [ "$1" = "--version" ]; then echo "git version fake-1.0"; exit 0; fi +if [ "$1" = "rev-parse" ]; then echo '.git'; exit 0; fi +if [ "$1" = "-c" ] && [ "$2" = "core.fsmonitor=false" ]; then shift 2; fi +if [ "$1" = "--no-optional-locks" ]; then + case "$repo" in + clean) printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' ;; + detached) printf '%s\0' '# branch.oid abc' '# branch.head (detached)' ;; + noupstream) printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.ab +0 -0' ;; + *) echo "unknown fake repository $repo" >&2; exit 10 ;; + esac + exit 0 +fi +exit 0 +` + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + if err := os.Chmod(path, 0o700); err != nil { + t.Fatalf("chmod fake Git: %v", err) + } + return path +} diff --git a/internal/cli/command/exec/command.go b/internal/cli/command/exec/command.go new file mode 100644 index 0000000..cc0231c --- /dev/null +++ b/internal/cli/command/exec/command.go @@ -0,0 +1,117 @@ +// Package exec implements the command that runs a Git command across +// discovered repositories. +package exec + +import ( + "fmt" + "time" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/execution" + "github.com/ramonzx6/gitmulti/internal/cli/scanflags" + "github.com/spf13/cobra" +) + +// New constructs the exec command using application as its process and +// discovery boundary. +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags + var commandString string + var jobs int + var failFast bool + var timeout time.Duration + var dryRun bool + var output string + var interactive bool + var quiet bool + var requireMatch bool + + command := &cobra.Command{ + Use: "exec [flags] -- ", + Aliases: []string{"ex"}, + Short: "Execute a Git command across repositories", + Args: cobra.ArbitraryArgs, + RunE: func(command *cobra.Command, args []string) error { + gitArgs, err := parseGitCommand(commandString, args) + if err != nil { + return clierr.Usage("%s", err) + } + if jobs < 1 { + return clierr.Usage("--jobs must be at least 1") + } + if timeout < 0 { + return clierr.Usage("--timeout cannot be negative") + } + if interactive { + if jobs != 1 { + return clierr.Usage("--interactive requires --jobs=1") + } + output = "raw" + } + if !execution.ValidOutput(output) { + return clierr.Usage("unsupported output mode %q", output) + } + if output == "raw" && jobs != 1 { + return clierr.Usage("raw output requires --jobs=1") + } + + discovered, err := scan.Discover(command.Context(), application, command) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 { + if requireMatch { + return clierr.Runtime("no Git repositories matched") + } + return nil + } + + if dryRun { + for _, repo := range discovered.Repositories { + if _, err := fmt.Fprintln(command.OutOrStdout(), execution.DisplayCommand(repo.Path, gitArgs)); err != nil { + return err + } + } + return nil + } + if _, err := application.Git.LookPath(); err != nil { + return err + } + + records := execution.ExecuteAcross(command, application, discovered.Repositories, gitArgs, jobs, timeout, failFast, output, interactive) + if err := execution.Render(command, records, output, quiet, discovered.Warnings); err != nil { + return err + } + failures, skipped := countFailures(records) + if failures > 0 || skipped > 0 { + return clierr.Runtime("Git command failed in %d and skipped %d of %d repositories", failures, skipped, len(records)) + } + return nil + }, + } + scan.Bind(command) + command.Flags().StringVarP(&commandString, "command", "c", "", "Git command string (deprecated; use positional arguments after --)") + _ = command.Flags().MarkDeprecated("command", "use positional Git arguments after --") + command.Flags().IntVarP(&jobs, "jobs", "j", 1, "maximum concurrent Git processes") + command.Flags().BoolVar(&failFast, "fail-fast", false, "cancel remaining work after the first failure") + command.Flags().DurationVar(&timeout, "timeout", 0, "per-repository command timeout") + command.Flags().BoolVar(&dryRun, "dry-run", false, "print commands without executing them") + command.Flags().StringVar(&output, "output", "grouped", "output mode: grouped, prefixed, raw, json, or ndjson") + command.Flags().BoolVar(&interactive, "interactive", false, "inherit stdin and use raw sequential output") + command.Flags().BoolVarP(&quiet, "quiet", "q", false, "suppress repository headings") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} + +func countFailures(records []execution.Record) (failures, skipped int) { + for _, record := range records { + if record.Skipped { + skipped++ + } else if record.Error != "" { + failures++ + } + } + return failures, skipped +} diff --git a/internal/cli/command/exec/command_test.go b/internal/cli/command/exec/command_test.go new file mode 100644 index 0000000..8810400 --- /dev/null +++ b/internal/cli/command/exec/command_test.go @@ -0,0 +1,263 @@ +package exec + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/execution" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestParseGitCommandSupportsPositionalAndLegacyForms(t *testing.T) { + positional, err := parseGitCommand("", []string{"status", "--short"}) + if err != nil || !reflect.DeepEqual(positional, []string{"status", "--short"}) { + t.Fatalf("positional command = %#v, error %v", positional, err) + } + legacy, err := parseGitCommand(`status --short "path with spaces"`, nil) + if err != nil || !reflect.DeepEqual(legacy, []string{"status", "--short", "path with spaces"}) { + t.Fatalf("legacy command = %#v, error %v", legacy, err) + } +} + +func TestNewExecDryRunUsesRepositoryDiscoveryAndDoesNotResolveGit(t *testing.T) { + var out bytes.Buffer + application := app.App{ + Out: &out, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{Repositories: []repository.Repository{{Name: "repo", Path: "/tmp/repo"}}}, nil + }, + } + command := New(application) + command.SetOut(&out) + command.SetArgs([]string{"--dry-run", "--", "status", "--short"}) + if err := command.Execute(); err != nil { + t.Fatalf("dry-run returned error: %v", err) + } + if got, want := out.String(), `git -C "/tmp/repo" "status" "--short"`+"\n"; got != want { + t.Fatalf("dry-run output = %q, want %q", got, want) + } +} + +func TestNewExecRequireMatchUsesRuntimeExitCode(t *testing.T) { + application := app.App{Discover: func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{}, nil + }} + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetArgs([]string{"--dry-run", "--require-match", "--", "status"}) + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "no Git repositories matched") { + t.Fatalf("error = %v, want no-match error", err) + } +} + +func TestParseGitCommandRejectsEmptyAndMixedForms(t *testing.T) { + for _, test := range []struct { + name string + cmd string + args []string + want string + }{ + {name: "empty", want: "Git command cannot be empty"}, + {name: "mixed", cmd: "status", args: []string{"log"}, want: "either --command or positional"}, + {name: "invalid shell quoting", cmd: `status "`, want: "invalid Git command"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := parseGitCommand(test.cmd, test.args) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestExecRendersGroupedPrefixedRawJSONAndNDJSON(t *testing.T) { + application, fixture := execFixtureApp(t, "alpha") + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "grouped", args: []string{"--output", "grouped", "--", "emit"}, want: "== alpha =="}, + {name: "prefixed", args: []string{"--output", "prefixed", "--", "emit"}, want: "[alpha] out-alpha"}, + {name: "raw", args: []string{"--output", "raw", "--", "emit"}, want: "out-alpha"}, + } { + t.Run(test.name, func(t *testing.T) { + result := runExec(t, application, "", test.args...) + if result.err != nil { + t.Fatalf("exec %s returned error: %v", test.name, result.err) + } + if !strings.Contains(result.stdout, test.want) || !strings.Contains(result.stderr, "err-alpha") { + t.Fatalf("exec %s output stdout=%q stderr=%q", test.name, result.stdout, result.stderr) + } + }) + } + + jsonResult := runExec(t, application, "", "--output", "json", "--", "emit") + if jsonResult.err != nil { + t.Fatalf("exec JSON returned error: %v", jsonResult.err) + } + var document execution.Document + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("exec JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if len(document.Results) != 1 || document.Results[0].Repository != fixture.Repositories[0].Name || document.Results[0].Stdout != "out-alpha\n" { + t.Fatalf("exec JSON document = %#v", document) + } + + ndjsonResult := runExec(t, application, "", "--output", "ndjson", "--", "emit") + if ndjsonResult.err != nil { + t.Fatalf("exec NDJSON returned error: %v", ndjsonResult.err) + } + lines := strings.Split(strings.TrimSpace(ndjsonResult.stdout), "\n") + if len(lines) != 1 { + t.Fatalf("NDJSON lines = %d, output %q", len(lines), ndjsonResult.stdout) + } + var record execution.Record + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatalf("exec NDJSON decode failed: %v", err) + } + if record.Repository != "alpha" || record.ExitCode != 0 { + t.Fatalf("exec NDJSON record = %#v", record) + } +} + +func TestExecMachineOutputsRetainDiscoveryDiagnostics(t *testing.T) { + application, fixture := execFixtureApp(t, "alpha") + application.Discover = func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{ + Repositories: fixture.Repositories, + Warnings: []repository.Warning{{Path: "/tmp/skipped", Message: "inaccessible"}}, + }, nil + } + result := runExec(t, application, "", "--output", "json", "--", "emit") + if result.err != nil { + t.Fatalf("diagnostic exec returned error: %v", result.err) + } + var document execution.Document + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("diagnostic JSON decode failed: %v", err) + } + if len(document.Diagnostics) != 1 || document.Diagnostics[0].Message != "inaccessible" { + t.Fatalf("diagnostics = %#v", document.Diagnostics) + } + if !strings.Contains(result.stderr, "warning: /tmp/skipped: inaccessible") { + t.Fatalf("warning was not emitted to stderr: %q", result.stderr) + } +} + +func TestExecAggregatesFailuresAndTimeouts(t *testing.T) { + application, fixture := execFixtureApp(t, "fail-a", "ok", "fail-b") + failure := runExec(t, application, "", "--output", "json", "--jobs", "2", "--", "fail") + mustExecError(t, failure, "failed in 2") + var document execution.Document + if err := json.Unmarshal([]byte(failure.stdout), &document); err != nil { + t.Fatalf("failure JSON decode failed: %v output=%q stderr=%q", err, failure.stdout, failure.stderr) + } + if len(document.Results) != len(fixture.Repositories) { + t.Fatalf("failure records = %d, want %d", len(document.Results), len(fixture.Repositories)) + } + failures := 0 + for _, record := range document.Results { + if record.Error != "" { + failures++ + } + } + if failures != 2 { + t.Fatalf("failure records = %#v", document.Results) + } + + timeout := runExec(t, application, "", "--output", "json", "--timeout", "20ms", "--", "hang") + mustExecError(t, timeout, "failed in") + if !strings.Contains(timeout.stdout, "context deadline exceeded") { + t.Fatalf("timeout JSON = %q", timeout.stdout) + } +} + +func TestExecFailFastMarksRemainingRepositoriesSkipped(t *testing.T) { + application, _ := execFixtureApp(t, "fail-first", "second") + result := runExec(t, application, "", "--output", "json", "--jobs", "1", "--fail-fast", "--", "fail") + mustExecError(t, result, "skipped 1 of 2") + var document execution.Document + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("fail-fast JSON decode failed: %v output=%q stderr=%q", err, result.stdout, result.stderr) + } + if !document.Results[1].Skipped || document.Results[1].Error == "" { + t.Fatalf("fail-fast records = %#v", document.Results) + } +} + +func TestExecInteractiveInheritsStdinAndForcesRawOutput(t *testing.T) { + application, _ := execFixtureApp(t, "alpha") + result := runExec(t, application, "hello\n", "--interactive", "--", "stdin") + if result.err != nil { + t.Fatalf("interactive exec returned error: %v", result.err) + } + if result.stdout != "got-alpha-hello\n" { + t.Fatalf("interactive stdout = %q", result.stdout) + } +} + +func TestExecQuietAndAliases(t *testing.T) { + application, _ := execFixtureApp(t, "alpha") + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + if len(command.Aliases) != 1 || command.Aliases[0] != "ex" { + t.Fatalf("aliases = %#v, want [ex]", command.Aliases) + } + + result := runExec(t, application, "", "--quiet", "--", "emit") + if result.err != nil { + t.Fatalf("quiet exec returned error: %v", result.err) + } + if strings.Contains(result.stdout, "== alpha ==") { + t.Fatalf("quiet output has heading: %q", result.stdout) + } +} + +func TestExecFlagValidationAndRequireMatch(t *testing.T) { + application, _ := execFixtureApp(t, "alpha") + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "zero jobs", args: []string{"--jobs", "0", "--dry-run", "--", "status"}, want: "--jobs must be at least 1"}, + {name: "negative timeout", args: []string{"--timeout=-1s", "--dry-run", "--", "status"}, want: "--timeout cannot be negative"}, + {name: "interactive parallel", args: []string{"--interactive", "--jobs", "2", "--dry-run", "--", "status"}, want: "--interactive requires --jobs=1"}, + {name: "raw parallel", args: []string{"--output", "raw", "--jobs", "2", "--dry-run", "--", "status"}, want: "raw output requires --jobs=1"}, + {name: "invalid output", args: []string{"--output", "invalid", "--dry-run", "--", "status"}, want: "unsupported output mode"}, + } { + t.Run(test.name, func(t *testing.T) { + result := runExec(t, application, "", test.args...) + mustExecError(t, result, test.want) + if got := clierr.ExitCode(result.err); got != clierr.UsageExitCode { + t.Fatalf("exit code = %d, want %d", got, clierr.UsageExitCode) + } + }) + } + + empty, _ := execFixtureApp(t) + result := runExec(t, empty, "", "--dry-run", "--require-match", "--", "status") + mustExecError(t, result, "no Git repositories matched") + if got := clierr.ExitCode(result.err); got != clierr.RuntimeExitCode { + t.Fatalf("require-match exit code = %d, want %d", got, clierr.RuntimeExitCode) + } + + // Ensure the command exposes the stable coded error type rather than a + // Cobra validation error for malformed command forms. + missing := runExec(t, application, "", "--dry-run") + var coded interface{ ExitCode() int } + if missing.err == nil || !errors.As(missing.err, &coded) { + t.Fatalf("missing command error = %v, want coded error", missing.err) + } +} diff --git a/internal/cli/command/exec/fixture_test.go b/internal/cli/command/exec/fixture_test.go new file mode 100644 index 0000000..e05f45e --- /dev/null +++ b/internal/cli/command/exec/fixture_test.go @@ -0,0 +1,96 @@ +package exec + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type execResult struct { + stdout string + stderr string + err error +} + +func execFixtureApp(t *testing.T, names ...string) (app.App, repository.Result) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake Git fixture uses a POSIX executable") + } + root := t.TempDir() + result := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create fixture repository %q: %v", name, err) + } + result.Repositories = append(result.Repositories, repository.Repository{Name: name, Path: path}) + } + script := filepath.Join(t.TempDir(), "fake-git") + const fakeGit = `#!/bin/sh +repo=${PWD##*/} +case "$1" in + emit) + printf 'out-%s\n' "$repo" + printf 'err-%s\n' "$repo" >&2 + ;; + fail) + printf 'out-%s\n' "$repo" + printf 'err-%s\n' "$repo" >&2 + case "$repo" in + fail-*|fail-first) exit 7 ;; + esac + ;; + stdin) + IFS= read -r value + printf 'got-%s-%s\n' "$repo" "$value" + ;; + hang) + while :; do sleep 1; done + ;; + *) + printf 'out-%s\n' "$repo" + ;; +esac +` + if err := os.WriteFile(script, []byte(fakeGit), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + return app.App{ + Git: gitexec.Runner{Executable: script}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return result, nil + }, + }, result +} + +func runExec(t *testing.T, application app.App, input string, args ...string) execResult { + t.Helper() + var stdout, stderr bytes.Buffer + application.In = bytes.NewBufferString(input) + application.Out = &stdout + application.Err = &stderr + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetIn(application.In) + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.Execute() + return execResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func mustExecError(t *testing.T, result execResult, want string) { + t.Helper() + if result.err == nil || !bytes.Contains([]byte(result.err.Error()), []byte(want)) { + t.Fatalf("error = %v, want %q", result.err, want) + } +} diff --git a/internal/cli/command/exec/parse.go b/internal/cli/command/exec/parse.go new file mode 100644 index 0000000..f7be12e --- /dev/null +++ b/internal/cli/command/exec/parse.go @@ -0,0 +1,30 @@ +package exec + +import ( + "errors" + "fmt" + "strings" + + "github.com/google/shlex" +) + +func parseGitCommand(command string, args []string) ([]string, error) { + trimmedCommand := strings.TrimSpace(command) + if trimmedCommand != "" && len(args) > 0 { + return nil, errors.New("use either --command or positional Git arguments, not both") + } + if trimmedCommand == "" { + if len(args) == 0 { + return nil, errors.New("Git command cannot be empty") + } + return append([]string(nil), args...), nil + } + parsedArgs, err := shlex.Split(trimmedCommand) + if err != nil { + return nil, fmt.Errorf("invalid Git command %q: %w", command, err) + } + if len(parsedArgs) == 0 { + return nil, errors.New("Git command cannot be empty") + } + return parsedArgs, nil +} diff --git a/cmd/list.go b/internal/cli/command/list/command.go similarity index 66% rename from cmd/list.go rename to internal/cli/command/list/command.go index 50033aa..c512457 100644 --- a/cmd/list.go +++ b/internal/cli/command/list/command.go @@ -1,16 +1,23 @@ -package cmd +// Package list implements the list command. +package list import ( "encoding/json" "fmt" "path/filepath" + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/cli/scanflags" "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -func newListCommand(app App) *cobra.Command { - var scan scanFlags +// New constructs a fresh list command and its private flag state. +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags var format string var absolute bool var nullTerminated bool @@ -21,55 +28,55 @@ func newListCommand(app App) *cobra.Command { Use: "list", Aliases: []string{"ls"}, Short: "List discovered Git repositories", - Args: noArgs, + Args: clierr.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - if !validListFormat(format) { - return errorsForFlag(fmt.Sprintf("unsupported list format %q", format)) + if !validFormat(format) { + return clierr.Usage("unsupported list format %q", format) } if absolute { if format != "relative" && format != "absolute" { - return errorsForFlag(fmt.Sprintf("--absolute cannot be combined with --format %s", format)) + return clierr.Usage("--absolute cannot be combined with --format %s", format) } format = "absolute" } if nullTerminated && format != "name" && format != "relative" && format != "absolute" { - return errorsForFlag("--null is only supported with name, relative, or absolute output") + return clierr.Usage("--null is only supported with name, relative, or absolute output") } if count && nullTerminated { - return errorsForFlag("--count cannot be combined with --null") + return clierr.Usage("--count cannot be combined with --null") } if count && absolute { - return errorsForFlag("--count cannot be combined with --absolute") + return clierr.Usage("--count cannot be combined with --absolute") } if count && format != "relative" { - return errorsForFlag(fmt.Sprintf("--count cannot be combined with --format %s", format)) + return clierr.Usage("--count cannot be combined with --format %s", format) } - result, err := discoverRepositories(command.Context(), app, command, scan) + result, err := scan.Discover(command.Context(), application, command) if err != nil { return err } if requireMatch && len(result.Repositories) == 0 { - return newExitError(1, "no Git repositories matched") + return clierr.Runtime("no Git repositories matched") } if count { _, err := fmt.Fprintln(command.OutOrStdout(), len(result.Repositories)) return err } - return renderRepositoryList(command, result, format, nullTerminated) + return render(command, result, format, nullTerminated) }, } - scan.bind(command) + scan.Bind(command) + scan.BindValidation(command) command.Flags().StringVar(&format, "format", "relative", "output format: name, relative, absolute, json, or ndjson") command.Flags().BoolVar(&absolute, "absolute", false, "print absolute paths") command.Flags().BoolVarP(&nullTerminated, "null", "0", false, "terminate text records with NUL") command.Flags().BoolVar(&count, "count", false, "print only the repository count") - command.Flags().BoolVar(&scan.validate, "validate", false, "validate repository markers using Git") command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") return command } -func validListFormat(format string) bool { +func validFormat(format string) bool { switch format { case "name", "relative", "absolute", "json", "ndjson": return true @@ -78,7 +85,7 @@ func validListFormat(format string) bool { } } -func renderRepositoryList(command *cobra.Command, result repository.Result, format string, nullTerminated bool) error { +func render(command *cobra.Command, result repository.Result, format string, nullTerminated bool) error { switch format { case "name", "relative", "absolute": separator := "\n" @@ -108,7 +115,7 @@ func renderRepositoryList(command *cobra.Command, result repository.Result, form SchemaVersion int `json:"schemaVersion"` Repositories []repository.Repository `json:"repositories"` Diagnostics []repository.Warning `json:"diagnostics,omitempty"` - }{SchemaVersion: outputSchemaVersion, Repositories: result.Repositories, Diagnostics: result.Warnings}) + }{SchemaVersion: output.SchemaVersion, Repositories: result.Repositories, Diagnostics: result.Warnings}) case "ndjson": encoder := json.NewEncoder(command.OutOrStdout()) for _, repo := range result.Repositories { @@ -129,6 +136,6 @@ func renderRepositoryList(command *cobra.Command, result repository.Result, form } return nil default: - return errorsForFlag(fmt.Sprintf("unsupported list format %q", format)) + return clierr.Usage("unsupported list format %q", format) } } diff --git a/internal/cli/command/list/command_test.go b/internal/cli/command/list/command_test.go new file mode 100644 index 0000000..0e87581 --- /dev/null +++ b/internal/cli/command/list/command_test.go @@ -0,0 +1,209 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type listResult struct { + stdout string + stderr string + err error +} + +func runList(t *testing.T, application app.App, args ...string) listResult { + t.Helper() + var stdout, stderr bytes.Buffer + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetIn(bytes.NewReader(nil)) + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.Execute() + return listResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func listFixture(root string, names ...string) repository.Result { + result := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + result.Repositories = append(result.Repositories, repository.Repository{ + Name: name, + Path: filepath.Join(root, filepath.FromSlash(name)), + }) + } + return result +} + +func listApp(result repository.Result) app.App { + return app.App{ + In: bytes.NewReader(nil), + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return result, nil + }, + } +} + +func TestListRunERendersTextFormatsCountAndAlias(t *testing.T) { + root := t.TempDir() + fixture := listFixture(root, "alpha", "nested/beta") + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "name", args: []string{"--format", "name"}, want: "alpha\nbeta\n"}, + {name: "relative", args: []string{"--format", "relative"}, want: "alpha\nnested/beta\n"}, + {name: "null", args: []string{"--format", "name", "--null"}, want: "alpha\x00beta\x00"}, + {name: "count", args: []string{"--count"}, want: "2\n"}, + } { + t.Run(test.name, func(t *testing.T) { + result := runList(t, listApp(fixture), test.args...) + if result.err != nil || result.stdout != test.want { + t.Fatalf("result = %#v, want stdout %q", result, test.want) + } + }) + } + + absolute := runList(t, listApp(fixture), "--format", "absolute") + wantAbsolute := filepath.Join(root, "alpha") + "\n" + filepath.Join(root, "nested", "beta") + "\n" + if absolute.err != nil || absolute.stdout != wantAbsolute { + t.Fatalf("absolute result = %#v, want %q", absolute, wantAbsolute) + } + + alias := runList(t, listApp(fixture), "--format", "name") + if alias.err != nil || alias.stdout != "alpha\nbeta\n" { + t.Fatalf("name result = %#v", alias) + } +} + +func TestListRunERendersMachineFormatsAndDiagnostics(t *testing.T) { + root := t.TempDir() + fixture := listFixture(root, "alpha", "beta") + application := listApp(fixture) + application.Discover = func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{Repositories: fixture.Repositories, Warnings: []repository.Warning{{Path: "/tmp/skipped", Message: "inaccessible"}}}, nil + } + + jsonResult := runList(t, application, "--format", "json") + if jsonResult.err != nil { + t.Fatalf("JSON list returned error: %v", jsonResult.err) + } + var document struct { + SchemaVersion int `json:"schemaVersion"` + Repositories []repository.Repository `json:"repositories"` + Diagnostics []repository.Warning `json:"diagnostics"` + } + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("JSON output is invalid: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != output.SchemaVersion || !reflect.DeepEqual(document.Repositories, fixture.Repositories) || len(document.Diagnostics) != 1 { + t.Fatalf("JSON document = %#v", document) + } + + ndjsonResult := runList(t, application, "--format", "ndjson") + if ndjsonResult.err != nil { + t.Fatalf("NDJSON list returned error: %v", ndjsonResult.err) + } + lines := strings.Split(strings.TrimSpace(ndjsonResult.stdout), "\n") + if len(lines) != len(fixture.Repositories)+1 { + t.Fatalf("NDJSON line count = %d, want %d", len(lines), len(fixture.Repositories)+1) + } + var diagnostic struct { + Type string `json:"type"` + } + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &diagnostic); err != nil || diagnostic.Type != "diagnostic" { + t.Fatalf("NDJSON diagnostic = %q, error = %v", lines[len(lines)-1], err) + } +} + +func TestListRunEValidatesFlagsAndRequireMatchExitCodes(t *testing.T) { + empty := listApp(repository.Result{}) + result := runList(t, empty, "--require-match") + if result.err == nil || clierr.ExitCode(result.err) != clierr.RuntimeExitCode { + t.Fatalf("require-match error = %v, want runtime exit code", result.err) + } + + for _, args := range [][]string{ + {"--format", "json", "--absolute"}, + {"--format", "json", "--null"}, + {"--format", "invalid"}, + {"--count", "--format", "json"}, + {"--count", "--null"}, + {"--max-depth", "1"}, + {"--include-nested"}, + } { + result := runList(t, empty, args...) + if result.err == nil || clierr.ExitCode(result.err) != clierr.UsageExitCode { + t.Errorf("args %v error = %v, want usage exit code", args, result.err) + } + } +} + +func TestListPassesAllScanOptionsToDiscoverer(t *testing.T) { + var got repository.Options + application := app.App{Discover: func(_ context.Context, options repository.Options) (repository.Result, error) { + got = options + return repository.Result{}, nil + }} + result := runList(t, application, "--path", "/workspace", "--recursive", "--include-root", "--include-nested", "--max-depth", "3", "--match", "services/*", "--exclude", "services/archive", "--include-hidden", "--follow-symlinks", "--include-bare") + if result.err != nil { + t.Fatalf("list with scan options returned error: %v", result.err) + } + want := repository.Options{Root: "/workspace", Recursive: true, IncludeRoot: true, IncludeNested: true, MaxDepth: 3, Match: []string{"services/*"}, Exclude: []string{"services/archive"}, IncludeHidden: true, FollowSymlinks: true, IncludeBare: true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("discovery options = %#v, want %#v", got, want) + } +} + +func TestListValidationUsesConfiguredGitRunner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake Git fixture uses a POSIX executable") + } + validated := false + path := writeListGit(t) + application := app.App{ + Git: gitexec.Runner{Executable: path}, + Discover: func(ctx context.Context, options repository.Options) (repository.Result, error) { + if options.ValidateRepository == nil { + t.Fatal("validation callback was not injected") + } + if err := options.ValidateRepository(ctx, t.TempDir()); err != nil { + t.Fatalf("injected validation returned error: %v", err) + } + validated = true + return repository.Result{}, nil + }, + } + result := runList(t, application, "--validate") + if result.err != nil || !validated { + t.Fatalf("validation result = %#v, validated = %t", result, validated) + } +} + +func writeListGit(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "fake-git") + if err := os.WriteFile(path, []byte("#!/bin/sh\nif [ \"$1\" = \"rev-parse\" ]; then exit 0; fi\nexit 1\n"), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + if err := os.Chmod(path, 0o700); err != nil { + t.Fatalf("chmod fake Git: %v", err) + } + return path +} diff --git a/internal/cli/command/snapshot/command.go b/internal/cli/command/snapshot/command.go new file mode 100644 index 0000000..fdbb3c7 --- /dev/null +++ b/internal/cli/command/snapshot/command.go @@ -0,0 +1,186 @@ +// Package snapshot exports repository revisions and remotes as machine-readable +// JSON while failing closed when redacting remote credentials. +package snapshot + +import ( + "encoding/json" + "strings" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/cli/scanflags" + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +type snapshotRepository struct { + Name string `json:"name"` + Path string `json:"path"` + Head string `json:"head,omitempty"` + Branch string `json:"branch,omitempty"` + Remotes map[string][]string `json:"remotes,omitempty"` + Error string `json:"error,omitempty"` +} + +type snapshotDocument struct { + SchemaVersion int `json:"schemaVersion"` + Repositories []snapshotRepository `json:"repositories"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} + +// New constructs the snapshot command using application as its process and +// discovery boundary. +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags + var outputPath string + var format string + var includeRemotes bool + var force bool + var requireMatch bool + + command := &cobra.Command{ + Use: "snapshot", + Short: "Export repository revisions and remotes", + Args: clierr.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if format != "json" && format != "ndjson" { + return clierr.Usage("unsupported snapshot format %q", format) + } + discovered, err := scan.Discover(command.Context(), application, command) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 && requireMatch { + return clierr.Runtime("no Git repositories matched") + } + if len(discovered.Repositories) > 0 { + if _, err := application.Git.LookPath(); err != nil { + return err + } + } + + records := make([]snapshotRepository, 0, len(discovered.Repositories)) + failures := 0 + for _, repo := range discovered.Repositories { + record := snapshotRepository{Name: repo.Name, Path: repo.Path} + head := application.Git.Run(command.Context(), repo.Path, []string{"rev-parse", "HEAD"}, nil) + if head.Err != nil { + record.Error = head.Err.Error() + failures++ + } else { + record.Head = strings.TrimSpace(head.Stdout) + } + branch := application.Git.Run(command.Context(), repo.Path, []string{"symbolic-ref", "--quiet", "--short", "HEAD"}, nil) + if branch.Err == nil { + record.Branch = strings.TrimSpace(branch.Stdout) + } + if includeRemotes { + remotes := application.Git.Run(command.Context(), repo.Path, []string{"remote", "-v"}, nil) + if remotes.Err != nil && record.Error == "" { + record.Error = remotes.Err.Error() + failures++ + } else { + record.Remotes = parseRemotes(remotes.Stdout) + } + } + records = append(records, record) + } + + writer, closeWriter, err := snapshotWriter(command.OutOrStdout(), outputPath, force) + if err != nil { + return err + } + encoder := json.NewEncoder(writer) + var encodeErr error + if format == "json" { + encoder.SetIndent("", " ") + encodeErr = encoder.Encode(snapshotDocument{SchemaVersion: output.SchemaVersion, Repositories: records, Diagnostics: discovered.Warnings}) + } else { + for _, record := range records { + if err := encoder.Encode(struct { + Type string `json:"type"` + snapshotRepository + }{Type: "repository", snapshotRepository: record}); err != nil { + encodeErr = err + break + } + } + for _, warning := range discovered.Warnings { + if err := encoder.Encode(struct { + Type string `json:"type"` + repository.Warning + }{Type: "diagnostic", Warning: warning}); err != nil { + encodeErr = err + break + } + } + } + if closeWriter != nil { + if encodeErr != nil { + if aborter, ok := writer.(interface{ Abort() error }); ok { + _ = aborter.Abort() + } + } else if closeErr := closeWriter(); closeErr != nil { + encodeErr = closeErr + } + } + if encodeErr != nil { + return encodeErr + } + if failures > 0 { + return clierr.Runtime("snapshot failed for %d repositories", failures) + } + return nil + }, + } + scan.Bind(command) + command.Flags().StringVarP(&outputPath, "output", "o", "", "write the snapshot to a file instead of stdout") + command.Flags().StringVar(&format, "format", "json", "output format: json or ndjson") + command.Flags().BoolVar(&includeRemotes, "include-remotes", false, "include configured remote URLs with credentials redacted") + command.Flags().BoolVar(&force, "force", false, "overwrite an existing output file") + command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} + +// parseRemotes parses `git remote -v` output and redacts credentials and +// credential-like query parameters. +func parseRemotes(value string) map[string][]string { + remotes := make(map[string][]string) + for _, line := range strings.Split(value, "\n") { + fields := strings.SplitN(line, "\t", 2) + if len(fields) != 2 { + continue + } + name := strings.TrimSpace(fields[0]) + remoteURL := strings.TrimSpace(fields[1]) + remoteURL = strings.TrimSuffix(remoteURL, " (fetch)") + remoteURL = strings.TrimSuffix(remoteURL, " (push)") + if name == "" || remoteURL == "" { + continue + } + remoteURL = redactRemoteURL(remoteURL) + seen := false + for _, existing := range remotes[name] { + if existing == remoteURL { + seen = true + break + } + } + if !seen { + remotes[name] = append(remotes[name], remoteURL) + } + } + return remotes +} + +// redactRemoteURL removes credentials and credential-like query parameters. +// Invalid or suspicious input is replaced completely so snapshots fail +// closed rather than accidentally exposing secrets. +func redactRemoteURL(raw string) string { + if safe, handled := redactSCPRemote(raw); handled { + return safe + } + return redactParsedURL(raw) +} diff --git a/internal/cli/command/snapshot/command_test.go b/internal/cli/command/snapshot/command_test.go new file mode 100644 index 0000000..68bdc7a --- /dev/null +++ b/internal/cli/command/snapshot/command_test.go @@ -0,0 +1,274 @@ +package snapshot + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestParseRemotesRedactsCredentialsAndDeduplicates(t *testing.T) { + value := strings.Join([]string{ + "origin\thttps://alice:password@example.com/project.git (fetch)", + "origin\thttps://alice:password@example.com/project.git (push)", + "token\thttps://example.com/project.git?access_token=abc&mode=ro (fetch)", + "ssh\tgit@example.com:project.git (fetch)", + }, "\n") + want := map[string][]string{ + "origin": {"https://REDACTED:REDACTED@example.com/project.git"}, + "token": {"https://example.com/project.git?access_token=REDACTED&mode=ro"}, + "ssh": {"git@example.com:project.git"}, + } + got := parseRemotes(value) + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseRemotes = %#v, want %#v", got, want) + } + if strings.Contains(strings.Join(got["origin"], " "), "password") || strings.Contains(strings.Join(got["token"], " "), "abc") { + t.Fatal("remote credentials or token leaked") + } +} + +func TestParseRemotesPreservesSpacesInTabDelimitedURLs(t *testing.T) { + got := parseRemotes("origin\tfile:///tmp/project with spaces (fetch)\n") + want := map[string][]string{"origin": {"file:///tmp/project with spaces"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseRemotes = %#v, want %#v", got, want) + } +} + +func TestRedactRemoteURLFailsClosedForSuspiciousValues(t *testing.T) { + for _, input := range []string{ + "https://example.com/%zz?token=SENSITIVE", + "origin/path?token=SENSITIVE", + "alice:secret@github.com:org/repo.git", + } { + if got := redactRemoteURL(input); strings.Contains(got, "SENSITIVE") || strings.Contains(got, "secret") { + t.Errorf("redactRemoteURL(%q) = %q, leaked secret", input, got) + } + } +} + +func TestRedactRemoteURLHandlesQueryAndUserinfoSecretVariants(t *testing.T) { + for _, input := range []string{ + "https://example.test/repo?token=abc", + "https://example.test/repo?AUTHORIZATION=abc", + "https://token-value@example.test/repo", + "https://example.test/repo?X-Amz-Credential=abc", + "https://user:password@example.test/repo", + } { + got := redactRemoteURL(input) + if strings.Contains(got, "abc") || strings.Contains(got, "password") || strings.Contains(got, "token-value") { + t.Errorf("redacted URL %q still contains secret: %q", input, got) + } + } +} + +func TestRedactRemoteURLFailsClosedOnInvalidEscapesAndSchemeLessQueries(t *testing.T) { + invalid := redactRemoteURL("https://alice:SENSITIVE_VALUE@example.invalid/%zz") + if invalid == "https://alice:SENSITIVE_VALUE@example.invalid/%zz" || strings.Contains(invalid, "SENSITIVE_VALUE") || strings.Contains(invalid, "alice") { + t.Fatalf("invalid remote URL was not redacted: %q", invalid) + } + query := redactRemoteURL("origin/path?token=SENSITIVE_VALUE") + if strings.Contains(query, "SENSITIVE_VALUE") { + t.Fatalf("scheme-less remote query leaked secret: %q", query) + } +} + +func TestRedactRemoteURLPreservesSafeSCPRemotes(t *testing.T) { + const raw = "git@github.com:org/repo.git" + if got := redactRemoteURL(raw); got != raw { + t.Fatalf("safe SCP remote = %q, want %q", got, raw) + } + if got := redactRemoteURL("alice:secret@github.com:org/repo.git"); strings.Contains(got, "secret") { + t.Fatalf("SCP-like credential leaked: %q", got) + } +} + +func TestNewSnapshotEmptyJSONDoesNotRequireGit(t *testing.T) { + var out bytes.Buffer + application := app.App{ + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{}, nil + }, + } + command := New(application) + command.SetOut(&out) + command.SetArgs([]string{"--format", "json"}) + if err := command.Execute(); err != nil { + t.Fatalf("empty snapshot returned error: %v", err) + } + var got snapshotDocument + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("snapshot output is invalid JSON: %v", err) + } + if got.SchemaVersion != output.SchemaVersion || len(got.Repositories) != 0 { + t.Fatalf("snapshot document = %#v", got) + } +} + +func TestNewSnapshotRequireMatchUsesRuntimeExitCode(t *testing.T) { + application := app.App{Discover: func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{}, nil + }} + command := New(application) + command.SetArgs([]string{"--require-match"}) + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "no Git repositories matched") { + t.Fatalf("error = %v, want no-match error", err) + } +} + +func TestSnapshotRendersJSONAndNDJSONDiagnosticsAndRedactedRemotes(t *testing.T) { + application, fixture := snapshotFixtureApp(t, "clean") + application.Discover = func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{ + Repositories: fixture.Repositories, + Warnings: []repository.Warning{{Path: "/tmp/skipped", Message: "inaccessible"}}, + }, nil + } + jsonResult := runSnapshot(t, application, "--format", "json", "--include-remotes") + if jsonResult.err != nil { + t.Fatalf("snapshot JSON returned error: %v", jsonResult.err) + } + var document snapshotDocument + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("snapshot JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != output.SchemaVersion || len(document.Repositories) != 1 || len(document.Diagnostics) != 1 { + t.Fatalf("snapshot document = %#v", document) + } + record := document.Repositories[0] + if record.Head != "deadbeef" || record.Branch != "main" || record.Remotes["origin"][0] != "https://REDACTED:REDACTED@example.com/project.git" { + t.Fatalf("snapshot record = %#v", record) + } + if strings.Contains(jsonResult.stdout, "password") || strings.Contains(jsonResult.stdout, "abc") { + t.Fatalf("snapshot leaked remote credential: %q", jsonResult.stdout) + } + if !strings.Contains(jsonResult.stderr, "warning: /tmp/skipped: inaccessible") { + t.Fatalf("snapshot warning stderr = %q", jsonResult.stderr) + } + + ndjsonResult := runSnapshot(t, application, "--format", "ndjson", "--include-remotes") + if ndjsonResult.err != nil { + t.Fatalf("snapshot NDJSON returned error: %v", ndjsonResult.err) + } + lines := strings.Split(strings.TrimSpace(ndjsonResult.stdout), "\n") + if len(lines) != 2 || !strings.Contains(lines[0], `"type":"repository"`) || !strings.Contains(lines[1], `"type":"diagnostic"`) { + t.Fatalf("snapshot NDJSON = %q", ndjsonResult.stdout) + } +} + +func TestSnapshotReportsRepositoryFailuresWithStructuredOutput(t *testing.T) { + application, _ := snapshotFixtureApp(t, "bad-head", "remote-fail") + result := runSnapshot(t, application, "--format", "json", "--include-remotes") + if result.err == nil || !strings.Contains(result.err.Error(), "snapshot failed for 2 repositories") { + t.Fatalf("snapshot failure error = %v", result.err) + } + var document snapshotDocument + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("snapshot failure JSON decode failed: %v", err) + } + if len(document.Repositories) != 2 || document.Repositories[0].Error == "" || document.Repositories[1].Error == "" { + t.Fatalf("snapshot failure records = %#v", document.Repositories) + } +} + +func TestSnapshotWriterUsesPrivateModeAndAtomicInstall(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot.json") + writer, closeWriter, err := snapshotWriter(nil, path, false) + if err != nil { + t.Fatalf("snapshotWriter returned error: %v", err) + } + temporary := writer.(*snapshotTempFile) + temporaryPath := temporary.tempPath + if _, err := writer.Write([]byte("snapshot")); err != nil { + t.Fatalf("write snapshot: %v", err) + } + if err := closeWriter(); err != nil { + t.Fatalf("commit snapshot: %v", err) + } + if _, err := os.Stat(temporaryPath); !os.IsNotExist(err) { + t.Fatalf("temporary snapshot still exists: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat snapshot: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("snapshot mode = %#o, want 0600", got) + } +} + +func TestSnapshotWriterAbortsAndCleansTemporaryFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot.json") + writer, _, err := snapshotWriter(nil, path, false) + if err != nil { + t.Fatalf("snapshotWriter returned error: %v", err) + } + temporary := writer.(*snapshotTempFile) + temporaryPath := temporary.tempPath + if _, err := writer.Write([]byte("partial")); err != nil { + t.Fatalf("write partial snapshot: %v", err) + } + if err := temporary.Abort(); err != nil { + t.Fatalf("Abort returned error: %v", err) + } + if temporary.tempPath != "" { + t.Fatalf("temporary path after Abort = %q", temporary.tempPath) + } + if _, err := os.Stat(temporaryPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("temporary snapshot after Abort: %v", err) + } +} + +func TestSnapshotWriterRefusesSymlinkDestination(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires platform support") + } + directory := t.TempDir() + target := filepath.Join(directory, "target.json") + link := filepath.Join(directory, "snapshot.json") + if err := os.WriteFile(target, []byte("target"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatalf("create destination symlink: %v", err) + } + if _, _, err := snapshotWriter(nil, link, true); err == nil { + t.Fatal("snapshotWriter accepted a symlink destination") + } + contents, err := os.ReadFile(target) + if err != nil || string(contents) != "target" { + t.Fatalf("symlink target changed to %q, error %v", contents, err) + } +} + +func TestSnapshotOptionErrorsHaveExpectedExitCodes(t *testing.T) { + empty, _ := snapshotFixtureApp(t) + invalid := runSnapshot(t, empty, "--format", "invalid") + if invalid.err == nil || !strings.Contains(invalid.err.Error(), "unsupported snapshot format") { + t.Fatalf("invalid format error = %v", invalid.err) + } + if got := clierr.ExitCode(invalid.err); got != clierr.UsageExitCode { + t.Fatalf("invalid format exit code = %d, want %d", got, clierr.UsageExitCode) + } + + missing := runSnapshot(t, empty, "--require-match") + if missing.err == nil || !strings.Contains(missing.err.Error(), "no Git repositories matched") { + t.Fatalf("require-match error = %v", missing.err) + } + if got := clierr.ExitCode(missing.err); got != clierr.RuntimeExitCode { + t.Fatalf("require-match exit code = %d, want %d", got, clierr.RuntimeExitCode) + } +} diff --git a/internal/cli/command/snapshot/fixture_test.go b/internal/cli/command/snapshot/fixture_test.go new file mode 100644 index 0000000..d08f9c8 --- /dev/null +++ b/internal/cli/command/snapshot/fixture_test.go @@ -0,0 +1,94 @@ +package snapshot + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type snapshotResult struct { + stdout string + stderr string + err error +} + +func snapshotFixtureApp(t *testing.T, names ...string) (app.App, repository.Result) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake Git fixture uses a POSIX executable") + } + root := t.TempDir() + result := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create fixture repository %q: %v", name, err) + } + result.Repositories = append(result.Repositories, repository.Repository{Name: name, Path: path}) + } + script := filepath.Join(t.TempDir(), "fake-git") + const fakeGit = `#!/bin/sh +repo=${PWD##*/} +case "$1" in + rev-parse) + if [ "$repo" = "bad-head" ]; then + echo synthetic-head-failure >&2 + exit 8 + fi + printf 'deadbeef\n' + ;; + symbolic-ref) + if [ "$repo" = "detached" ]; then + exit 1 + fi + printf 'main\n' + ;; + remote) + if [ "$repo" = "remote-fail" ]; then + echo synthetic-remote-failure >&2 + exit 12 + fi + printf 'origin\thttps://alice:password@example.com/project.git (fetch)\n' + printf 'origin\thttps://alice:password@example.com/project.git (push)\n' + printf 'token\thttps://example.com/project.git?access_token=abc&mode=ro (fetch)\n' + printf 'ssh\tgit@example.com:project.git (fetch)\n' + ;; + *) + exit 0 + ;; +esac +` + if err := os.WriteFile(script, []byte(fakeGit), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + return app.App{ + Git: gitexec.Runner{Executable: script}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return result, nil + }, + }, result +} + +func runSnapshot(t *testing.T, application app.App, args ...string) snapshotResult { + t.Helper() + var stdout, stderr bytes.Buffer + application.In = bytes.NewBuffer(nil) + application.Out = &stdout + application.Err = &stderr + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetIn(application.In) + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.Execute() + return snapshotResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} diff --git a/internal/cli/command/snapshot/remotes.go b/internal/cli/command/snapshot/remotes.go new file mode 100644 index 0000000..a9e0b52 --- /dev/null +++ b/internal/cli/command/snapshot/remotes.go @@ -0,0 +1,79 @@ +package snapshot + +import ( + "net/url" + "strings" +) + +func redactParsedURL(raw string) string { + parsed, err := url.Parse(raw) + if err != nil { + return "REDACTED" + } + if parsed.Scheme == "" { + if strings.Contains(raw, "?") { + return "REDACTED" + } + return raw + } + if parsed.User != nil { + username := parsed.User.Username() + _, hasPassword := parsed.User.Password() + if parsed.Scheme == "http" || parsed.Scheme == "https" { + username = "REDACTED" + } + if hasPassword { + parsed.User = url.UserPassword(username, "REDACTED") + } else { + parsed.User = url.User(username) + } + } + query := parsed.Query() + if parsed.Scheme == "file" && parsed.User == nil && len(query) == 0 { + // Preserve local paths exactly as Git reported them; URL.String would + // percent-encode spaces in an otherwise non-sensitive path. + return raw + } + for key := range query { + normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_")) + if strings.Contains(normalized, "token") || + strings.Contains(normalized, "auth") || + strings.Contains(normalized, "password") || + strings.Contains(normalized, "passwd") || + strings.Contains(normalized, "secret") || + strings.Contains(normalized, "signature") || + strings.Contains(normalized, "credential") || + normalized == "key" || strings.HasSuffix(normalized, "_key") || normalized == "sig" { + query.Set(key, "REDACTED") + } + } + parsed.RawQuery = query.Encode() + return parsed.String() +} + +// redactSCPRemote handles Git's scp-like SSH syntax, which net/url rejects: +// user@host:path. There is no password field in this syntax, so an ordinary +// user/host/path remains useful. Suspicious userinfo or query material is +// replaced completely rather than returned unparsed. +func redactSCPRemote(raw string) (string, bool) { + if strings.Contains(raw, "://") { + return "", false + } + at := strings.IndexByte(raw, '@') + if at <= 0 || at == len(raw)-1 { + return "", false + } + colon := strings.IndexByte(raw[at+1:], ':') + if colon < 1 { + return "", false + } + user := raw[:at] + hostPath := raw[at+1:] + if strings.ContainsAny(user, "/?#") { + return "", false + } + if strings.Contains(user, ":") || strings.Contains(hostPath, "?") { + return "REDACTED", true + } + return raw, true +} diff --git a/internal/cli/command/snapshot/writer.go b/internal/cli/command/snapshot/writer.go new file mode 100644 index 0000000..b35bb69 --- /dev/null +++ b/internal/cli/command/snapshot/writer.go @@ -0,0 +1,124 @@ +package snapshot + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +type snapshotTempFile struct { + file *os.File + tempPath string + destination string + force bool +} + +func (writer *snapshotTempFile) Write(data []byte) (int, error) { + return writer.file.Write(data) +} + +func (writer *snapshotTempFile) Commit() error { + if writer.file == nil { + return nil + } + if err := writer.file.Sync(); err != nil { + return writer.fail(err) + } + if err := writer.file.Close(); err != nil { + return writer.fail(err) + } + writer.file = nil + + if err := validateSnapshotDestination(writer.destination, writer.force); err != nil { + return writer.fail(err) + } + if writer.force { + if err := os.Rename(writer.tempPath, writer.destination); err != nil { + return writer.fail(err) + } + writer.tempPath = "" + } else { + // A hard link is an atomic, no-overwrite install on the same + // filesystem. Unlike rename, it cannot replace a destination that + // appeared after the initial validation. + if err := os.Link(writer.tempPath, writer.destination); err != nil { + return writer.fail(err) + } + tempPath := writer.tempPath + if err := os.Remove(tempPath); err != nil { + // The destination is already installed. Retry cleanup, but never + // remove the successfully installed destination if cleanup fails. + cleanupErr := writer.Abort() + if cleanupErr != nil { + return fmt.Errorf("remove temporary snapshot %s after install: %w (cleanup: %v)", tempPath, err, cleanupErr) + } + return fmt.Errorf("remove temporary snapshot %s after install: %w", tempPath, err) + } + writer.tempPath = "" + } + return nil +} + +func (writer *snapshotTempFile) Abort() error { + if writer.file != nil { + _ = writer.file.Close() + writer.file = nil + } + if writer.tempPath == "" { + return nil + } + err := os.Remove(writer.tempPath) + writer.tempPath = "" + if os.IsNotExist(err) { + return nil + } + return err +} + +func (writer *snapshotTempFile) fail(err error) error { + _ = writer.Abort() + return err +} + +func validateSnapshotDestination(path string, force bool) error { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("inspect snapshot output %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing symlink snapshot output %s", path) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("snapshot output %s is not a regular file", path) + } + if !force { + return fmt.Errorf("snapshot output %s already exists", path) + } + return nil +} + +func snapshotWriter(stdout io.Writer, path string, force bool) (io.Writer, func() error, error) { + if path == "" { + return stdout, nil, nil + } + if err := validateSnapshotDestination(path, force); err != nil { + return nil, nil, err + } + directory := filepath.Dir(path) + base := filepath.Base(path) + file, err := os.CreateTemp(directory, "."+base+".tmp-*") + if err != nil { + return nil, nil, fmt.Errorf("create temporary snapshot in %s: %w", directory, err) + } + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + _ = os.Remove(file.Name()) + return nil, nil, fmt.Errorf("secure temporary snapshot %s: %w", file.Name(), err) + } + temporary := &snapshotTempFile{file: file, tempPath: file.Name(), destination: path, force: force} + return temporary, temporary.Commit, nil +} diff --git a/cmd/status.go b/internal/cli/command/status/command.go similarity index 57% rename from cmd/status.go rename to internal/cli/command/status/command.go index 23201ef..e534049 100644 --- a/cmd/status.go +++ b/internal/cli/command/status/command.go @@ -1,33 +1,37 @@ -package cmd +// Package status implements the status command. +package status import ( - "context" "encoding/json" "fmt" - "sync" + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/cli/scanflags" + "github.com/ramonzx6/gitmulti/internal/cli/statusset" "github.com/ramonzx6/gitmulti/internal/gitstatus" "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -const outputSchemaVersion = 1 - -type statusDocument struct { +type document struct { SchemaVersion int `json:"schemaVersion"` Repositories []gitstatus.State `json:"repositories"` Diagnostics []repository.Warning `json:"diagnostics,omitempty"` } -type statusLine struct { +type line struct { Type string `json:"type"` Status *gitstatus.State `json:"status,omitempty"` Path string `json:"path,omitempty"` Message string `json:"message,omitempty"` } -func newStatusCommand(app App) *cobra.Command { - var scan scanFlags +// New constructs a fresh status command and its private flag state. +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags var dirtyOnly bool var format string var noAheadBehind bool @@ -37,22 +41,22 @@ func newStatusCommand(app App) *cobra.Command { command := &cobra.Command{ Use: "status", Short: "Show an aggregate repository status dashboard", - Args: noArgs, + Args: clierr.NoArgs, RunE: func(command *cobra.Command, _ []string) error { if jobs < 1 { - return errorsForFlag("--jobs must be at least 1") + return clierr.Usage("--jobs must be at least 1") } if format != "table" && format != "json" && format != "ndjson" { - return errorsForFlag(fmt.Sprintf("unsupported status format %q", format)) + return clierr.Usage("unsupported status format %q", format) } - discovered, err := discoverRepositories(command.Context(), app, command, scan) + discovered, err := scan.Discover(command.Context(), application, command) if err != nil { return err } if len(discovered.Repositories) == 0 && requireMatch { - return newExitError(1, "no Git repositories matched") + return clierr.Runtime("no Git repositories matched") } - states := collectStatuses(command.Context(), app, discovered.Repositories, jobs, noAheadBehind) + states := statusset.Collect(command.Context(), application, discovered.Repositories, jobs, noAheadBehind) if dirtyOnly { filtered := states[:0] for _, state := range states { @@ -62,17 +66,16 @@ func newStatusCommand(app App) *cobra.Command { } states = filtered } - if err := renderStatuses(command, states, discovered.Warnings, format, noAheadBehind); err != nil { + if err := render(command, states, discovered.Warnings, format, noAheadBehind); err != nil { return err } - failures := statusErrorCount(states) - if failures > 0 { - return newExitError(1, "status collection failed for %d repositories", failures) + if failures := statusset.ErrorCount(states); failures > 0 { + return clierr.Runtime("status collection failed for %d repositories", failures) } return nil }, } - scan.bind(command) + scan.Bind(command) command.Flags().BoolVar(&dirtyOnly, "dirty-only", false, "show only dirty repositories") command.Flags().StringVar(&format, "format", "table", "output format: table, json, or ndjson") command.Flags().BoolVar(&noAheadBehind, "no-ahead-behind", false, "hide ahead and behind counts") @@ -81,68 +84,21 @@ func newStatusCommand(app App) *cobra.Command { return command } -func collectStatuses(ctx context.Context, app App, repositories []repository.Repository, jobs int, noAheadBehind ...bool) []gitstatus.State { - states := make([]gitstatus.State, len(repositories)) - if len(repositories) == 0 { - return states - } - workerCount := jobs - if workerCount < 1 { - workerCount = 1 - } - if workerCount > len(repositories) { - workerCount = len(repositories) - } - indices := make(chan int) - collector := gitstatus.NewCollector(app.Git) - options := gitstatus.CollectOptions{} - if len(noAheadBehind) > 0 && noAheadBehind[0] { - options.NoAheadBehind = true - } - var wait sync.WaitGroup - for worker := 0; worker < workerCount; worker++ { - wait.Add(1) - go func() { - defer wait.Done() - for index := range indices { - repo := repositories[index] - states[index] = collector.CollectWithOptions(ctx, repo.Name, repo.Path, options) - } - }() - } - for index := range repositories { - indices <- index - } - close(indices) - wait.Wait() - return states -} - -func statusErrorCount(states []gitstatus.State) int { - count := 0 - for _, state := range states { - if state.Err != nil { - count++ - } - } - return count -} - -func renderStatuses(command *cobra.Command, states []gitstatus.State, warnings []repository.Warning, format string, hideAheadBehind bool) error { +func render(command *cobra.Command, states []gitstatus.State, warnings []repository.Warning, format string, hideAheadBehind bool) error { switch format { case "json": encoder := json.NewEncoder(command.OutOrStdout()) encoder.SetIndent("", " ") - return encoder.Encode(statusDocument{SchemaVersion: outputSchemaVersion, Repositories: states, Diagnostics: warnings}) + return encoder.Encode(document{SchemaVersion: output.SchemaVersion, Repositories: states, Diagnostics: warnings}) case "ndjson": encoder := json.NewEncoder(command.OutOrStdout()) for index := range states { - if err := encoder.Encode(statusLine{Type: "status", Status: &states[index]}); err != nil { + if err := encoder.Encode(line{Type: "status", Status: &states[index]}); err != nil { return err } } for _, warning := range warnings { - if err := encoder.Encode(statusLine{Type: "diagnostic", Path: warning.Path, Message: warning.Message}); err != nil { + if err := encoder.Encode(line{Type: "diagnostic", Path: warning.Path, Message: warning.Message}); err != nil { return err } } @@ -194,6 +150,6 @@ func renderStatuses(command *cobra.Command, states []gitstatus.State, warnings [ } return nil default: - return errorsForFlag(fmt.Sprintf("unsupported status format %q", format)) + return clierr.Usage("unsupported status format %q", format) } } diff --git a/internal/cli/command/status/command_test.go b/internal/cli/command/status/command_test.go new file mode 100644 index 0000000..d2519e9 --- /dev/null +++ b/internal/cli/command/status/command_test.go @@ -0,0 +1,211 @@ +package status + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type statusResult struct { + stdout string + stderr string + err error +} + +func runStatus(t *testing.T, application app.App, args ...string) statusResult { + t.Helper() + var stdout, stderr bytes.Buffer + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetIn(bytes.NewReader(nil)) + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.Execute() + return statusResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func statusFixture(root string, names ...string) repository.Result { + result := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + panic(err) + } + result.Repositories = append(result.Repositories, repository.Repository{Name: name, Path: path}) + } + return result +} + +func fakeStatusApp(t *testing.T, names ...string) (app.App, repository.Result) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("deterministic fake Git fixture uses a POSIX executable") + } + root := t.TempDir() + fixture := statusFixture(root, names...) + return app.App{ + In: bytes.NewReader(nil), + Git: gitexec.Runner{Executable: writeStatusGit(t)}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return fixture, nil + }, + }, fixture +} + +func TestStatusRunERendersTableJSONNDJSONAndDirtyOnly(t *testing.T) { + application, _ := fakeStatusApp(t, "clean", "dirty") + + table := runStatus(t, application, "--format", "table", "--jobs", "1") + if table.err != nil { + t.Fatalf("status table returned error: %v", table.err) + } + for _, want := range []string{ + "REPOSITORY\tBRANCH\tSTATE\tUPSTREAM\tAHEAD\tBEHIND", + "clean\tmain\tclean\torigin/main\t0\t0", + "dirty\tmain\tdirty\torigin/main\t0\t0", + } { + if !strings.Contains(table.stdout, want) { + t.Fatalf("table output lacks %q: %q", want, table.stdout) + } + } + + dirty := runStatus(t, application, "--dirty-only", "--format", "table") + if dirty.err != nil || strings.Contains(dirty.stdout, "clean\tmain") || !strings.Contains(dirty.stdout, "dirty\tmain\tdirty") { + t.Fatalf("dirty-only result = %#v", dirty) + } + + jsonResult := runStatus(t, application, "--format", "json") + if jsonResult.err != nil { + t.Fatalf("status JSON returned error: %v", jsonResult.err) + } + var document document + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("status JSON decode failed: %v\n%s", err, jsonResult.stdout) + } + if document.SchemaVersion != output.SchemaVersion || len(document.Repositories) != 2 { + t.Fatalf("status document = %#v", document) + } + + ndjson := runStatus(t, application, "--format", "ndjson") + if ndjson.err != nil { + t.Fatalf("status NDJSON returned error: %v", ndjson.err) + } + lines := strings.Split(strings.TrimSpace(ndjson.stdout), "\n") + if len(lines) != 2 { + t.Fatalf("NDJSON lines = %d, want 2", len(lines)) + } + var line line + if err := json.Unmarshal([]byte(lines[0]), &line); err != nil || line.Type != "status" || line.Status == nil { + t.Fatalf("status NDJSON line = %#v, error = %v", lines[0], err) + } +} + +func TestStatusNoAheadBehindRequestsUnknownCountsAndPreservesTableSchema(t *testing.T) { + application, _ := fakeStatusApp(t, "unknown-noahead") + jsonResult := runStatus(t, application, "--no-ahead-behind", "--format", "json") + if jsonResult.err != nil { + t.Fatalf("no-ahead-behind returned error: %v", jsonResult.err) + } + var document document + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil { + t.Fatalf("status JSON decode failed: %v", err) + } + if len(document.Repositories) != 1 || document.Repositories[0].AheadBehindKnown || document.Repositories[0].Ahead != 0 || document.Repositories[0].Behind != 0 { + t.Fatalf("unknown ahead/behind state = %#v", document.Repositories) + } + + table := runStatus(t, application, "--no-ahead-behind", "--format", "table") + if table.err != nil || table.stdout != "REPOSITORY\tBRANCH\tSTATE\tUPSTREAM\nunknown-noahead\tmain\tclean\torigin/main\n" { + t.Fatalf("no-ahead-behind table = %#v", table) + } +} + +func TestStatusRunEReportsCollectionErrorsAndDiagnostics(t *testing.T) { + application, _ := fakeStatusApp(t, "error") + result := runStatus(t, application, "--format", "table") + if result.err == nil || clierr.ExitCode(result.err) != clierr.RuntimeExitCode || !strings.Contains(result.err.Error(), "status collection failed") { + t.Fatalf("status error = %v", result.err) + } + if !strings.Contains(result.stdout, "error\t-\terror") || !strings.Contains(result.stderr, "error:") { + t.Fatalf("status collection output stdout=%q stderr=%q", result.stdout, result.stderr) + } + + application, _ = fakeStatusApp(t, "clean") + baseDiscover := application.Discover + application.Discover = func(ctx context.Context, options repository.Options) (repository.Result, error) { + result, err := baseDiscover(ctx, options) + result.Warnings = []repository.Warning{{Path: "/tmp/diagnostic", Message: "skipped entry"}} + return result, err + } + jsonResult := runStatus(t, application, "--format", "json") + if jsonResult.err != nil { + t.Fatalf("diagnostic status returned error: %v", jsonResult.err) + } + var document document + if err := json.Unmarshal([]byte(jsonResult.stdout), &document); err != nil || len(document.Diagnostics) != 1 { + t.Fatalf("status diagnostics document = %#v, error = %v", document, err) + } +} + +func TestStatusRunEValidatesFlagsAndFreshState(t *testing.T) { + application, _ := fakeStatusApp(t, "clean") + for _, args := range [][]string{{"--jobs", "0"}, {"--format", "invalid"}, {"unexpected"}} { + result := runStatus(t, application, args...) + if result.err == nil || clierr.ExitCode(result.err) != clierr.UsageExitCode { + t.Errorf("args %v error = %v, want usage exit code", args, result.err) + } + } + + var roots []string + application = app.App{Discover: func(_ context.Context, options repository.Options) (repository.Result, error) { + roots = append(roots, options.Root) + return repository.Result{}, nil + }} + first := runStatus(t, application, "--path", "/first") + second := runStatus(t, application) + if first.err != nil || second.err != nil || len(roots) != 2 || roots[0] != "/first" || roots[1] != "." { + t.Fatalf("fresh command roots = %#v, first=%v second=%v", roots, first.err, second.err) + } +} + +func writeStatusGit(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "fake-git") + script := `#!/bin/sh +repo=${PWD##*/} +if [ "$1" = "--version" ]; then echo "git version fake-1.0"; exit 0; fi +if [ "$1" = "-c" ] && [ "$2" = "core.fsmonitor=false" ]; then shift 2; fi +if [ "$1" = "--no-optional-locks" ]; then + case "$repo" in + clean) printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' ;; + dirty) printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' '1 .M N... 100644 100644 100644 abc abc changed' '? untracked' ;; + unknown-noahead) if [ "$5" != "--no-ahead-behind" ]; then echo missing-no-ahead >&2; exit 11; fi; printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' ;; + error) echo 'synthetic status failure' >&2; exit 9 ;; + *) echo "unknown fake repository $repo" >&2; exit 10 ;; + esac + exit 0 +fi +exit 0 +` + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + if err := os.Chmod(path, 0o700); err != nil { + t.Fatalf("chmod fake Git: %v", err) + } + return path +} diff --git a/cmd/sync.go b/internal/cli/command/sync/command.go similarity index 52% rename from cmd/sync.go rename to internal/cli/command/sync/command.go index 7022574..2da85c5 100644 --- a/cmd/sync.go +++ b/internal/cli/command/sync/command.go @@ -1,16 +1,24 @@ -package cmd +// Package sync implements safe fetch and fast-forward synchronization across +// discovered repositories. +package sync import ( "fmt" "strings" "time" - "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/execution" + "github.com/ramonzx6/gitmulti/internal/cli/scanflags" "github.com/spf13/cobra" ) -func newSyncCommand(app App) *cobra.Command { - var scan scanFlags +// New constructs the sync command using application as its process and +// discovery boundary. +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags var mode string var remote string var prune bool @@ -26,45 +34,47 @@ func newSyncCommand(app App) *cobra.Command { command := &cobra.Command{ Use: "sync", Short: "Safely fetch or fast-forward repositories", - Args: noArgs, + Args: clierr.NoArgs, RunE: func(command *cobra.Command, _ []string) error { if mode != "fetch" && mode != "ff-only" { - return errorsForFlag(fmt.Sprintf("unsupported sync mode %q", mode)) + return clierr.Usage("unsupported sync mode %q", mode) } if jobs < 1 { - return errorsForFlag("--jobs must be at least 1") + return clierr.Usage("--jobs must be at least 1") } if timeout < 0 { - return errorsForFlag("--timeout cannot be negative") + return clierr.Usage("--timeout cannot be negative") } if strings.HasPrefix(remote, "-") { - return errorsForFlag("--remote cannot begin with '-'") + return clierr.Usage("--remote cannot begin with '-'") } - runApp := app + + runApp := application if noPrompt { runApp.Git = runApp.Git.WithEnv("GIT_TERMINAL_PROMPT=0") } - if !validExecOutput(output) { - return errorsForFlag(fmt.Sprintf("unsupported output mode %q", output)) + if !execution.ValidOutput(output) { + return clierr.Usage("unsupported output mode %q", output) } if output == "raw" && jobs != 1 { - return errorsForFlag("raw output requires --jobs=1") + return clierr.Usage("raw output requires --jobs=1") } - discovered, err := discoverRepositories(command.Context(), app, command, scan) + discovered, err := scan.Discover(command.Context(), application, command) if err != nil { return err } if len(discovered.Repositories) == 0 { if requireMatch { - return newExitError(1, "no Git repositories matched") + return clierr.Runtime("no Git repositories matched") } return nil } + args := syncArgs(mode, remote, prune, tags) if dryRun { for _, repo := range discovered.Repositories { - if _, err := fmt.Fprintln(command.OutOrStdout(), displayCommand(repo.Path, args)); err != nil { + if _, err := fmt.Fprintln(command.OutOrStdout(), execution.DisplayCommand(repo.Path, args)); err != nil { return err } } @@ -74,13 +84,13 @@ func newSyncCommand(app App) *cobra.Command { return err } - var records []execRecord + var records []execution.Record if mode == "ff-only" { records = executeSafeFastForward(command, runApp, discovered.Repositories, args, jobs, timeout, failFast, output) } else { - records = executeAcross(command, runApp, discovered.Repositories, args, jobs, timeout, failFast, output, false) + records = execution.ExecuteAcross(command, runApp, discovered.Repositories, args, jobs, timeout, failFast, output, false) } - if err := renderExecRecords(command, records, output, false, discovered.Warnings); err != nil { + if err := execution.Render(command, records, output, false, discovered.Warnings); err != nil { return err } if output == "raw" { @@ -92,22 +102,14 @@ func newSyncCommand(app App) *cobra.Command { } } } - failures := 0 - skipped := 0 - for _, record := range records { - if record.Skipped { - skipped++ - } else if record.Error != "" { - failures++ - } - } + failures, skipped := countFailures(records) if failures > 0 || skipped > 0 { - return newExitError(1, "sync failed or was unsafe in %d and skipped %d of %d repositories", failures, skipped, len(records)) + return clierr.Runtime("sync failed or was unsafe in %d and skipped %d of %d repositories", failures, skipped, len(records)) } return nil }, } - scan.bind(command) + scan.Bind(command) command.Flags().StringVar(&mode, "mode", "fetch", "sync mode: fetch or ff-only") command.Flags().StringVar(&remote, "remote", "", "specific remote to use; fetch mode defaults to --all") command.Flags().BoolVar(&prune, "prune", true, "prune deleted remote-tracking references") @@ -122,6 +124,8 @@ func newSyncCommand(app App) *cobra.Command { return command } +// syncArgs builds the direct Git argument list for a sync mode. The returned +// arguments are safe to pass to gitexec without shell interpretation. func syncArgs(mode, remote string, prune, tags bool) []string { args := []string{} if mode == "fetch" { @@ -152,48 +156,13 @@ func syncArgs(mode, remote string, prune, tags bool) []string { return args } -func executeSafeFastForward(command *cobra.Command, app App, repositories []repository.Repository, args []string, jobs int, timeout time.Duration, failFast bool, output string) []execRecord { - states := collectStatuses(command.Context(), app, repositories, jobs) - records := make([]execRecord, len(repositories)) - safe := make([]repository.Repository, 0, len(repositories)) - indices := make([]int, 0, len(repositories)) - preflightFailure := false - for index, state := range states { - records[index] = execRecord{Repository: repositories[index].Name, Path: repositories[index].Path, Command: append([]string(nil), args...), ExitCode: -1} - reason := "" - switch { - case state.Err != nil: - reason = state.Err.Error() - case state.Dirty: - reason = "working tree is dirty" - case state.Detached: - reason = "HEAD is detached" - case state.Unborn: - reason = "branch has no commits" - case state.NoUpstream: - reason = "branch has no upstream" +func countFailures(records []execution.Record) (failures, skipped int) { + for _, record := range records { + if record.Skipped { + skipped++ + } else if record.Error != "" { + failures++ } - if reason != "" { - records[index].Error = "refusing ff-only sync: " + reason - if failFast { - preflightFailure = true - } - continue - } - if preflightFailure { - records[index].Error = "not started after fail-fast" - records[index].Skipped = true - continue - } - safe = append(safe, repositories[index]) - indices = append(indices, index) - } - if len(safe) == 0 { - return records - } - executed := executeAcross(command, app, safe, args, jobs, timeout, failFast, output, false) - for index, result := range executed { - records[indices[index]] = result } - return records + return failures, skipped } diff --git a/internal/cli/command/sync/command_test.go b/internal/cli/command/sync/command_test.go new file mode 100644 index 0000000..08cd9b0 --- /dev/null +++ b/internal/cli/command/sync/command_test.go @@ -0,0 +1,212 @@ +package sync + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/execution" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestSyncArgsBuildsFetchAndFastForwardCommands(t *testing.T) { + tests := []struct { + name string + mode string + remote string + prune bool + tags bool + want []string + }{ + {name: "all fetch", mode: "fetch", prune: true, want: []string{"fetch", "--prune", "--all"}}, + {name: "remote fetch with tags", mode: "fetch", remote: "origin", prune: false, tags: true, want: []string{"fetch", "--tags", "origin"}}, + {name: "ff only", mode: "ff-only", prune: true, want: []string{"pull", "--ff-only", "--prune"}}, + {name: "ff only remote tags", mode: "ff-only", remote: "upstream", prune: false, tags: true, want: []string{"pull", "--ff-only", "--tags", "upstream"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := syncArgs(test.mode, test.remote, test.prune, test.tags); !reflect.DeepEqual(got, test.want) { + t.Fatalf("syncArgs = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestNewSyncDryRunBuildsCommandWithoutGitPreflight(t *testing.T) { + var out bytes.Buffer + application := app.App{ + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{Repositories: []repository.Repository{{Name: "repo", Path: "/tmp/repo"}}}, nil + }, + } + command := New(application) + command.SetOut(&out) + command.SetArgs([]string{"--dry-run", "--mode", "fetch"}) + if err := command.Execute(); err != nil { + t.Fatalf("dry-run returned error: %v", err) + } + if got := out.String(); !strings.Contains(got, `"fetch" "--prune" "--all"`) { + t.Fatalf("dry-run output = %q", got) + } +} + +func TestNewSyncRejectsInvalidModeAsUsageError(t *testing.T) { + command := New(app.App{}) + command.SetArgs([]string{"--mode", "merge", "--dry-run"}) + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "unsupported sync mode") { + t.Fatalf("error = %v, want invalid mode", err) + } + if got := clierr.ExitCode(err); got != clierr.UsageExitCode { + t.Fatalf("exit code = %d, want %d", got, clierr.UsageExitCode) + } +} + +func TestSyncDryRunDoesNotResolveGitAndRejectsRemoteInjection(t *testing.T) { + application, _ := syncFixtureApp(t, "repo") + application.Git.Executable = "definitely-not-installed-gitmulti-sync-test" + result := runSync(t, application, "--dry-run", "--mode", "fetch") + if result.err != nil || !strings.Contains(result.stdout, `"fetch" "--prune" "--all"`) { + t.Fatalf("sync dry-run result = %#v", result) + } + + result = runSync(t, application, "--dry-run", "--remote", "--all") + if result.err == nil || !strings.Contains(result.err.Error(), "--remote cannot begin") { + t.Fatalf("remote injection error = %v", result.err) + } + if got := clierr.ExitCode(result.err); got != clierr.UsageExitCode { + t.Fatalf("remote injection exit code = %d, want %d", got, clierr.UsageExitCode) + } +} + +func TestSyncFetchRendersStructuredOutputAndDiagnostics(t *testing.T) { + application, fixture := syncFixtureApp(t, "clean") + application.Discover = func(context.Context, repository.Options) (repository.Result, error) { + return repository.Result{ + Repositories: fixture.Repositories, + Warnings: []repository.Warning{{Path: "/tmp/skipped", Message: "inaccessible"}}, + }, nil + } + result := runSync(t, application, "--mode", "fetch", "--output", "json") + if result.err != nil { + t.Fatalf("sync JSON returned error: %v", result.err) + } + var document execution.Document + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("sync JSON decode failed: %v\n%s", err, result.stdout) + } + if len(document.Results) != 1 || document.Results[0].Command[0] != "fetch" || len(document.Diagnostics) != 1 { + t.Fatalf("sync document = %#v", document) + } + if !strings.Contains(result.stderr, "warning: /tmp/skipped: inaccessible") { + t.Fatalf("sync warning stderr = %q", result.stderr) + } + + prefixed := runSync(t, application, "--mode", "fetch", "--output", "prefixed") + if prefixed.err != nil || !strings.Contains(prefixed.stdout, "[clean] synced-clean") { + t.Fatalf("sync prefixed result = %#v", prefixed) + } + + ndjson := runSync(t, application, "--mode", "fetch", "--output", "ndjson") + if ndjson.err != nil { + t.Fatalf("sync NDJSON returned error: %v", ndjson.err) + } + if lines := strings.Split(strings.TrimSpace(ndjson.stdout), "\n"); len(lines) != 2 || !strings.Contains(lines[1], `"type":"diagnostic"`) { + t.Fatalf("sync NDJSON = %q", ndjson.stdout) + } +} + +func TestSyncFastForwardRefusesUnsafeRepositories(t *testing.T) { + application, fixture := syncFixtureApp(t, "dirty", "detached", "noupstream", "unborn", "clean") + result := runSync(t, application, "--mode", "ff-only", "--output", "json", "--jobs", "2") + if result.err == nil || !strings.Contains(result.err.Error(), "unsafe") { + t.Fatalf("ff-only error = %v", result.err) + } + var document execution.Document + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("ff-only JSON decode failed: %v\n%s", err, result.stdout) + } + if len(document.Results) != len(fixture.Repositories) { + t.Fatalf("ff-only records = %d, want %d", len(document.Results), len(fixture.Repositories)) + } + refused := 0 + for _, record := range document.Results { + if strings.Contains(record.Error, "refusing ff-only sync") { + refused++ + } + } + if refused != 4 { + t.Fatalf("ff-only refused %d repositories: %#v", refused, document.Results) + } +} + +func TestSyncRawFailFastReportsRefusalAndSkipsEligibleRepository(t *testing.T) { + application, _ := syncFixtureApp(t, "dirty", "clean") + result := runSync(t, application, "--mode", "ff-only", "--output", "raw", "--fail-fast") + if result.err == nil || !strings.Contains(result.err.Error(), "unsafe") { + t.Fatalf("raw ff-only error = %v", result.err) + } + if !strings.Contains(result.stderr, "dirty: refusing ff-only sync") || !strings.Contains(result.stderr, "clean:") { + t.Fatalf("raw ff-only diagnostics = %q", result.stderr) + } + if strings.Contains(result.stdout, "synced-clean") { + t.Fatalf("fail-fast executed clean repository: %q", result.stdout) + } +} + +func TestSyncNoPromptSetsGitTerminalPromptAndPropagatesFetchFailures(t *testing.T) { + application, _ := syncFixtureApp(t, "prompt-check") + result := runSync(t, application, "--no-prompt", "--mode", "fetch") + if result.err != nil { + t.Fatalf("sync --no-prompt returned error: %v\nstdout=%q\nstderr=%q", result.err, result.stdout, result.stderr) + } + + application, _ = syncFixtureApp(t, "fetch-fail") + result = runSync(t, application, "--mode", "fetch", "--output", "json") + if result.err == nil || !strings.Contains(result.err.Error(), "sync failed") { + t.Fatalf("fetch failure error = %v", result.err) + } + var document execution.Document + if err := json.Unmarshal([]byte(result.stdout), &document); err != nil { + t.Fatalf("fetch failure JSON decode failed: %v", err) + } + if len(document.Results) != 1 || document.Results[0].Error == "" { + t.Fatalf("fetch failure record = %#v", document.Results) + } +} + +func TestSyncOptionValidationAndRequireMatch(t *testing.T) { + application, _ := syncFixtureApp(t, "repo") + for _, test := range []struct { + args []string + want string + }{ + {args: []string{"--mode", "merge", "--dry-run"}, want: "unsupported sync mode"}, + {args: []string{"--output", "invalid", "--dry-run"}, want: "unsupported output mode"}, + {args: []string{"--jobs", "0", "--dry-run"}, want: "--jobs must be at least 1"}, + {args: []string{"--timeout=-1s", "--dry-run"}, want: "--timeout cannot be negative"}, + {args: []string{"--output", "raw", "--jobs", "2", "--dry-run"}, want: "raw output requires --jobs=1"}, + } { + result := runSync(t, application, test.args...) + if result.err == nil || !strings.Contains(result.err.Error(), test.want) { + t.Errorf("args %v error = %v, want %q", test.args, result.err, test.want) + } + if got := clierr.ExitCode(result.err); got != clierr.UsageExitCode { + t.Errorf("args %v exit code = %d, want %d", test.args, got, clierr.UsageExitCode) + } + } + + empty, _ := syncFixtureApp(t) + result := runSync(t, empty, "--dry-run", "--require-match") + if result.err == nil || !strings.Contains(result.err.Error(), "no Git repositories matched") { + t.Fatalf("require-match error = %v", result.err) + } + if got := clierr.ExitCode(result.err); got != clierr.RuntimeExitCode { + t.Fatalf("require-match exit code = %d, want %d", got, clierr.RuntimeExitCode) + } +} diff --git a/internal/cli/command/sync/fixture_test.go b/internal/cli/command/sync/fixture_test.go new file mode 100644 index 0000000..bae474a --- /dev/null +++ b/internal/cli/command/sync/fixture_test.go @@ -0,0 +1,109 @@ +package sync + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +type syncResult struct { + stdout string + stderr string + err error +} + +func syncFixtureApp(t *testing.T, names ...string) (app.App, repository.Result) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake Git fixture uses a POSIX executable") + } + root := t.TempDir() + result := repository.Result{Repositories: make([]repository.Repository, 0, len(names))} + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create fixture repository %q: %v", name, err) + } + result.Repositories = append(result.Repositories, repository.Repository{Name: name, Path: path}) + } + script := filepath.Join(t.TempDir(), "fake-git") + const fakeGit = `#!/bin/sh +repo=${PWD##*/} +if [ "$1" = "-c" ] && [ "$2" = "core.fsmonitor=false" ]; then + shift 2 +fi +if [ "$1" = "--no-optional-locks" ]; then + case "$repo" in + dirty) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' '1 .M N... 100644 100644 100644 abc abc changed' + ;; + detached) + printf '%s\0' '# branch.oid abc' '# branch.head (detached)' + ;; + noupstream) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.ab +0 -0' + ;; + unborn) + printf '%s\0' '# branch.oid (initial)' '# branch.head main' + ;; + status-error) + echo synthetic-status-failure >&2 + exit 9 + ;; + *) + printf '%s\0' '# branch.oid abc' '# branch.head main' '# branch.upstream origin/main' '# branch.ab +0 -0' + ;; + esac + exit 0 +fi +case "$1" in + fetch|pull) + if [ "$repo" = "fetch-fail" ]; then + echo synthetic-fetch-failure >&2 + exit 13 + fi + if [ "$repo" = "prompt-check" ] && [ "${GIT_TERMINAL_PROMPT:-}" != "0" ]; then + echo credential-prompt-was-not-disabled >&2 + exit 12 + fi + printf 'synced-%s\n' "$repo" + ;; + *) + printf 'out-%s\n' "$repo" + ;; +esac +` + if err := os.WriteFile(script, []byte(fakeGit), 0o700); err != nil { + t.Fatalf("write fake Git: %v", err) + } + return app.App{ + Git: gitexec.Runner{Executable: script}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return result, nil + }, + }, result +} + +func runSync(t *testing.T, application app.App, args ...string) syncResult { + t.Helper() + var stdout, stderr bytes.Buffer + application.In = bytes.NewBuffer(nil) + application.Out = &stdout + application.Err = &stderr + command := New(application) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetIn(application.In) + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.Execute() + return syncResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} diff --git a/internal/cli/command/sync/safety.go b/internal/cli/command/sync/safety.go new file mode 100644 index 0000000..ba3329e --- /dev/null +++ b/internal/cli/command/sync/safety.go @@ -0,0 +1,62 @@ +package sync + +import ( + "time" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/execution" + "github.com/ramonzx6/gitmulti/internal/cli/statusset" + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +func executeSafeFastForward(command *cobra.Command, application app.App, repositories []repository.Repository, args []string, jobs int, timeout time.Duration, failFast bool, output string) []execution.Record { + states := statusset.Collect(command.Context(), application, repositories, jobs) + records := make([]execution.Record, len(repositories)) + safe := make([]repository.Repository, 0, len(repositories)) + indices := make([]int, 0, len(repositories)) + preflightFailure := false + for index, state := range states { + records[index] = execution.Record{ + Repository: repositories[index].Name, + Path: repositories[index].Path, + Command: append([]string(nil), args...), + ExitCode: -1, + } + reason := "" + switch { + case state.Err != nil: + reason = state.Err.Error() + case state.Dirty: + reason = "working tree is dirty" + case state.Detached: + reason = "HEAD is detached" + case state.Unborn: + reason = "branch has no commits" + case state.NoUpstream: + reason = "branch has no upstream" + } + if reason != "" { + records[index].Error = "refusing ff-only sync: " + reason + if failFast { + preflightFailure = true + } + continue + } + if preflightFailure { + records[index].Error = "not started after fail-fast" + records[index].Skipped = true + continue + } + safe = append(safe, repositories[index]) + indices = append(indices, index) + } + if len(safe) == 0 { + return records + } + executed := execution.ExecuteAcross(command, application, safe, args, jobs, timeout, failFast, output, false) + for index, result := range executed { + records[indices[index]] = result + } + return records +} diff --git a/cmd/version.go b/internal/cli/command/version/command.go similarity index 67% rename from cmd/version.go rename to internal/cli/command/version/command.go index 1fecb76..8c9fdcb 100644 --- a/cmd/version.go +++ b/internal/cli/command/version/command.go @@ -1,13 +1,18 @@ -package cmd +// Package version implements the version command. +package version import ( "encoding/json" "fmt" + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/buildinfo" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" "github.com/spf13/cobra" ) -func newVersionCommand(app App, info buildInfo) *cobra.Command { +// New constructs a fresh version command using the supplied build metadata. +func New(_ app.App, info buildinfo.Info) *cobra.Command { var short bool var asJSON bool @@ -15,10 +20,10 @@ func newVersionCommand(app App, info buildInfo) *cobra.Command { Use: "version", Aliases: []string{"v"}, Short: "Print version and build information", - Args: noArgs, + Args: clierr.NoArgs, RunE: func(command *cobra.Command, _ []string) error { if short && asJSON { - return errorsForFlag("--short and --json cannot be used together") + return clierr.Usage("--short and --json cannot be used together") } switch { case asJSON: diff --git a/internal/cli/command/version/command_test.go b/internal/cli/command/version/command_test.go new file mode 100644 index 0000000..8d6125f --- /dev/null +++ b/internal/cli/command/version/command_test.go @@ -0,0 +1,102 @@ +package version + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/buildinfo" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" +) + +func TestVersionRunERendersDetailedJSONAndShortForms(t *testing.T) { + info := buildinfo.Info{Version: "1.2.3", Commit: "abc", BuildDate: "today", Dirty: true, GoVersion: "go1.24", Module: "example/module"} + + var detailed bytes.Buffer + command := New(app.App{}, info) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetOut(&detailed) + if err := command.Execute(); err != nil { + t.Fatalf("detailed execute returned error: %v", err) + } + for _, want := range []string{"gitmulti version 1.2.3", "commit: abc", "built: today", "dirty: true", "go: go1.24"} { + if !strings.Contains(detailed.String(), want) { + t.Fatalf("detailed output lacks %q: %q", want, detailed.String()) + } + } + + var jsonOutput bytes.Buffer + command = New(app.App{}, info) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetOut(&jsonOutput) + command.SetArgs([]string{"--json"}) + if err := command.Execute(); err != nil { + t.Fatalf("JSON execute returned error: %v", err) + } + var got buildinfo.Info + if err := json.Unmarshal(jsonOutput.Bytes(), &got); err != nil { + t.Fatalf("JSON output is invalid: %v", err) + } + if got != info { + t.Fatalf("JSON info = %#v, want %#v", got, info) + } + + var short bytes.Buffer + command = New(app.App{}, info) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetOut(&short) + command.SetArgs([]string{"--short"}) + if err := command.Execute(); err != nil || short.String() != "1.2.3\n" { + t.Fatalf("short result error=%v output=%q", err, short.String()) + } +} + +func TestVersionRunERejectsConflictingFlagsAndArguments(t *testing.T) { + command := New(app.App{}, buildinfo.Info{Version: "dev", Commit: "unknown", BuildDate: "unknown"}) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetArgs([]string{"--short", "--json"}) + err := command.Execute() + if err == nil || clierr.ExitCode(err) != clierr.UsageExitCode || !strings.Contains(err.Error(), "short") || !strings.Contains(err.Error(), "json") { + t.Fatalf("conflicting flags error = %v", err) + } + + command = New(app.App{}, buildinfo.Info{Version: "dev", Commit: "unknown", BuildDate: "unknown"}) + command.SilenceUsage = true + command.SilenceErrors = true + command.SetArgs([]string{"unexpected-argument"}) + err = command.Execute() + if err == nil || clierr.ExitCode(err) != clierr.UsageExitCode { + t.Fatalf("unexpected argument error = %v", err) + } +} + +func TestVersionConstructorsHaveIndependentFlagState(t *testing.T) { + info := buildinfo.Info{Version: "1.0.0", Commit: "abc", BuildDate: "today"} + first := New(app.App{}, info) + first.SilenceUsage = true + first.SilenceErrors = true + first.SetArgs([]string{"--short"}) + var firstOut bytes.Buffer + first.SetOut(&firstOut) + if err := first.Execute(); err != nil { + t.Fatalf("first execute returned error: %v", err) + } + + second := New(app.App{}, info) + second.SilenceUsage = true + second.SilenceErrors = true + var secondOut bytes.Buffer + second.SetOut(&secondOut) + if err := second.Execute(); err != nil { + t.Fatalf("second execute returned error: %v", err) + } + if strings.HasPrefix(secondOut.String(), "1.0.0\n") || !strings.Contains(secondOut.String(), "gitmulti version 1.0.0") { + t.Fatalf("second command leaked --short state: %q", secondOut.String()) + } +} diff --git a/cmd/exec.go b/internal/cli/execution/execution.go similarity index 60% rename from cmd/exec.go rename to internal/cli/execution/execution.go index 7cf7d4a..b5919f2 100644 --- a/cmd/exec.go +++ b/internal/cli/execution/execution.go @@ -1,4 +1,5 @@ -package cmd +// Package execution coordinates and renders Git commands run across repositories. +package execution import ( "bytes" @@ -12,12 +13,16 @@ import ( "sync" "time" + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/cli/output" "github.com/ramonzx6/gitmulti/internal/gitexec" "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -type execRecord struct { +// Record is the complete result of one repository execution. +type Record struct { Repository string `json:"repository"` Path string `json:"path"` Command []string `json:"command"` @@ -29,113 +34,16 @@ type execRecord struct { Skipped bool `json:"skipped,omitempty"` } -type execDocument struct { +// Document is the JSON envelope for execution results. +type Document struct { SchemaVersion int `json:"schemaVersion"` - Results []execRecord `json:"results"` + Results []Record `json:"results"` Diagnostics []repository.Warning `json:"diagnostics,omitempty"` } -func newExecCommand(app App) *cobra.Command { - var scan scanFlags - var commandString string - var jobs int - var failFast bool - var timeout time.Duration - var dryRun bool - var output string - var interactive bool - var quiet bool - var requireMatch bool - - command := &cobra.Command{ - Use: "exec [flags] -- ", - Aliases: []string{"ex"}, - Short: "Execute a Git command across repositories", - Args: cobra.ArbitraryArgs, - RunE: func(command *cobra.Command, args []string) error { - gitArgs, err := parseGitCommand(commandString, args) - if err != nil { - return errorsForFlag(err.Error()) - } - if jobs < 1 { - return errorsForFlag("--jobs must be at least 1") - } - if timeout < 0 { - return errorsForFlag("--timeout cannot be negative") - } - if interactive { - if jobs != 1 { - return errorsForFlag("--interactive requires --jobs=1") - } - output = "raw" - } - if !validExecOutput(output) { - return errorsForFlag(fmt.Sprintf("unsupported output mode %q", output)) - } - if output == "raw" && jobs != 1 { - return errorsForFlag("raw output requires --jobs=1") - } - - discovered, err := discoverRepositories(command.Context(), app, command, scan) - if err != nil { - return err - } - if len(discovered.Repositories) == 0 { - if requireMatch { - return newExitError(1, "no Git repositories matched") - } - return nil - } - - if dryRun { - for _, repo := range discovered.Repositories { - if _, err := fmt.Fprintln(command.OutOrStdout(), displayCommand(repo.Path, gitArgs)); err != nil { - return err - } - } - return nil - } - if _, err := app.Git.LookPath(); err != nil { - return err - } - - records := executeAcross(command, app, discovered.Repositories, gitArgs, jobs, timeout, failFast, output, interactive) - if err := renderExecRecords(command, records, output, quiet, discovered.Warnings); err != nil { - return err - } - failures := 0 - skipped := 0 - for _, record := range records { - if record.Skipped { - skipped++ - } else if record.Error != "" { - failures++ - } - } - if failures > 0 || skipped > 0 { - return newExitError(1, "Git command failed in %d and skipped %d of %d repositories", failures, skipped, len(records)) - } - return nil - }, - } - scan.bind(command) - command.Flags().StringVarP(&commandString, "command", "c", "", "Git command string (deprecated; use positional arguments after --)") - _ = command.Flags().MarkDeprecated("command", "use positional Git arguments after --") - command.Flags().IntVarP(&jobs, "jobs", "j", 1, "maximum concurrent Git processes") - command.Flags().BoolVar(&failFast, "fail-fast", false, "cancel remaining work after the first failure") - command.Flags().DurationVar(&timeout, "timeout", 0, "per-repository command timeout") - command.Flags().BoolVar(&dryRun, "dry-run", false, "print commands without executing them") - command.Flags().StringVar(&output, "output", "grouped", "output mode: grouped, prefixed, raw, json, or ndjson") - command.Flags().BoolVar(&interactive, "interactive", false, "inherit stdin and use raw sequential output") - command.Flags().BoolVarP(&quiet, "quiet", "q", false, "suppress repository headings") - command.Flags().BoolVar(&requireMatch, "require-match", false, "fail when no repositories match") - return command -} - -func errorsForFlag(message string) error { return newExitError(2, "%s", message) } - -func validExecOutput(output string) bool { - switch output { +// ValidOutput reports whether value names a supported execution output mode. +func ValidOutput(value string) bool { + switch value { case "grouped", "prefixed", "raw", "json", "ndjson": return true default: @@ -143,10 +51,23 @@ func validExecOutput(output string) bool { } } -func executeAcross(command *cobra.Command, app App, repositories []repository.Repository, args []string, jobs int, timeout time.Duration, failFast bool, output string, interactive bool) []execRecord { +// DisplayCommand formats an execution for dry-run output. strconv.Quote is +// used for unambiguous display only; commands are still executed without a shell. +func DisplayCommand(dir string, args []string) string { + parts := []string{"git", "-C", strconv.Quote(dir)} + for _, arg := range args { + parts = append(parts, strconv.Quote(arg)) + } + return strings.Join(parts, " ") +} + +// ExecuteAcross runs args in repositories with bounded concurrency. Results +// retain repository order regardless of completion order. +func ExecuteAcross(command *cobra.Command, application app.App, repositories []repository.Repository, args []string, jobs int, timeout time.Duration, failFast bool, outputMode string, interactive bool) []Record { ctx, cancel := context.WithCancel(command.Context()) defer cancel() - records := make([]execRecord, len(repositories)) + + records := make([]Record, len(repositories)) if len(repositories) == 0 { return records } @@ -157,6 +78,7 @@ func executeAcross(command *cobra.Command, app App, repositories []repository.Re if workerCount > len(repositories) { workerCount = len(repositories) } + indices := make(chan int) var wait sync.WaitGroup var outputMu sync.Mutex @@ -166,23 +88,25 @@ func executeAcross(command *cobra.Command, app App, repositories []repository.Re defer wait.Done() for index := range indices { repo := repositories[index] - record := execRecord{Repository: repo.Name, Path: repo.Path, Command: append([]string(nil), args...)} + record := Record{Repository: repo.Name, Path: repo.Path, Command: append([]string(nil), args...)} runCtx := ctx var timeoutCancel context.CancelFunc if timeout > 0 { runCtx, timeoutCancel = context.WithTimeout(ctx, timeout) } + var result gitexec.Result - if output == "raw" { + switch outputMode { + case "raw": var input io.Reader if interactive { input = command.InOrStdin() } - result = app.Git.Stream(runCtx, repo.Path, args, input, command.OutOrStdout(), command.ErrOrStderr()) - } else if output == "prefixed" { + result = application.Git.Stream(runCtx, repo.Path, args, input, command.OutOrStdout(), command.ErrOrStderr()) + case "prefixed": stdout := newLinePrefixWriter(command.OutOrStdout(), repo.Name, &outputMu) stderr := newLinePrefixWriter(command.ErrOrStderr(), repo.Name, &outputMu) - result = app.Git.Stream(runCtx, repo.Path, args, nil, stdout, stderr) + result = application.Git.Stream(runCtx, repo.Path, args, nil, stdout, stderr) if flushErr := stdout.Flush(); flushErr != nil && result.Err == nil { result.Err = flushErr result.ExitCode = -1 @@ -191,12 +115,13 @@ func executeAcross(command *cobra.Command, app App, repositories []repository.Re result.Err = flushErr result.ExitCode = -1 } - } else { - result = app.Git.Run(runCtx, repo.Path, args, nil) + default: + result = application.Git.Run(runCtx, repo.Path, args, nil) } if timeoutCancel != nil { timeoutCancel() } + record.Stdout = result.Stdout record.Stderr = result.Stderr record.ExitCode = result.ExitCode @@ -214,9 +139,17 @@ func executeAcross(command *cobra.Command, app App, repositories []repository.Re } }() } + for index := range repositories { if failFast && ctx.Err() != nil { - records[index] = execRecord{Repository: repositories[index].Name, Path: repositories[index].Path, Command: append([]string(nil), args...), ExitCode: -1, Error: "not started after fail-fast", Skipped: true} + records[index] = Record{ + Repository: repositories[index].Name, + Path: repositories[index].Path, + Command: append([]string(nil), args...), + ExitCode: -1, + Error: "not started after fail-fast", + Skipped: true, + } continue } indices <- index @@ -226,25 +159,27 @@ func executeAcross(command *cobra.Command, app App, repositories []repository.Re return records } -func renderExecRecords(command *cobra.Command, records []execRecord, output string, quiet bool, diagnostics ...[]repository.Warning) error { +// Render writes records in outputMode. Prefixed and raw process output is +// streamed during execution; Render only emits any remaining diagnostics. +func Render(command *cobra.Command, records []Record, outputMode string, quiet bool, diagnostics ...[]repository.Warning) error { var warnings []repository.Warning if len(diagnostics) > 0 { warnings = diagnostics[0] } - switch output { + switch outputMode { case "raw": return nil case "json": encoder := json.NewEncoder(command.OutOrStdout()) encoder.SetIndent("", " ") - return encoder.Encode(execDocument{SchemaVersion: outputSchemaVersion, Results: records, Diagnostics: warnings}) + return encoder.Encode(Document{SchemaVersion: output.SchemaVersion, Results: records, Diagnostics: warnings}) case "ndjson": encoder := json.NewEncoder(command.OutOrStdout()) for _, record := range records { if err := encoder.Encode(struct { Type string `json:"type"` - execRecord - }{Type: "execution", execRecord: record}); err != nil { + Record + }{Type: "execution", Record: record}); err != nil { return err } } @@ -293,7 +228,7 @@ func renderExecRecords(command *cobra.Command, records []execRecord, output stri } return nil default: - return errorsForFlag(fmt.Sprintf("unsupported output mode %q", output)) + return clierr.Usage("unsupported output mode %q", outputMode) } } @@ -318,6 +253,10 @@ func writePrefixed(writer io.Writer, prefix, value string) error { return nil } +const linePrefixChunkSize = 64 * 1024 + +// linePrefixWriter retains at most one bounded partial-line chunk. This keeps +// live prefixed output from growing with an arbitrarily long child-process line. type linePrefixWriter struct { writer io.Writer prefix string @@ -326,8 +265,6 @@ type linePrefixWriter struct { lineAtStart bool } -const linePrefixChunkSize = 64 * 1024 - func newLinePrefixWriter(writer io.Writer, label string, shared *sync.Mutex) *linePrefixWriter { quoted := strconv.QuoteToASCII(label) if len(quoted) >= 2 { diff --git a/internal/cli/execution/execution_test.go b/internal/cli/execution/execution_test.go new file mode 100644 index 0000000..cd6c663 --- /dev/null +++ b/internal/cli/execution/execution_test.go @@ -0,0 +1,461 @@ +package execution + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +const executionHelperEnv = "GITMULTI_EXECUTION_TEST_HELPER" + +func TestMain(m *testing.M) { + if os.Getenv(executionHelperEnv) == "1" { + os.Exit(runExecutionHelper()) + } + os.Exit(m.Run()) +} + +func TestValidOutputAndDisplayCommand(t *testing.T) { + for _, value := range []string{"grouped", "prefixed", "raw", "json", "ndjson"} { + if !ValidOutput(value) { + t.Errorf("ValidOutput(%q) = false", value) + } + } + if ValidOutput("yaml") { + t.Error("ValidOutput(yaml) = true") + } + if got, want := DisplayCommand("a repo", []string{"status", "--short"}), `git -C "a repo" "status" "--short"`; got != want { + t.Fatalf("DisplayCommand() = %q, want %q", got, want) + } +} + +func TestExecuteAcrossRetainsDiscoveryOrderAndBoundsParallelism(t *testing.T) { + repositories, stateDir := executionFixture(t, "slow", "fast", "medium", "quick", "last") + command, _, _ := executionCommand() + records := ExecuteAcross(command, executionApp(stateDir), repositories, []string{"ordered"}, 2, 0, false, "json", false) + + if len(records) != len(repositories) { + t.Fatalf("ExecuteAcross() returned %d records, want %d", len(records), len(repositories)) + } + for index, record := range records { + if record.Repository != repositories[index].Name || record.Path != repositories[index].Path { + t.Fatalf("record %d = %#v, want repository %#v", index, record, repositories[index]) + } + if record.ExitCode != 0 || record.Error != "" || record.Stdout != "out-"+repositories[index].Name+"\n" { + t.Fatalf("record %d execution result = %#v", index, record) + } + } + if got := maxConcurrentHelpers(t, stateDir, repositories); got < 2 || got > 2 { + t.Fatalf("maximum concurrent helpers = %d, want exactly 2", got) + } +} + +func TestExecuteAcrossTimesOutRepository(t *testing.T) { + repositories, stateDir := executionFixture(t, "hanging") + command, _, _ := executionCommand() + records := ExecuteAcross(command, executionApp(stateDir), repositories, []string{"hang"}, 1, 30*time.Millisecond, false, "json", false) + + if len(records) != 1 { + t.Fatalf("ExecuteAcross() returned %d records, want 1", len(records)) + } + record := records[0] + if record.ExitCode != -1 || record.Skipped || !strings.Contains(record.Error, context.DeadlineExceeded.Error()) { + t.Fatalf("timeout record = %#v", record) + } +} + +func TestExecuteAcrossFailFastMarksRemainingRepositoriesSkipped(t *testing.T) { + repositories, stateDir := executionFixture(t, "fail", "second", "third") + command, _, _ := executionCommand() + records := ExecuteAcross(command, executionApp(stateDir), repositories, []string{"fail-first"}, 1, 0, true, "json", false) + + if len(records) != 3 || records[0].ExitCode != 7 || records[0].Error == "" || records[0].Skipped { + t.Fatalf("fail-fast records = %#v", records) + } + for index, record := range records[1:] { + if !record.Skipped || record.ExitCode != -1 || record.Error == "" { + t.Fatalf("record %d after failure = %#v, want skipped", index+1, record) + } + } +} + +func TestExecuteAcrossRawStreamsToSeparateWriters(t *testing.T) { + repositories, stateDir := executionFixture(t, "repo") + command, stdout, stderr := executionCommand() + records := ExecuteAcross(command, executionApp(stateDir), repositories, []string{"emit"}, 1, 0, false, "raw", false) + + if stdout.String() != "out-repo\n" || stderr.String() != "err-repo\n" { + t.Fatalf("raw output stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if len(records) != 1 || records[0].Stdout != "" || records[0].Stderr != "" || records[0].Error != "" { + t.Fatalf("raw record = %#v", records) + } +} + +func TestExecuteAcrossPrefixedOutputIsLiveAndKeepsStreamsSeparate(t *testing.T) { + repositories, stateDir := executionFixture(t, "repo") + command := &cobra.Command{} + command.SetContext(context.Background()) + stdout := newSignalWriter() + stderr := newSignalWriter() + command.SetOut(stdout) + command.SetErr(stderr) + done := make(chan []Record, 1) + go func() { + done <- ExecuteAcross(command, executionApp(stateDir), repositories, []string{"live"}, 1, 0, false, "prefixed", false) + }() + + for name, signal := range map[string]<-chan struct{}{"stdout": stdout.firstWrite, "stderr": stderr.firstWrite} { + select { + case <-signal: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for live %s", name) + } + } + select { + case records := <-done: + t.Fatalf("execution completed before helper's trailing delay: %#v", records) + default: + } + + records := <-done + if got, want := stdout.String(), "[repo] out-repo\n"; got != want { + t.Fatalf("prefixed stdout = %q, want %q", got, want) + } + if got, want := stderr.String(), "[repo] err-repo\n"; got != want { + t.Fatalf("prefixed stderr = %q, want %q", got, want) + } + if len(records) != 1 || records[0].Stdout != "" || records[0].Stderr != "" || records[0].Error != "" { + t.Fatalf("prefixed record = %#v", records) + } +} + +func TestExecuteAcrossPrefixedBoundsLargeNewlineFreeOutput(t *testing.T) { + repositories, stateDir := executionFixture(t, "large") + command, stdout, stderr := executionCommand() + records := ExecuteAcross(command, executionApp(stateDir), repositories, []string{"large"}, 1, 0, false, "prefixed", false) + + if len(records) != 1 || records[0].Error != "" || stderr.Len() != 0 { + t.Fatalf("large prefixed execution records=%#v stderr=%q", records, stderr.String()) + } + const size = linePrefixChunkSize*2 + 17 + want := append([]byte("[large] "), bytes.Repeat([]byte{'x'}, size)...) + want = append(want, '\n') + if !bytes.Equal(stdout.Bytes(), want) { + t.Fatalf("large prefixed output length = %d, want %d", stdout.Len(), len(want)) + } + if strings.Count(stdout.String(), "[large] ") != 1 { + t.Fatal("large logical line received more than one prefix") + } +} + +func TestExecuteAcrossEmptyInput(t *testing.T) { + command := &cobra.Command{} + command.SetContext(context.Background()) + if records := ExecuteAcross(command, app.App{}, nil, []string{"status"}, 4, 0, false, "json", false); len(records) != 0 { + t.Fatalf("ExecuteAcross() = %#v", records) + } +} + +func TestRenderGroupedWritesHeadingsOutputErrorsAndHonorsQuiet(t *testing.T) { + records := []Record{{Repository: "repo", Stdout: "output\n", Stderr: "warning\n", Error: "failed"}} + command, stdout, stderr := executionCommand() + if err := Render(command, records, "grouped", false); err != nil { + t.Fatalf("Render(grouped) error = %v", err) + } + if stdout.String() != "== repo ==\noutput\n" || stderr.String() != "warning\nrepo: failed\n" { + t.Fatalf("grouped output stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + + command, stdout, _ = executionCommand() + if err := Render(command, records, "grouped", true); err != nil { + t.Fatalf("Render(grouped, quiet) error = %v", err) + } + if stdout.String() != "output\n" { + t.Fatalf("quiet grouped stdout = %q", stdout.String()) + } +} + +func TestRenderJSONDocumentIncludesDiagnostics(t *testing.T) { + command, stdout, stderr := executionCommand() + records := []Record{{Repository: "repo", Path: "/repo", Command: []string{"status"}, ExitCode: 0}} + warnings := []repository.Warning{{Path: "other", Message: "unreadable"}} + if err := Render(command, records, "json", false, warnings); err != nil { + t.Fatalf("Render() error = %v", err) + } + var document Document + if err := json.Unmarshal(stdout.Bytes(), &document); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if document.SchemaVersion != 1 || len(document.Results) != 1 || len(document.Diagnostics) != 1 { + t.Fatalf("document = %#v", document) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestRenderNDJSONEmitsExecutionAndDiagnosticRecords(t *testing.T) { + command, stdout, _ := executionCommand() + records := []Record{{Repository: "repo", Path: "/repo", ExitCode: 0}} + warnings := []repository.Warning{{Path: "/other", Message: "skipped"}} + if err := Render(command, records, "ndjson", false, warnings); err != nil { + t.Fatalf("Render(ndjson) error = %v", err) + } + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + if len(lines) != 2 { + t.Fatalf("NDJSON lines = %d, want 2: %q", len(lines), stdout.String()) + } + var executionLine struct { + Type string `json:"type"` + Repository string `json:"repository"` + } + if err := json.Unmarshal([]byte(lines[0]), &executionLine); err != nil || executionLine.Type != "execution" || executionLine.Repository != "repo" { + t.Fatalf("execution line = %#v, error = %v", executionLine, err) + } + var diagnosticLine struct { + Type string `json:"type"` + Path string `json:"path"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(lines[1]), &diagnosticLine); err != nil || diagnosticLine.Type != "diagnostic" || diagnosticLine.Path != "/other" || diagnosticLine.Message != "skipped" { + t.Fatalf("diagnostic line = %#v, error = %v", diagnosticLine, err) + } +} + +func TestRenderPrefixedBufferedOutputAndRawNoop(t *testing.T) { + records := []Record{{Repository: "repo", Stdout: "out\npartial", Stderr: "err\n", Error: "failed"}} + command, stdout, stderr := executionCommand() + if err := Render(command, records, "prefixed", false); err != nil { + t.Fatalf("Render(prefixed) error = %v", err) + } + if stdout.String() != "[repo] out\n[repo] partial\n" || stderr.String() != "[repo] err\n[repo] error: failed\n" { + t.Fatalf("prefixed render stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + + command, stdout, stderr = executionCommand() + if err := Render(command, records, "raw", false); err != nil || stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("Render(raw) error=%v stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } +} + +func TestRenderRejectsUnknownModeAsUsageError(t *testing.T) { + err := Render(&cobra.Command{}, nil, "yaml", false) + if clierr.ExitCode(err) != clierr.UsageExitCode { + t.Fatalf("Render() error = %v, want usage exit", err) + } +} + +func TestLinePrefixWriterHandlesPartialLinesAndEscapesLabel(t *testing.T) { + var output bytes.Buffer + writer := newLinePrefixWriter(&output, "repo\n\t\x01", &sync.Mutex{}) + if n, err := writer.Write([]byte("first")); err != nil || n != len("first") || output.Len() != 0 { + t.Fatalf("partial write = n:%d err:%v output:%q", n, err, output.String()) + } + if n, err := writer.Write([]byte(" line\nsecond")); err != nil || n != len(" line\nsecond") { + t.Fatalf("line write = n:%d err:%v", n, err) + } + if want := "[repo\\n\\t\\x01] first line\n"; output.String() != want { + t.Fatalf("line output = %q, want %q", output.String(), want) + } + if err := writer.Flush(); err != nil { + t.Fatalf("Flush() error = %v", err) + } + if want := "[repo\\n\\t\\x01] first line\n[repo\\n\\t\\x01] second\n"; output.String() != want { + t.Fatalf("flushed output = %q, want %q", output.String(), want) + } +} + +func TestLinePrefixWriterBoundsPartialLines(t *testing.T) { + var output bytes.Buffer + writer := newLinePrefixWriter(&output, "repo", &sync.Mutex{}) + value := strings.Repeat("x", linePrefixChunkSize*2+5) + written, err := writer.Write([]byte(value)) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + if written != len(value) { + t.Fatalf("Write() = %d, want %d", written, len(value)) + } + if writer.pending.Len() > linePrefixChunkSize { + t.Fatalf("pending buffer = %d, exceeds %d", writer.pending.Len(), linePrefixChunkSize) + } + if err := writer.Flush(); err != nil { + t.Fatalf("Flush() error = %v", err) + } + if strings.Count(output.String(), "[repo] ") != 1 { + t.Fatal("long logical line received repeated prefix") + } +} + +func executionCommand() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + var stdout, stderr bytes.Buffer + command := &cobra.Command{} + command.SetContext(context.Background()) + command.SetOut(&stdout) + command.SetErr(&stderr) + return command, &stdout, &stderr +} + +func executionFixture(t *testing.T, names ...string) ([]repository.Repository, string) { + t.Helper() + root := t.TempDir() + stateDir := t.TempDir() + repositories := make([]repository.Repository, 0, len(names)) + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create repository fixture %q: %v", path, err) + } + repositories = append(repositories, repository.Repository{Name: name, Path: path}) + } + return repositories, stateDir +} + +func executionApp(stateDir string) app.App { + runner := gitexec.Runner{Executable: os.Args[0]}.WithEnv( + executionHelperEnv+"=1", + "GITMULTI_EXECUTION_TEST_STATE="+stateDir, + ) + return app.App{Git: runner} +} + +type signalWriter struct { + mu sync.Mutex + buffer bytes.Buffer + firstWrite chan struct{} + once sync.Once +} + +func newSignalWriter() *signalWriter { + return &signalWriter{firstWrite: make(chan struct{})} +} + +func (writer *signalWriter) Write(data []byte) (int, error) { + writer.mu.Lock() + defer writer.mu.Unlock() + writer.once.Do(func() { close(writer.firstWrite) }) + return writer.buffer.Write(data) +} + +func (writer *signalWriter) String() string { + writer.mu.Lock() + defer writer.mu.Unlock() + return writer.buffer.String() +} + +func runExecutionHelper() int { + name := filepath.Base(mustWorkingDirectory()) + stateDir := os.Getenv("GITMULTI_EXECUTION_TEST_STATE") + writeTimestamp(filepath.Join(stateDir, name+".start")) + defer writeTimestamp(filepath.Join(stateDir, name+".end")) + + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "missing helper mode") + return 64 + } + switch os.Args[1] { + case "ordered": + delays := map[string]time.Duration{ + "slow": 200 * time.Millisecond, "fast": 20 * time.Millisecond, + "medium": 120 * time.Millisecond, "quick": 30 * time.Millisecond, + "last": 80 * time.Millisecond, + } + time.Sleep(delays[name]) + fmt.Printf("out-%s\n", name) + case "emit": + fmt.Printf("out-%s\n", name) + fmt.Fprintf(os.Stderr, "err-%s\n", name) + case "live": + fmt.Printf("out-%s\n", name) + fmt.Fprintf(os.Stderr, "err-%s\n", name) + time.Sleep(300 * time.Millisecond) + case "large": + _, _ = io.WriteString(os.Stdout, strings.Repeat("x", linePrefixChunkSize*2+17)) + case "hang": + time.Sleep(10 * time.Second) + case "fail-first": + if name == "fail" { + fmt.Fprintln(os.Stderr, "synthetic failure") + return 7 + } + fmt.Printf("out-%s\n", name) + default: + fmt.Fprintf(os.Stderr, "unknown helper mode %q\n", os.Args[1]) + return 64 + } + return 0 +} + +func mustWorkingDirectory() string { + directory, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "get working directory: %v\n", err) + os.Exit(70) + } + return directory +} + +func writeTimestamp(path string) { + if path == "" || filepath.Dir(path) == "." { + return + } + _ = os.WriteFile(path, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)), 0o600) +} + +func maxConcurrentHelpers(t *testing.T, stateDir string, repositories []repository.Repository) int { + t.Helper() + type event struct { + at int64 + delta int + } + events := make([]event, 0, len(repositories)*2) + for _, repo := range repositories { + for _, marker := range []struct { + suffix string + delta int + }{{".start", 1}, {".end", -1}} { + value, err := os.ReadFile(filepath.Join(stateDir, repo.Name+marker.suffix)) + if err != nil { + t.Fatalf("read %s marker for %q: %v", marker.suffix, repo.Name, err) + } + at, err := strconv.ParseInt(string(value), 10, 64) + if err != nil { + t.Fatalf("parse %s marker for %q: %v", marker.suffix, repo.Name, err) + } + events = append(events, event{at: at, delta: marker.delta}) + } + } + sort.Slice(events, func(i, j int) bool { + if events[i].at == events[j].at { + return events[i].delta > events[j].delta + } + return events[i].at < events[j].at + }) + active, maximum := 0, 0 + for _, item := range events { + active += item.delta + if active > maximum { + maximum = active + } + } + return maximum +} + +var _ io.Writer = (*signalWriter)(nil) diff --git a/internal/cli/output/output.go b/internal/cli/output/output.go new file mode 100644 index 0000000..3be1f10 --- /dev/null +++ b/internal/cli/output/output.go @@ -0,0 +1,6 @@ +// Package output contains shared contracts for machine-readable CLI output. +package output + +// SchemaVersion is incremented when a machine-readable document changes in a +// backward-incompatible way. +const SchemaVersion = 1 diff --git a/internal/cli/output/output_test.go b/internal/cli/output/output_test.go new file mode 100644 index 0000000..3f0bdd4 --- /dev/null +++ b/internal/cli/output/output_test.go @@ -0,0 +1,9 @@ +package output + +import "testing" + +func TestSchemaVersionIsPositive(t *testing.T) { + if SchemaVersion < 1 { + t.Fatalf("SchemaVersion = %d, want positive", SchemaVersion) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..1ea049f --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,89 @@ +// Package cli constructs and runs the GitMulti command-line interface. +package cli + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/buildinfo" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + checkcommand "github.com/ramonzx6/gitmulti/internal/cli/command/check" + completioncommand "github.com/ramonzx6/gitmulti/internal/cli/command/completion" + doctorcommand "github.com/ramonzx6/gitmulti/internal/cli/command/doctor" + execcommand "github.com/ramonzx6/gitmulti/internal/cli/command/exec" + listcommand "github.com/ramonzx6/gitmulti/internal/cli/command/list" + snapshotcommand "github.com/ramonzx6/gitmulti/internal/cli/command/snapshot" + statuscommand "github.com/ramonzx6/gitmulti/internal/cli/command/status" + synccommand "github.com/ramonzx6/gitmulti/internal/cli/command/sync" + versioncommand "github.com/ramonzx6/gitmulti/internal/cli/command/version" + "github.com/spf13/cobra" +) + +// App is the dependency boundary supplied to all CLI commands. +type App = app.App + +// DefaultApp returns production process streams and services. +func DefaultApp() App { + return app.Default() +} + +// NewRootCommand builds an isolated command tree with fresh flag state. +func NewRootCommand(application App) *cobra.Command { + application = application.Normalized() + info := buildinfo.Current() + + root := &cobra.Command{ + Use: "gitmulti", + Short: "Run Git operations across multiple repositories", + Long: "GitMulti discovers repositories under a directory and reports on or operates across them.", + SilenceErrors: true, + SilenceUsage: true, + Version: info.Version, + } + root.SetIn(application.In) + root.SetOut(application.Out) + root.SetErr(application.Err) + root.SetVersionTemplate("gitmulti version {{.Version}}\n") + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return clierr.Usage("%s", err) + }) + + root.AddCommand( + listcommand.New(application), + execcommand.New(application), + statuscommand.New(application), + checkcommand.New(application), + doctorcommand.New(application), + synccommand.New(application), + snapshotcommand.New(application), + completioncommand.New(application), + versioncommand.New(application, info), + ) + + return root +} + +// Run executes args with application and returns a process exit code. Runtime +// errors are printed exactly once without Cobra usage text. +func Run(ctx context.Context, args []string, application App) int { + root := NewRootCommand(application) + root.SetArgs(args) + if err := root.ExecuteContext(ctx); err != nil { + if err.Error() != "" { + _, _ = fmt.Fprintf(root.ErrOrStderr(), "Error: %v\n", err) + } + if strings.HasPrefix(err.Error(), "unknown command") { + return clierr.UsageExitCode + } + return clierr.ExitCode(err) + } + return 0 +} + +// Execute runs the production CLI using the current process arguments. +func Execute() int { + return Run(context.Background(), os.Args[1:], DefaultApp()) +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..6876ea6 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,87 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/buildinfo" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +func TestRunReturnsStableExitCodesAndOneError(t *testing.T) { + application, _, stderr := testApp(repository.Result{}) + if code := Run(context.Background(), []string{"exec"}, application); code != 2 { + t.Fatalf("usage exit code = %d, want 2", code) + } + if got := strings.Count(stderr.String(), "Error:"); got != 1 { + t.Fatalf("stderr has %d error prefixes, want 1: %q", got, stderr.String()) + } + if strings.Contains(stderr.String(), "Usage:") { + t.Fatalf("stderr unexpectedly includes usage: %q", stderr.String()) + } + + application, _, _ = testApp(repository.Result{}) + if code := Run(context.Background(), []string{"list", "--require-match"}, application); code != 1 { + t.Fatalf("require-match exit code = %d, want 1", code) + } + + application, _, _ = testApp(repository.Result{}) + if code := Run(context.Background(), []string{"missing-command"}, application); code != 2 { + t.Fatalf("unknown-command exit code = %d, want 2", code) + } +} + +func TestNewRootCommandDoesNotLeakFlagsBetweenInstances(t *testing.T) { + var roots []string + application := App{ + Discover: func(_ context.Context, options repository.Options) (repository.Result, error) { + roots = append(roots, options.Root) + return repository.Result{}, nil + }, + } + for _, args := range [][]string{{"list", "--path", "/first-root"}, {"list"}} { + root := NewRootCommand(application) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + } + if want := []string{"/first-root", "."}; !reflect.DeepEqual(roots, want) { + t.Fatalf("discovery roots = %#v, want %#v", roots, want) + } +} + +func TestVersionCommandUsesBuildInfo(t *testing.T) { + application, stdout, _ := testApp(repository.Result{}) + root := NewRootCommand(application) + root.SetArgs([]string{"version", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("version --json: %v", err) + } + var info buildinfo.Info + if err := json.Unmarshal(stdout.Bytes(), &info); err != nil { + t.Fatalf("decode version JSON: %v\n%s", err, stdout.String()) + } + if info.Version == "" || info.Commit == "" || info.BuildDate == "" { + t.Fatalf("version JSON omitted required fields: %#v", info) + } +} + +func testApp(result repository.Result) (App, *bytes.Buffer, *bytes.Buffer) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + return App{ + In: bytes.NewReader(nil), + Out: stdout, + Err: stderr, + Git: gitexec.Runner{Executable: "gitmulti-test-git-does-not-exist"}, + Discover: func(context.Context, repository.Options) (repository.Result, error) { + return result, nil + }, + }, stdout, stderr +} diff --git a/cmd/options.go b/internal/cli/scanflags/scanflags.go similarity index 52% rename from cmd/options.go rename to internal/cli/scanflags/scanflags.go index 0ba4330..72a5e9a 100644 --- a/cmd/options.go +++ b/internal/cli/scanflags/scanflags.go @@ -1,15 +1,21 @@ -package cmd +// Package scanflags owns the repository-discovery flags shared by commands. +package scanflags import ( "context" "fmt" "strings" + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" "github.com/ramonzx6/gitmulti/internal/repository" "github.com/spf13/cobra" ) -type scanFlags struct { +// Flags stores repository discovery options bound to a Cobra command. +// Its fields remain private so all commands receive identical validation and +// option-copying behavior. +type Flags struct { path string recursive bool includeRoot bool @@ -23,7 +29,8 @@ type scanFlags struct { validate bool } -func (flags *scanFlags) bind(command *cobra.Command) { +// Bind adds the common repository discovery flags to command. +func (flags *Flags) Bind(command *cobra.Command) { command.Flags().StringVarP(&flags.path, "path", "p", ".", "root path to scan") command.Flags().BoolVarP(&flags.recursive, "recursive", "r", false, "scan nested directories") command.Flags().BoolVar(&flags.includeRoot, "include-root", false, "include the root path when it is a repository") @@ -36,7 +43,23 @@ func (flags *scanFlags) bind(command *cobra.Command) { command.Flags().BoolVar(&flags.includeBare, "include-bare", false, "include bare Git repositories") } -func (flags scanFlags) options() repository.Options { +// BindValidation adds the optional Git-backed marker validation flag. It is +// separate because validation is intentionally exposed only by commands that +// need it. +func (flags *Flags) BindValidation(command *cobra.Command) { + command.Flags().BoolVar(&flags.validate, "validate", false, "validate repository markers using Git") +} + +// SetValidation controls Git-backed repository marker validation. It is used +// by diagnostic commands whose validation decision comes from a preflight +// check rather than a command-line flag. +func (flags *Flags) SetValidation(enabled bool) { + flags.validate = enabled +} + +// Options returns a copy of the discovery options currently represented by +// the flags. +func (flags Flags) Options() repository.Options { return repository.Options{ Root: flags.path, Recursive: flags.recursive, @@ -52,20 +75,25 @@ func (flags scanFlags) options() repository.Options { } } -func discoverRepositories(ctx context.Context, app App, command *cobra.Command, flags scanFlags, suppressWarnings ...bool) (repository.Result, error) { +// Discover validates the bound flags, discovers repositories, and emits +// non-fatal warnings on the command's stderr. Passing true for suppressWarnings +// retains warnings in the result without printing them. +func (flags Flags) Discover(ctx context.Context, application app.App, command *cobra.Command, suppressWarnings ...bool) (repository.Result, error) { if flags.maxDepth < 0 { - return repository.Result{}, errorsForFlag("--max-depth cannot be negative") + return repository.Result{}, clierr.Usage("--max-depth cannot be negative") } if flags.maxDepth > 0 && !flags.recursive { - return repository.Result{}, errorsForFlag("--max-depth requires --recursive") + return repository.Result{}, clierr.Usage("--max-depth requires --recursive") } if flags.includeNested && !flags.recursive { - return repository.Result{}, errorsForFlag("--include-nested requires --recursive") + return repository.Result{}, clierr.Usage("--include-nested requires --recursive") } - options := flags.options() + + application = application.Normalized() + options := flags.Options() if options.Validate { options.ValidateRepository = func(ctx context.Context, path string) error { - result := app.Git.Run(ctx, path, []string{"rev-parse", "--git-dir"}, nil) + result := application.Git.Run(ctx, path, []string{"rev-parse", "--git-dir"}, nil) if result.Err == nil { return nil } @@ -75,17 +103,17 @@ func discoverRepositories(ctx context.Context, app App, command *cobra.Command, return result.Err } } - result, err := app.Discover(ctx, options) + + result, err := application.Discover(ctx, options) if err != nil { return repository.Result{}, err } quiet := len(suppressWarnings) > 0 && suppressWarnings[0] - for _, warning := range result.Warnings { - if quiet { - continue - } - if _, err := fmt.Fprintf(command.ErrOrStderr(), "warning: %s: %s\n", warning.Path, warning.Message); err != nil { - return repository.Result{}, err + if !quiet { + for _, warning := range result.Warnings { + if _, err := fmt.Fprintf(command.ErrOrStderr(), "warning: %s: %s\n", warning.Path, warning.Message); err != nil { + return repository.Result{}, err + } } } return result, nil diff --git a/internal/cli/scanflags/scanflags_test.go b/internal/cli/scanflags/scanflags_test.go new file mode 100644 index 0000000..8f8018b --- /dev/null +++ b/internal/cli/scanflags/scanflags_test.go @@ -0,0 +1,85 @@ +package scanflags + +import ( + "bytes" + "context" + "testing" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/ramonzx6/gitmulti/internal/repository" + "github.com/spf13/cobra" +) + +func TestDiscoverMapsFlagsAndWritesWarnings(t *testing.T) { + var received repository.Options + application := app.App{Discover: func(_ context.Context, options repository.Options) (repository.Result, error) { + received = options + return repository.Result{Warnings: []repository.Warning{{Path: "broken", Message: "not readable"}}}, nil + }} + var flags Flags + command := &cobra.Command{Use: "test"} + flags.Bind(command) + command.SetArgs([]string{"--path", "repos", "--recursive", "--max-depth", "2", "--match", "team/*", "--exclude", "old/*", "--include-hidden", "--follow-symlinks", "--include-bare"}) + if err := command.ParseFlags(command.Flags().Args()); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + // Cobra parses SetArgs during execution, so parse the desired values directly. + if err := command.Flags().Parse([]string{"--path", "repos", "--recursive", "--max-depth", "2", "--match", "team/*", "--exclude", "old/*", "--include-hidden", "--follow-symlinks", "--include-bare"}); err != nil { + t.Fatalf("flag parse error = %v", err) + } + var stderr bytes.Buffer + command.SetErr(&stderr) + + if _, err := flags.Discover(context.Background(), application, command); err != nil { + t.Fatalf("Discover() error = %v", err) + } + if received.Root != "repos" || !received.Recursive || received.MaxDepth != 2 || !received.IncludeHidden || !received.FollowSymlinks || !received.IncludeBare { + t.Fatalf("discovery options = %#v", received) + } + if len(received.Match) != 1 || received.Match[0] != "team/*" || len(received.Exclude) != 1 || received.Exclude[0] != "old/*" { + t.Fatalf("pattern options = %#v", received) + } + if got := stderr.String(); got != "warning: broken: not readable\n" { + t.Fatalf("stderr = %q", got) + } +} + +func TestDiscoverRejectsRecursiveOnlyOptions(t *testing.T) { + for _, args := range [][]string{{"--max-depth", "2"}, {"--include-nested"}} { + var flags Flags + command := &cobra.Command{Use: "test"} + flags.Bind(command) + if err := command.Flags().Parse(args); err != nil { + t.Fatalf("flag parse error = %v", err) + } + _, err := flags.Discover(context.Background(), app.App{}, command) + if clierr.ExitCode(err) != clierr.UsageExitCode { + t.Fatalf("Discover(%v) error = %v, want usage exit", args, err) + } + } +} + +func TestBindValidationAndSuppressWarnings(t *testing.T) { + var flags Flags + command := &cobra.Command{Use: "test"} + flags.Bind(command) + flags.BindValidation(command) + if err := command.Flags().Parse([]string{"--validate"}); err != nil { + t.Fatalf("flag parse error = %v", err) + } + application := app.App{Discover: func(_ context.Context, options repository.Options) (repository.Result, error) { + if !options.Validate || options.ValidateRepository == nil { + t.Fatal("validation was not configured") + } + return repository.Result{Warnings: []repository.Warning{{Path: "repo", Message: "warning"}}}, nil + }} + var stderr bytes.Buffer + command.SetErr(&stderr) + if _, err := flags.Discover(context.Background(), application, command, true); err != nil { + t.Fatalf("Discover() error = %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("suppressed stderr = %q", stderr.String()) + } +} diff --git a/internal/cli/statusset/statusset.go b/internal/cli/statusset/statusset.go new file mode 100644 index 0000000..3acbcbd --- /dev/null +++ b/internal/cli/statusset/statusset.go @@ -0,0 +1,63 @@ +// Package statusset coordinates Git status collection across repositories. +package statusset + +import ( + "context" + "sync" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/gitstatus" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +// Collect gathers repository states with bounded concurrency. Results retain +// discovery order. The optional noAheadBehind argument avoids the corresponding +// Git calculation when true. +func Collect(ctx context.Context, application app.App, repositories []repository.Repository, jobs int, noAheadBehind ...bool) []gitstatus.State { + states := make([]gitstatus.State, len(repositories)) + if len(repositories) == 0 { + return states + } + workerCount := jobs + if workerCount < 1 { + workerCount = 1 + } + if workerCount > len(repositories) { + workerCount = len(repositories) + } + + indices := make(chan int) + collector := gitstatus.NewCollector(application.Git) + options := gitstatus.CollectOptions{} + if len(noAheadBehind) > 0 && noAheadBehind[0] { + options.NoAheadBehind = true + } + var wait sync.WaitGroup + for worker := 0; worker < workerCount; worker++ { + wait.Add(1) + go func() { + defer wait.Done() + for index := range indices { + repo := repositories[index] + states[index] = collector.CollectWithOptions(ctx, repo.Name, repo.Path, options) + } + }() + } + for index := range repositories { + indices <- index + } + close(indices) + wait.Wait() + return states +} + +// ErrorCount returns the number of states whose collection failed. +func ErrorCount(states []gitstatus.State) int { + count := 0 + for _, state := range states { + if state.Err != nil { + count++ + } + } + return count +} diff --git a/internal/cli/statusset/statusset_test.go b/internal/cli/statusset/statusset_test.go new file mode 100644 index 0000000..147f536 --- /dev/null +++ b/internal/cli/statusset/statusset_test.go @@ -0,0 +1,250 @@ +package statusset + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/gitexec" + "github.com/ramonzx6/gitmulti/internal/gitstatus" + "github.com/ramonzx6/gitmulti/internal/repository" +) + +const statussetHelperEnv = "GITMULTI_STATUSSET_TEST_HELPER" + +func TestMain(m *testing.M) { + if os.Getenv(statussetHelperEnv) == "1" { + os.Exit(runStatussetHelper()) + } + os.Exit(m.Run()) +} + +func TestCollectRetainsDiscoveryOrderAndBoundsParallelism(t *testing.T) { + repositories, stateDir := statussetFixture(t, "slow", "fast", "medium", "quick", "last") + states := Collect(context.Background(), statussetApp(stateDir, false), repositories, 2) + + if len(states) != len(repositories) { + t.Fatalf("Collect() returned %d states, want %d", len(states), len(repositories)) + } + for index, state := range states { + if state.Name != repositories[index].Name || state.Path != repositories[index].Path { + t.Fatalf("state %d = %#v, want repository %#v", index, state, repositories[index]) + } + if state.Err != nil || state.Branch != "main" || state.Head != "abc123" || !state.AheadBehindKnown { + t.Fatalf("state %d collection result = %#v", index, state) + } + } + if got := maxConcurrentStatusHelpers(t, stateDir, repositories); got < 2 || got > 2 { + t.Fatalf("maximum concurrent helpers = %d, want exactly 2", got) + } +} + +func TestCollectPassesNoAheadBehindOption(t *testing.T) { + repositories, stateDir := statussetFixture(t, "noahead") + states := Collect(context.Background(), statussetApp(stateDir, true), repositories, 1, true) + + if len(states) != 1 || states[0].Err != nil { + t.Fatalf("Collect(no-ahead-behind) = %#v", states) + } + state := states[0] + if state.AheadBehindKnown || state.Ahead != 0 || state.Behind != 0 || state.Upstream != "origin/main" { + t.Fatalf("no-ahead-behind state = %#v", state) + } +} + +func TestCollectPreservesSuccessfulAndFailedStates(t *testing.T) { + repositories, stateDir := statussetFixture(t, "clean", "error", "dirty") + states := Collect(context.Background(), statussetApp(stateDir, false), repositories, 3) + + if len(states) != 3 || states[0].Err != nil || states[1].Err == nil || states[2].Err != nil { + t.Fatalf("mixed collection states = %#v", states) + } + if !strings.Contains(states[1].Error, "synthetic status failure") { + t.Fatalf("failed state error = %q", states[1].Error) + } + if !states[2].Dirty || !states[2].Untracked { + t.Fatalf("dirty state = %#v", states[2]) + } + if got := ErrorCount(states); got != 1 { + t.Fatalf("ErrorCount() = %d, want 1", got) + } +} + +func TestCollectCapsHugeWorkerCountToRepositoryCount(t *testing.T) { + repositories, stateDir := statussetFixture(t, "only") + maxJobs := int(^uint(0) >> 1) + states := Collect(context.Background(), statussetApp(stateDir, false), repositories, maxJobs) + if len(states) != 1 || states[0].Err != nil || states[0].Name != "only" { + t.Fatalf("Collect() with huge jobs = %#v", states) + } +} + +func TestCollectCoercesNonPositiveJobsToOne(t *testing.T) { + repositories, stateDir := statussetFixture(t, "first", "second") + states := Collect(context.Background(), statussetApp(stateDir, false), repositories, 0) + if len(states) != 2 || states[0].Err != nil || states[1].Err != nil { + t.Fatalf("Collect() with zero jobs = %#v", states) + } + if got := maxConcurrentStatusHelpers(t, stateDir, repositories); got != 1 { + t.Fatalf("maximum concurrent helpers = %d, want 1", got) + } +} + +func TestCollectEmptyInput(t *testing.T) { + states := Collect(context.Background(), app.App{}, nil, 0) + if len(states) != 0 { + t.Fatalf("Collect() = %#v, want empty", states) + } +} + +func TestErrorCount(t *testing.T) { + states := []gitstatus.State{ + {Name: "clean"}, + {Name: "failed", Err: errors.New("failed")}, + {Name: "also-failed", Err: errors.New("failed")}, + } + if got := ErrorCount(states); got != 2 { + t.Fatalf("ErrorCount() = %d, want 2", got) + } +} + +func statussetFixture(t *testing.T, names ...string) ([]repository.Repository, string) { + t.Helper() + root := t.TempDir() + stateDir := t.TempDir() + repositories := make([]repository.Repository, 0, len(names)) + for _, name := range names { + path := filepath.Join(root, name) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("create repository fixture %q: %v", path, err) + } + repositories = append(repositories, repository.Repository{Name: name, Path: path}) + } + return repositories, stateDir +} + +func statussetApp(stateDir string, expectNoAheadBehind bool) app.App { + runner := gitexec.Runner{Executable: os.Args[0]}.WithEnv( + statussetHelperEnv+"=1", + "GITMULTI_STATUSSET_TEST_STATE="+stateDir, + "GITMULTI_STATUSSET_EXPECT_NO_AHEAD="+strconv.FormatBool(expectNoAheadBehind), + ) + return app.App{Git: runner} +} + +func runStatussetHelper() int { + name := filepath.Base(statusWorkingDirectory()) + stateDir := os.Getenv("GITMULTI_STATUSSET_TEST_STATE") + writeStatusTimestamp(filepath.Join(stateDir, name+".start")) + defer writeStatusTimestamp(filepath.Join(stateDir, name+".end")) + + wantNoAhead, _ := strconv.ParseBool(os.Getenv("GITMULTI_STATUSSET_EXPECT_NO_AHEAD")) + hasNoAhead := false + wantArgs := []string{"-c", "core.fsmonitor=false", "--no-optional-locks", "status", "--porcelain=v2", "--branch"} + if len(os.Args) < len(wantArgs)+2 { + fmt.Fprintf(os.Stderr, "status helper received too few arguments: %q\n", os.Args[1:]) + return 64 + } + for index, want := range wantArgs { + if os.Args[index+1] != want { + fmt.Fprintf(os.Stderr, "status helper argument %d = %q, want %q\n", index, os.Args[index+1], want) + return 64 + } + } + remaining := os.Args[len(wantArgs)+1:] + if len(remaining) > 0 && remaining[0] == "--no-ahead-behind" { + hasNoAhead = true + remaining = remaining[1:] + } + if len(remaining) != 1 || remaining[0] != "-z" || hasNoAhead != wantNoAhead { + fmt.Fprintf(os.Stderr, "status helper trailing arguments = %q, no-ahead=%t, want no-ahead=%t\n", remaining, hasNoAhead, wantNoAhead) + return 64 + } + + delays := map[string]time.Duration{ + "slow": 200 * time.Millisecond, "fast": 20 * time.Millisecond, + "medium": 120 * time.Millisecond, "quick": 30 * time.Millisecond, + "last": 80 * time.Millisecond, "first": 80 * time.Millisecond, + "second": 80 * time.Millisecond, + } + time.Sleep(delays[name]) + if name == "error" { + fmt.Fprintln(os.Stderr, "synthetic status failure") + return 8 + } + + records := []string{"# branch.oid abc123", "# branch.head main", "# branch.upstream origin/main"} + if !hasNoAhead { + records = append(records, "# branch.ab +2 -1") + } + if name == "dirty" { + records = append(records, "? untracked.txt") + } + for _, record := range records { + _, _ = os.Stdout.Write(append([]byte(record), 0)) + } + return 0 +} + +func statusWorkingDirectory() string { + directory, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "get working directory: %v\n", err) + os.Exit(70) + } + return directory +} + +func writeStatusTimestamp(path string) { + if path == "" || filepath.Dir(path) == "." { + return + } + _ = os.WriteFile(path, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)), 0o600) +} + +func maxConcurrentStatusHelpers(t *testing.T, stateDir string, repositories []repository.Repository) int { + t.Helper() + type event struct { + at int64 + delta int + } + events := make([]event, 0, len(repositories)*2) + for _, repo := range repositories { + for _, marker := range []struct { + suffix string + delta int + }{{".start", 1}, {".end", -1}} { + value, err := os.ReadFile(filepath.Join(stateDir, repo.Name+marker.suffix)) + if err != nil { + t.Fatalf("read %s marker for %q: %v", marker.suffix, repo.Name, err) + } + at, err := strconv.ParseInt(string(value), 10, 64) + if err != nil { + t.Fatalf("parse %s marker for %q: %v", marker.suffix, repo.Name, err) + } + events = append(events, event{at: at, delta: marker.delta}) + } + } + sort.Slice(events, func(i, j int) bool { + if events[i].at == events[j].at { + return events[i].delta > events[j].delta + } + return events[i].at < events[j].at + }) + active, maximum := 0, 0 + for _, item := range events { + active += item.delta + if active > maximum { + maximum = active + } + } + return maximum +} diff --git a/main.go b/main.go deleted file mode 100644 index e5cedda..0000000 --- a/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "os" - - "github.com/ramonzx6/gitmulti/cmd" -) - -func main() { - os.Exit(cmd.Execute()) -} From 61261104b2626e6f9e3110949d0a88c20a457e1a Mon Sep 17 00:00:00 2001 From: ramonzx6 Date: Sat, 22 Aug 2026 08:36:56 +0000 Subject: [PATCH 5/7] refactor: remove low-value code comments --- internal/cli/app/app.go | 1 - internal/cli/buildinfo/buildinfo.go | 2 -- internal/cli/command/check/command.go | 2 -- internal/cli/command/completion/command.go | 3 --- internal/cli/command/doctor/command.go | 2 -- internal/cli/command/exec/command.go | 4 ---- internal/cli/command/list/command.go | 2 -- internal/cli/command/snapshot/command.go | 2 -- internal/cli/command/status/command.go | 2 -- internal/cli/command/sync/command.go | 4 ---- internal/cli/command/version/command.go | 2 -- internal/cli/execution/execution.go | 4 ---- internal/cli/output/output.go | 1 - internal/cli/root.go | 3 --- internal/cli/scanflags/scanflags.go | 2 -- internal/cli/scanflags/scanflags_test.go | 2 +- internal/cli/statusset/statusset.go | 2 -- internal/gitexec/gitexec.go | 2 -- internal/gitstatus/status.go | 2 -- internal/gitstatus/status_test.go | 2 -- 20 files changed, 1 insertion(+), 45 deletions(-) diff --git a/internal/cli/app/app.go b/internal/cli/app/app.go index d9d4a94..91a76c8 100644 --- a/internal/cli/app/app.go +++ b/internal/cli/app/app.go @@ -1,4 +1,3 @@ -// Package app defines the dependencies shared by the CLI command packages. package app import ( diff --git a/internal/cli/buildinfo/buildinfo.go b/internal/cli/buildinfo/buildinfo.go index adb648a..9e4dc78 100644 --- a/internal/cli/buildinfo/buildinfo.go +++ b/internal/cli/buildinfo/buildinfo.go @@ -1,4 +1,3 @@ -// Package buildinfo exposes the build metadata reported by the CLI. package buildinfo import "runtime/debug" @@ -10,7 +9,6 @@ var ( BuildDate = "unknown" ) -// Info is the stable build metadata rendered by the version command. type Info struct { Version string `json:"version"` Commit string `json:"commit"` diff --git a/internal/cli/command/check/command.go b/internal/cli/command/check/command.go index 5879f88..7ab7b08 100644 --- a/internal/cli/command/check/command.go +++ b/internal/cli/command/check/command.go @@ -1,4 +1,3 @@ -// Package check implements repository policy checks. package check import ( @@ -31,7 +30,6 @@ type document struct { Diagnostics []repository.Warning `json:"diagnostics,omitempty"` } -// New constructs a fresh check command and its private flag state. func New(application app.App) *cobra.Command { application = application.Normalized() var scan scanflags.Flags diff --git a/internal/cli/command/completion/command.go b/internal/cli/command/completion/command.go index 52b694e..2ee8100 100644 --- a/internal/cli/command/completion/command.go +++ b/internal/cli/command/completion/command.go @@ -1,4 +1,3 @@ -// Package completion implements shell completion generation. package completion import ( @@ -7,8 +6,6 @@ import ( "github.com/spf13/cobra" ) -// New constructs a fresh completion command. The application is accepted for -// a consistent command-constructor API; Cobra obtains streams from the root. func New(_ app.App) *cobra.Command { return &cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", diff --git a/internal/cli/command/doctor/command.go b/internal/cli/command/doctor/command.go index a575580..e12734d 100644 --- a/internal/cli/command/doctor/command.go +++ b/internal/cli/command/doctor/command.go @@ -1,4 +1,3 @@ -// Package doctor implements the doctor command. package doctor import ( @@ -26,7 +25,6 @@ type document struct { Findings []finding `json:"findings"` } -// New constructs a fresh doctor command and its private flag state. func New(application app.App) *cobra.Command { application = application.Normalized() var scan scanflags.Flags diff --git a/internal/cli/command/exec/command.go b/internal/cli/command/exec/command.go index cc0231c..21abded 100644 --- a/internal/cli/command/exec/command.go +++ b/internal/cli/command/exec/command.go @@ -1,5 +1,3 @@ -// Package exec implements the command that runs a Git command across -// discovered repositories. package exec import ( @@ -13,8 +11,6 @@ import ( "github.com/spf13/cobra" ) -// New constructs the exec command using application as its process and -// discovery boundary. func New(application app.App) *cobra.Command { application = application.Normalized() var scan scanflags.Flags diff --git a/internal/cli/command/list/command.go b/internal/cli/command/list/command.go index c512457..81f0ebf 100644 --- a/internal/cli/command/list/command.go +++ b/internal/cli/command/list/command.go @@ -1,4 +1,3 @@ -// Package list implements the list command. package list import ( @@ -14,7 +13,6 @@ import ( "github.com/spf13/cobra" ) -// New constructs a fresh list command and its private flag state. func New(application app.App) *cobra.Command { application = application.Normalized() var scan scanflags.Flags diff --git a/internal/cli/command/snapshot/command.go b/internal/cli/command/snapshot/command.go index fdbb3c7..3014aa4 100644 --- a/internal/cli/command/snapshot/command.go +++ b/internal/cli/command/snapshot/command.go @@ -29,8 +29,6 @@ type snapshotDocument struct { Diagnostics []repository.Warning `json:"diagnostics,omitempty"` } -// New constructs the snapshot command using application as its process and -// discovery boundary. func New(application app.App) *cobra.Command { application = application.Normalized() var scan scanflags.Flags diff --git a/internal/cli/command/status/command.go b/internal/cli/command/status/command.go index e534049..9261cbd 100644 --- a/internal/cli/command/status/command.go +++ b/internal/cli/command/status/command.go @@ -1,4 +1,3 @@ -// Package status implements the status command. package status import ( @@ -28,7 +27,6 @@ type line struct { Message string `json:"message,omitempty"` } -// New constructs a fresh status command and its private flag state. func New(application app.App) *cobra.Command { application = application.Normalized() var scan scanflags.Flags diff --git a/internal/cli/command/sync/command.go b/internal/cli/command/sync/command.go index 2da85c5..69025b1 100644 --- a/internal/cli/command/sync/command.go +++ b/internal/cli/command/sync/command.go @@ -1,5 +1,3 @@ -// Package sync implements safe fetch and fast-forward synchronization across -// discovered repositories. package sync import ( @@ -14,8 +12,6 @@ import ( "github.com/spf13/cobra" ) -// New constructs the sync command using application as its process and -// discovery boundary. func New(application app.App) *cobra.Command { application = application.Normalized() var scan scanflags.Flags diff --git a/internal/cli/command/version/command.go b/internal/cli/command/version/command.go index 8c9fdcb..324c5d2 100644 --- a/internal/cli/command/version/command.go +++ b/internal/cli/command/version/command.go @@ -1,4 +1,3 @@ -// Package version implements the version command. package version import ( @@ -11,7 +10,6 @@ import ( "github.com/spf13/cobra" ) -// New constructs a fresh version command using the supplied build metadata. func New(_ app.App, info buildinfo.Info) *cobra.Command { var short bool var asJSON bool diff --git a/internal/cli/execution/execution.go b/internal/cli/execution/execution.go index b5919f2..f590d5f 100644 --- a/internal/cli/execution/execution.go +++ b/internal/cli/execution/execution.go @@ -1,4 +1,3 @@ -// Package execution coordinates and renders Git commands run across repositories. package execution import ( @@ -21,7 +20,6 @@ import ( "github.com/spf13/cobra" ) -// Record is the complete result of one repository execution. type Record struct { Repository string `json:"repository"` Path string `json:"path"` @@ -34,14 +32,12 @@ type Record struct { Skipped bool `json:"skipped,omitempty"` } -// Document is the JSON envelope for execution results. type Document struct { SchemaVersion int `json:"schemaVersion"` Results []Record `json:"results"` Diagnostics []repository.Warning `json:"diagnostics,omitempty"` } -// ValidOutput reports whether value names a supported execution output mode. func ValidOutput(value string) bool { switch value { case "grouped", "prefixed", "raw", "json", "ndjson": diff --git a/internal/cli/output/output.go b/internal/cli/output/output.go index 3be1f10..5d995f5 100644 --- a/internal/cli/output/output.go +++ b/internal/cli/output/output.go @@ -1,4 +1,3 @@ -// Package output contains shared contracts for machine-readable CLI output. package output // SchemaVersion is incremented when a machine-readable document changes in a diff --git a/internal/cli/root.go b/internal/cli/root.go index 1ea049f..c159973 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,4 +1,3 @@ -// Package cli constructs and runs the GitMulti command-line interface. package cli import ( @@ -25,7 +24,6 @@ import ( // App is the dependency boundary supplied to all CLI commands. type App = app.App -// DefaultApp returns production process streams and services. func DefaultApp() App { return app.Default() } @@ -83,7 +81,6 @@ func Run(ctx context.Context, args []string, application App) int { return 0 } -// Execute runs the production CLI using the current process arguments. func Execute() int { return Run(context.Background(), os.Args[1:], DefaultApp()) } diff --git a/internal/cli/scanflags/scanflags.go b/internal/cli/scanflags/scanflags.go index 72a5e9a..13395f0 100644 --- a/internal/cli/scanflags/scanflags.go +++ b/internal/cli/scanflags/scanflags.go @@ -1,4 +1,3 @@ -// Package scanflags owns the repository-discovery flags shared by commands. package scanflags import ( @@ -29,7 +28,6 @@ type Flags struct { validate bool } -// Bind adds the common repository discovery flags to command. func (flags *Flags) Bind(command *cobra.Command) { command.Flags().StringVarP(&flags.path, "path", "p", ".", "root path to scan") command.Flags().BoolVarP(&flags.recursive, "recursive", "r", false, "scan nested directories") diff --git a/internal/cli/scanflags/scanflags_test.go b/internal/cli/scanflags/scanflags_test.go index 8f8018b..570ee8b 100644 --- a/internal/cli/scanflags/scanflags_test.go +++ b/internal/cli/scanflags/scanflags_test.go @@ -24,7 +24,7 @@ func TestDiscoverMapsFlagsAndWritesWarnings(t *testing.T) { if err := command.ParseFlags(command.Flags().Args()); err != nil { t.Fatalf("ParseFlags() error = %v", err) } - // Cobra parses SetArgs during execution, so parse the desired values directly. + // Cobra does not consume SetArgs until execution, so parse these flags directly. if err := command.Flags().Parse([]string{"--path", "repos", "--recursive", "--max-depth", "2", "--match", "team/*", "--exclude", "old/*", "--include-hidden", "--follow-symlinks", "--include-bare"}); err != nil { t.Fatalf("flag parse error = %v", err) } diff --git a/internal/cli/statusset/statusset.go b/internal/cli/statusset/statusset.go index 3acbcbd..9a43946 100644 --- a/internal/cli/statusset/statusset.go +++ b/internal/cli/statusset/statusset.go @@ -1,4 +1,3 @@ -// Package statusset coordinates Git status collection across repositories. package statusset import ( @@ -51,7 +50,6 @@ func Collect(ctx context.Context, application app.App, repositories []repository return states } -// ErrorCount returns the number of states whose collection failed. func ErrorCount(states []gitstatus.State) int { count := 0 for _, state := range states { diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index c660b69..2ca8ca3 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -34,8 +34,6 @@ func New(executable string) Runner { return Runner{Executable: executable} } -// NewRunner is an alias for New for callers that prefer a constructor whose -// name describes the returned value. func NewRunner(executable string) Runner { return New(executable) } diff --git a/internal/gitstatus/status.go b/internal/gitstatus/status.go index 83a1318..9ccf0a1 100644 --- a/internal/gitstatus/status.go +++ b/internal/gitstatus/status.go @@ -58,7 +58,6 @@ type CollectOptions struct { NoAheadBehind bool } -// NewCollector returns a collector backed by runner. func NewCollector(runner gitexec.Runner) Collector { return Collector{Runner: runner} } @@ -164,7 +163,6 @@ func Parse(data []byte) (State, error) { return state, nil } -// ParseString is a convenience wrapper for callers that already have text. func ParseString(data string) (State, error) { return Parse([]byte(data)) } diff --git a/internal/gitstatus/status_test.go b/internal/gitstatus/status_test.go index 1fe8d0f..8d8fcf6 100644 --- a/internal/gitstatus/status_test.go +++ b/internal/gitstatus/status_test.go @@ -208,8 +208,6 @@ func TestCollectorReturnsUsefulErrorState(t *testing.T) { } } -// Keep the test's Runner construction local to this package's public API -// without importing implementation details from the collector. func gitexecRunner(executable string) gitexec.Runner { return gitexec.Runner{Executable: executable} } From 4475127a30069e9c36edd489969700a446aab527 Mon Sep 17 00:00:00 2001 From: ramonzx6 Date: Sat, 22 Aug 2026 08:45:09 +0000 Subject: [PATCH 6/7] docs: add MkDocs documentation site --- .gitignore | 2 + CONTRIBUTING.md | 16 +- Makefile | 13 +- README.md | 300 +++------------------------------ docs/commands.md | 230 +++++++++++++++++++++++++ docs/development.md | 82 +++++++++ docs/discovery.md | 88 ++++++++++ docs/getting-started.md | 86 ++++++++++ docs/index.md | 28 +++ docs/output-and-exit-status.md | 90 ++++++++++ docs/safety.md | 74 ++++++++ mkdocs.yml | 13 ++ requirements-docs.txt | 1 + 13 files changed, 746 insertions(+), 277 deletions(-) create mode 100644 docs/commands.md create mode 100644 docs/development.md create mode 100644 docs/discovery.md create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 docs/output-and-exit-status.md create mode 100644 docs/safety.md create mode 100644 mkdocs.yml create mode 100644 requirements-docs.txt diff --git a/.gitignore b/.gitignore index 83a137a..5a3e5be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env +/.venv/ /gitmulti +/site/ test/ /gitmulti.exe reviewtmp/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9fe64a..c7099c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,8 @@ by that license. 1. Read the applicable AGENTS.md instructions. 2. Keep changes focused and preserve unrelated work. 3. Add or update tests for behavior changes and regressions. -4. Update the README when command flags, output, or exit behavior changes. +4. Update the relevant page in `docs/` when command flags, output, or exit + behavior changes. Keep the README's documentation links current. 5. Do not include credentials, private repository URLs, or generated binaries. The CLI is intentionally shell-free: pass Git arguments as positional values @@ -52,6 +53,19 @@ make build make smoke ~~~ +Documentation changes also require a strict MkDocs build. Create an isolated +environment, install the pinned documentation dependency, and build the site: + +~~~sh +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-docs.txt +make docs-build +~~~ + +On PowerShell, activate the environment with +`.\.venv\Scripts\Activate.ps1` before installing the dependency. + If a required tool or platform-specific check is unavailable, report it as skipped in the change description rather than treating it as passed. The GitHub Actions workflow repeats formatting, vet, tests, race tests, and native diff --git a/Makefile b/Makefile index fd9ccb3..6d28719 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ GO ?= go GOFMT ?= gofmt +DOCS_PYTHON ?= python3 +DOCS_REQUIREMENTS ?= requirements-docs.txt BINARY ?= gitmulti VERSION ?= dev COMMIT ?= unknown @@ -7,7 +9,7 @@ BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS ?= -s -w -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.Version=$(VERSION) -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.Commit=$(COMMIT) -X github.com/ramonzx6/gitmulti/internal/cli/buildinfo.BuildDate=$(BUILD_DATE) -.PHONY: all fmt fmt-check vet test race build smoke check clean +.PHONY: all fmt fmt-check vet test race build smoke check docs-install docs-build docs-serve clean all: check @@ -35,5 +37,14 @@ smoke: build check: fmt-check vet test race build smoke +docs-install: + $(DOCS_PYTHON) -m pip install -r "$(DOCS_REQUIREMENTS)" + +docs-build: + $(DOCS_PYTHON) -m mkdocs build --strict + +docs-serve: + $(DOCS_PYTHON) -m mkdocs serve + clean: rm -f "$(BINARY)" "$(BINARY).exe" diff --git a/README.md b/README.md index 74fa589..2a54e05 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ GitMulti discovers Git repositories below a directory and reports on or runs Git operations across them. It is a single, shell-free Go binary built with Cobra. -## Requirements and installation +## Install -- Go 1.24 or newer (only needed to build or install GitMulti) -- Git available on `PATH` when a command inspects or operates on repositories +GitMulti requires Go 1.24 or newer to build or install, and Git on `PATH` when +a command inspects or operates on repositories. Install the latest version with Go: @@ -15,21 +15,17 @@ Install the latest version with Go: go install github.com/ramonzx6/gitmulti/cmd/gitmulti@latest ``` -The binary is placed in `go env GOBIN`, or `go env GOPATH`/`bin` when -`GOBIN` is unset. Add that directory to `PATH` if `gitmulti` is not -found. This works from PowerShell as well as Unix shells. - To build from a checkout: ```sh git clone https://github.com/ramonzx6/gitmulti.git cd gitmulti go build -o gitmulti ./cmd/gitmulti -./gitmulti --help ``` On Windows, use `go build -o gitmulti.exe ./cmd/gitmulti` and run -`./gitmulti.exe`. +`./gitmulti.exe`. If `gitmulti` is not found after `go install`, add +`go env GOBIN` (or `go env GOPATH`/`bin` when `GOBIN` is unset) to `PATH`. ## Quick start @@ -50,277 +46,31 @@ gitmulti exec --path ~/src --recursive -- status --short gitmulti sync --path ~/src --recursive --dry-run ``` -Run `gitmulti --help` or `gitmulti --help` for the complete flag -list of the installed binary. - -## Repository discovery - -All repository-oriented commands accept these discovery flags: - -| Flag | Meaning | -| --- | --- | -| `-p, --path DIR` | Root directory to scan; defaults to `.`. The root must be a directory. | -| `-r, --recursive` | Traverse below direct children. Without it, only direct children are inspected. | -| `--include-root` | Include `DIR` itself when it is a repository. It is excluded by default. | -| `--include-nested` | Continue traversing below a discovered repository. Requires `--recursive`. | -| `--max-depth N` | Limit recursive depth measured from `DIR`; `0` means unlimited. Requires `--recursive`. | -| `--match GLOB` | Include only repository names matching this repeatable pattern. | -| `--exclude GLOB` | Exclude names matching this repeatable pattern; exclusion wins over inclusion. | -| `--include-hidden` | Visit dot-prefixed directories, which are skipped by default. | -| `--follow-symlinks` | Follow directory symlinks with cycle protection; targets outside the canonical root are skipped. | -| `--include-bare` | Include bare repositories; worktrees are detected by default. | -| `--require-match` | Exit with an error when no repositories match (where supported). | - -Repository names are slash-normalized paths relative to `DIR`; the selected -root is named `.` when included. `--match` and `--exclude` use Go's -`path.Match` syntax (`*`, `?`, and bracket classes). A pattern's `*` -does not cross `/`, and `**` has no special recursive meaning. Repeat the -flag for multiple patterns. - -Finding a repository creates a traversal boundary by default, so recursive -scans do not unexpectedly enter its working tree or discover nested clones. -Use `--include-nested` when that is intentional. Hidden directories, -symlinks, and bare repositories are opt-in. Filesystem problems below the root -are warnings and discovery continues where possible; an invalid or unreadable -root is fatal. Warnings are written to stderr and are included in status JSON -diagnostics. - -## Commands - -### `list` / `ls` - -List discovered repositories. The default output is one relative name per -line. - -```sh -gitmulti list --recursive --format relative -gitmulti list --count -gitmulti list --format json > repositories.json -gitmulti list --format ndjson -``` - -Command-specific flags: - -- `--format name|relative|absolute|json|ndjson` (default `relative`) -- `--absolute`, equivalent to `--format absolute` for text output -- `-0, --null`, terminate text records with NUL for safe pipelines -- `--count`, print only the number of repositories -- `--validate`, ask Git to validate each repository marker -- `--require-match`, exit 1 if no repositories match - -Validation failures (including malformed `.git` files) are warnings; the -candidate is skipped and the remaining scan continues. - -### `exec` / `ex` - -Run one Git command in each discovered repository. Positional arguments after -`--` are preferred and preserve argument boundaries: - -```sh -gitmulti exec -- status --short -gitmulti exec --jobs 4 --output prefixed -- fetch --prune -gitmulti exec --path ~/src --recursive --match 'services/*' -- test -``` - -`-c, --command 'status --short'` is a deprecated compatibility form; do not -combine it with positional arguments. GitMulti invokes Git directly, without a -shell, so shell operators and expansion are not interpreted. - -Execution flags are `-j, --jobs N` (default `1`), `--fail-fast`, -`--timeout DURATION`, `--dry-run`, -`--output grouped|prefixed|raw|json|ndjson` (default `grouped`), -`--interactive`, `-q, --quiet`, and `--require-match`. Interactive mode -inherits stdin and forces sequential raw output (`--jobs=1`). Raw output -also requires `--jobs=1`. JSON is a `schemaVersion`/`results` document; -NDJSON emits one `type: execution` record per repository. Both retain -stdout, stderr, exit code, duration, and error details. Grouped output buffers -each repository's output and prints it in discovery order; prefixed and raw -output stream while Git runs. Git failures do not stop remaining repositories -unless `--fail-fast` is set; any failure exits 1. - -### `status` - -Show repository, branch, clean/dirty/detached/error state, upstream, and -ahead/behind counts. Use `--dirty-only`, `--format -table|json|ndjson` (default `table`), `--no-ahead-behind`, -`-j, --jobs N` (default `4`), and `--require-match`. Status failures are -rendered where possible and exit 1. `--no-ahead-behind` asks Git to skip the -tracking-count calculation; machine output marks those counts as unknown. - -```sh -gitmulti status --recursive --dirty-only -gitmulti status --format json > status.json -gitmulti status --format ndjson --no-ahead-behind -``` - -### `check` - -Apply repository-state policies for CI or preflight checks. Defaults are -`dirty,diverged`. - -```sh -gitmulti check --recursive --fail-on dirty,unpushed,behind,detached -gitmulti check --format json -gitmulti check --fetch --path ~/src --recursive -``` +GitMulti invokes Git directly, without a shell. Read the documentation before +running commands that can change repositories or contact remotes. -`--fail-on LIST` accepts `dirty`, `unpushed`, `behind`, `diverged`, -`detached`, and `no-upstream`; values may be comma-separated or repeated. -Other flags are `--fetch` (run `git fetch --all --prune` first), -`--format table|json|ndjson` (default `table`), `-q, --quiet`, -`-j, --jobs N` (default `4`), `--timeout DURATION` (for fetches), -`--no-prompt` (set `GIT_TERMINAL_PROMPT=0`), and `--require-match`. -Violations and inspection/fetch failures exit 1; invalid policies or flags -exit 2. Machine formats still emit a structured result when a fetch fails. +## Documentation -### `doctor` - -Diagnose Git availability, discovery warnings, malformed markers, and, with -`--status`, detached or upstream-less repositories. Malformed `.git` files -and other discovery issues are reported as findings; they do not abort the -scan. - -```sh -gitmulti doctor --path ~/src --recursive --status -gitmulti doctor --format json --strict -``` +The [GitMulti documentation](docs/index.md) is the complete command manual: -Use `--format text|json` (default `text`), `--status`, `--strict` -(warnings also fail), and `-j, --jobs N` (default `4`). Errors exit 1; -`--strict` also turns warnings into exit 1. - -### `sync` - -Synchronize repositories conservatively. The default `fetch` mode only -fetches. `ff-only` checks state first and runs `git pull --ff-only`; it -refuses dirty, detached, unborn, or upstream-less repositories rather than -merging or rebasing them. - -```sh -gitmulti sync --recursive --dry-run -gitmulti sync --recursive --mode fetch --remote origin --prune -gitmulti sync --recursive --mode ff-only --output json --timeout 2m -``` - -Flags are `--mode fetch|ff-only` (default `fetch`), `--remote NAME` -(fetch defaults to `--all`), `--prune` (default true), `--tags`, -`--dry-run`, `-j, --jobs N` (default `1`), `--fail-fast`, -`--timeout DURATION`, `--no-prompt` (disable Git credential prompts), -`--output grouped|prefixed|raw|json|ndjson` -(default `grouped`), and `--require-match`. Raw output requires -`--jobs=1`. Sync failures and refused unsafe updates exit 1. - -Sync uses Git's configured credential helpers and environment. Do not put -credentials in command-line arguments or scripts, and treat command output as -sensitive. `--dry-run` does not contact remotes or read credentials. - -### `snapshot` - -Export each repository's current commit and branch as JSON or newline-delimited -JSON. By default output goes to stdout; `--output FILE` creates a new file -with mode `0600`, installs it atomically, and refuses to overwrite it unless -`--force` is supplied. Symlink output destinations are refused. - -```sh -gitmulti snapshot --recursive --output snapshot.json -gitmulti snapshot --recursive --include-remotes --format ndjson -``` - -Use `--format json|ndjson` (default `json`), `-o, --output FILE`, -`--include-remotes`, `--force`, and `--require-match`. Remote URLs are -redacted when `--include-remotes` is enabled: HTTP(S) userinfo, passwords, and -common token, key, secret, signature, credential, and authorization query -parameters are replaced with `REDACTED`. Treat snapshots as sensitive -nevertheless. -Inspection and no-match failures exit 1; invalid arguments exit 2. - -### Trust boundaries - -GitMulti never passes `exec` arguments through a shell, and its fixed status -collector disables repository-configured filesystem-monitor hooks. Git itself -is not a sandbox, however. Git aliases, credential and remote helpers, hooks, -remote URLs, and other repository or user configuration can invoke external -programs. Run `exec`, `check --fetch`, and `sync` only on repositories and Git -configuration you trust. Scan roots must not be concurrently writable by an -untrusted user; static followed symlinks are canonicalized and confined to the -selected root, but GitMulti does not provide descriptor-level isolation from -hostile filesystem replacement races. - -Timeouts bound how long GitMulti waits for a command, including when a -descendant keeps an output pipe open. Cancellation is not guaranteed to -terminate every grandchild process on every operating system. - -### `completion` - -Generate completion scripts for `bash`, `zsh`, `fish`, or `powershell`: - -```sh -gitmulti completion bash > ~/.local/share/bash-completion/completions/gitmulti -gitmulti completion zsh > "${fpath[1]}/_gitmulti" -gitmulti completion fish > ~/.config/fish/completions/gitmulti.fish -gitmulti completion powershell > gitmulti.ps1 -``` - -### `version` / `v` - -Print version and build metadata. `gitmulti --version` and `gitmulti -v` -print the short version; `version --short` does the same. `version --json` -emits `version`, `commit`, `buildDate`, `dirty`, `goVersion`, and -`module`. -Release builds set version, commit, and UTC build date through linker flags; -local builds report `dev` and `unknown` when metadata is unavailable. - -## Output and exit status - -JSON documents use schema version `1` where a `schemaVersion` field is -present. NDJSON emits one complete JSON object per line. List JSON is a -`schemaVersion`/`repositories` document and list NDJSON records have -`type: repository` or `type: diagnostic`. Execution and sync JSON are -`schemaVersion`/`results` documents, while their NDJSON records have -`type: execution`. Status, check, and snapshot NDJSON records also include a -`type` field. JSON goes to stdout and diagnostics/warnings go to stderr. -Machine documents also retain discovery warnings as structured diagnostics. - -- `0`: completed successfully and all requested checks passed -- `1`: Git execution, synchronization, status, policy, doctor, or - `--require-match` failure -- `2`: invalid arguments or options - -## Development - -The repository's Makefile defines the complete project gate: - -```sh -make check -``` - -It checks formatting, runs `go vet`, the full tests and race detector, builds -with metadata, and runs a help/version smoke test. Individual targets include -`make fmt`, `make fmt-check`, `make vet`, `make test`, `make race`, -`make build`, and `make smoke`. See [CONTRIBUTING.md](CONTRIBUTING.md) for -the contribution workflow and validation requirements. - -The Go source follows the standard command/internal split: - -```text -cmd/gitmulti/ executable entry point only -internal/cli/ root construction and shared CLI adapters -internal/cli/command// one package per user-facing command -internal/gitexec/ direct Git process execution -internal/gitstatus/ porcelain status parsing -internal/repository/ repository discovery -``` +- [Getting started](docs/getting-started.md): requirements, installation, and + first workflows. +- [Repository discovery](docs/discovery.md): scan roots, recursion, filters, + symlinks, and repository boundaries. +- [Commands and workflows](docs/commands.md): every command, alias, option, + and common usage pattern. +- [Output and exit status](docs/output-and-exit-status.md): text, JSON, + NDJSON, diagnostics, and automation contracts. +- [Safety and trust boundaries](docs/safety.md): shell behavior, Git + configuration, credentials, synchronization, and snapshot handling. +- [Development](docs/development.md): Go checks, documentation setup, and + contribution guidance. -Command packages expose a small `New(...)` constructor and keep their flag -state, renderers, and focused tests together. Reusable scheduling, discovery -flags, exit errors, build metadata, and status batching live in neutral -`internal/cli` subpackages so leaf commands do not import the root or each -other. +Run `gitmulti --help` or `gitmulti --help` for the options supported +by the installed binary. ## License This project is source-available under the -[PolyForm Strict License 1.0.0](LICENSE). Noncommercial and personal uses are -permitted under that license. It does not grant permission to distribute the -software or make changes or derivative works without permission. Read the -complete license before using or redistributing this source. +[PolyForm Strict License 1.0.0](LICENSE). Read the complete license before +using or redistributing this source. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..2008c7f --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,230 @@ +# Commands and workflows + +The installed binary's `--help` output is authoritative if a future release +adds an option. The command summary below documents the current interface. +Common scan flags are described in [Repository discovery](discovery.md). + +## Command summary + +| Command | Alias | Purpose | +| --- | --- | --- | +| `list` | `ls` | List discovered repositories. | +| `exec` | `ex` | Run a Git command in each discovered repository. | +| `status` | — | Show branch, working-tree, upstream, and ahead/behind state. | +| `check` | — | Enforce repository-state policies. | +| `doctor` | — | Diagnose Git and discovery problems. | +| `sync` | — | Fetch or fast-forward repositories conservatively. | +| `snapshot` | — | Export current revisions, branches, and optionally remotes. | +| `completion` | — | Generate Bash, Zsh, Fish, or PowerShell completion. | +| `version` | `v` | Print version and build metadata. | + +All commands reject unexpected positional arguments except `exec` and +`completion`, which use the positional forms shown below. + +## `list` / `ls` + +List repositories in relative-name order. The default is one relative name +per line. + +```sh +gitmulti list --recursive --format relative +gitmulti list --count +gitmulti list --format json > repositories.json +gitmulti list --format ndjson +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--format name\|relative\|absolute\|json\|ndjson` | `relative` | Select text or machine-readable output. `name` prints the final path component. | +| `--absolute` | off | Equivalent to `--format absolute` for text output. | +| `-0, --null` | off | Terminate text records with NUL; only works with text formats. | +| `--count` | off | Print only the repository count. It cannot be combined with `--null`, `--absolute`, or a non-relative format. | +| `--validate` | off | Ask Git to validate each repository marker; failures become warnings and the candidate is skipped. | +| `--require-match` | off | Exit 1 when no repositories match. | + +## `exec` / `ex` + +Run one Git command in every discovered repository: + +```sh +gitmulti exec -- status --short +gitmulti exec --jobs 4 --output prefixed -- fetch --prune +gitmulti exec --path ~/src --recursive --match 'services/*' -- test +``` + +Arguments after `--` preserve their boundaries. GitMulti invokes Git +directly, so shell operators, pipes, variable expansion, and command +substitution are not interpreted. The deprecated `-c, --command` compatibility +form parses one quoted command string, such as `'status --short'`; do not +combine it with positional arguments. Prefer the positional form. + +| Option | Default | Description | +| --- | --- | --- | +| `-j, --jobs N` | `1` | Maximum concurrent Git processes; must be at least 1. | +| `--fail-fast` | off | Cancel remaining work after the first failure. | +| `--timeout DURATION` | no timeout | Per-repository timeout; negative values are invalid. | +| `--dry-run` | off | Print the `git -C ...` commands without executing Git. | +| `--output grouped\|prefixed\|raw\|json\|ndjson` | `grouped` | Select output behavior; see [Output and exit status](output-and-exit-status.md). | +| `--interactive` | off | Inherit stdin and force sequential raw output; requires `--jobs=1`. | +| `-q, --quiet` | off | Suppress repository headings in grouped output. | +| `--require-match` | off | Exit 1 when no repositories match. | + +Raw output also requires `--jobs=1`. Without `--fail-fast`, a Git failure is +reported and execution continues in the other repositories. + +## `status` + +Show repository, branch, clean/dirty/detached/error state, upstream, and +ahead/behind counts: + +```sh +gitmulti status --recursive --dirty-only +gitmulti status --format json > status.json +gitmulti status --format ndjson --no-ahead-behind +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--dirty-only` | off | Show only dirty repositories and repositories whose status collection failed. | +| `--format table\|json\|ndjson` | `table` | Select output format. | +| `--no-ahead-behind` | off | Skip tracking-count calculation; machine output marks counts unknown and table output uses `-`. | +| `-j, --jobs N` | `4` | Maximum concurrent status checks; must be at least 1. | +| `--require-match` | off | Exit 1 when no repositories match. | + +Status failures are rendered where possible and return exit code 1. + +## `check` + +Apply repository-state policies for CI or preflight checks. The default +policies are `dirty,diverged`: + +```sh +gitmulti check --recursive --fail-on dirty,unpushed,behind,detached +gitmulti check --format json +gitmulti check --fetch --path ~/src --recursive +``` + +`--fail-on` accepts `dirty`, `unpushed`, `behind`, `diverged`, +`detached`, and `no-upstream`. Values may be comma-separated or supplied +repeatedly. Duplicate values are ignored. A policy violation returns exit code +1. + +| Option | Default | Description | +| --- | --- | --- | +| `--fail-on LIST` | `dirty,diverged` | Conditions that fail the check. | +| `--fetch` | off | Run `git fetch --all --prune` before checking. | +| `--format table\|json\|ndjson` | `table` | Select output format. | +| `-q, --quiet` | off | Produce no output; use only the exit status. | +| `-j, --jobs N` | `4` | Maximum concurrent Git processes; applies to fetch and status. | +| `--timeout DURATION` | no timeout | Per-repository fetch timeout. | +| `--no-prompt` | off | Set `GIT_TERMINAL_PROMPT=0` for network operations. | +| `--require-match` | off | Exit 1 when no repositories match. | + +Fetch failures and status-inspection failures also return 1. Invalid policies +and options return 2. Machine formats retain structured results when a fetch +fails. + +## `doctor` + +Diagnose Git availability, discovery warnings, malformed markers, and (with +`--status`) detached or upstream-less repositories: + +```sh +gitmulti doctor --path ~/src --recursive --status +gitmulti doctor --format json --strict +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--format text\|json` | `text` | Select human-readable or machine-readable findings. | +| `--status` | off | Check detached HEAD and upstream state. | +| `--strict` | off | Treat warnings as failures as well as errors. | +| `-j, --jobs N` | `4` | Maximum concurrent status checks when `--status` is set. | + +Doctor does not support `--require-match`. Errors return 1; `--strict` also +returns 1 for warnings. Discovery problems are reported as findings rather +than aborting a scan where possible. + +## `sync` + +Synchronize repositories conservatively. The default `fetch` mode only +fetches. `ff-only` checks state first and runs `git pull --ff-only`; it +refuses dirty, detached, unborn, or upstream-less repositories rather than +merging or rebasing them. + +```sh +gitmulti sync --recursive --dry-run +gitmulti sync --recursive --mode fetch --remote origin --prune +gitmulti sync --recursive --mode ff-only --output json --timeout 2m +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--mode fetch\|ff-only` | `fetch` | Select fetch-only or safe fast-forward synchronization. | +| `--remote NAME` | fetch all | Fetch a specific remote. In `fetch` mode, an empty value uses `--all`; in `ff-only` mode an empty value uses the configured upstream. A name beginning with `-` is rejected. | +| `--prune` | on | Prune deleted remote-tracking references. | +| `--tags` | off | Fetch tags. | +| `--dry-run` | off | Print commands without running Git or safety checks; it does not contact remotes or read credentials. | +| `-j, --jobs N` | `1` | Maximum concurrent Git processes; must be at least 1. | +| `--fail-fast` | off | Cancel remaining work after the first failure or refusal. | +| `--timeout DURATION` | no timeout | Per-repository timeout. | +| `--no-prompt` | off | Set `GIT_TERMINAL_PROMPT=0` for network operations. | +| `--output grouped\|prefixed\|raw\|json\|ndjson` | `grouped` | Select output behavior. | +| `--require-match` | off | Exit 1 when no repositories match. | + +Raw output requires `--jobs=1`. Sync failures and refused unsafe updates +return exit code 1. + +## `snapshot` + +Export each repository's current commit and branch as JSON or newline-delimited +JSON. Output goes to stdout unless `--output FILE` is supplied: + +```sh +gitmulti snapshot --recursive --output snapshot.json +gitmulti snapshot --recursive --include-remotes --format ndjson +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--format json\|ndjson` | `json` | Select the document or record-per-line format. | +| `-o, --output FILE` | stdout | Create a new output file with mode `0600`; refuse to overwrite unless `--force` is set. Symlink destinations are refused. | +| `--include-remotes` | off | Include configured remote URLs after credential-like material is redacted. | +| `--force` | off | Allow replacement of an existing regular output file. | +| `--require-match` | off | Exit 1 when no repositories match. | + +Remote URLs are redacted when `--include-remotes` is enabled, but snapshots +should still be treated as sensitive. Inspection and no-match failures return +1; invalid arguments return 2. + +## `completion` + +Generate a completion script for exactly one supported shell: + +```sh +gitmulti completion bash +gitmulti completion zsh +gitmulti completion fish +gitmulti completion powershell +``` + +The command accepts no flags other than `--help` and requires one of the four +listed shell names. + +## `version` / `v` + +Print version and build metadata: + +```sh +gitmulti --version +gitmulti -v +gitmulti version --short +gitmulti version --json +``` + +`gitmulti --version` and `gitmulti -v` print the short version, as does +`version --short`. `--json` emits `version`, `commit`, `buildDate`, +`dirty`, `goVersion`, and `module` where available. The two +`version` flags cannot be combined. Release builds set version, commit, and +UTC build date through linker flags; local builds use `dev` and `unknown` +when metadata is unavailable. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..da1bcf3 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,82 @@ +# Development + +GitMulti is a Go project. User-facing commands live in +`internal/cli/command/`, repository discovery lives in +`internal/repository`, direct Git execution in `internal/gitexec`, and +porcelain status parsing in `internal/gitstatus`. The executable entry point +is `cmd/gitmulti`. + +## Go checks + +Go 1.24 or newer and Git are required for local development. The complete +project gate is: + +```sh +make check +``` + +It runs formatting validation, `go vet`, all tests, race tests, a metadata- +bearing build, and help/version smoke tests. Individual targets are available +while iterating: + +```sh +make fmt # format Go files in place +make fmt-check +make vet +make test +make race +make build +make smoke +``` + +Keep changes focused, add or update tests for behavior changes, and preserve +the shell-free execution boundary. The repository's +[contribution guide](https://github.com/ramonzx6/gitmulti/blob/main/CONTRIBUTING.md) +contains the full checklist, validation expectations, and vulnerability +reporting guidance. + +The project is source-available under the PolyForm Strict License 1.0.0. +Noncommercial and personal uses are permitted under that license, but it does +not grant permission to distribute the software or create changes or +derivative works without permission. Read the +[complete license](https://github.com/ramonzx6/gitmulti/blob/main/LICENSE) +before contributing, using, or redistributing the source. + +## Documentation setup + +The documentation site uses MkDocs' built-in theme and has no runtime Go or +third-party theme/plugin dependency. `requirements-docs.txt` pins the MkDocs +version used to build the site. + +For an isolated local setup: + +```sh +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-docs.txt +``` + +On PowerShell, activate with: + +```powershell +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements-docs.txt +``` + +Build the site with strict warning handling: + +```sh +make docs-build +``` + +Serve it locally with automatic rebuilds: + +```sh +make docs-serve +``` + +The targets invoke `python3 -m mkdocs` by default and do not install packages +or fetch dependencies. Install dependencies explicitly with `make +docs-install` or the `pip` command above. Documentation is intentionally +separate from `make check`, so a normal Go validation does not perform +network installation. diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 0000000..11b6e24 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,88 @@ +# Repository discovery + +Repository-oriented commands scan a root directory and return repositories in +deterministic slash-normalized relative-name order. The root itself is named +`.` when it is included. All paths passed to Git are cleaned absolute paths. + +## The scan root + +`--path DIR` (or `-p DIR`) selects the root and defaults to `.`. The root must +be an existing directory. Without `--recursive` (or `-r`), GitMulti inspects +only direct child directories. `--recursive` traverses deeper directories. + +The root is excluded even when it is itself a repository unless +`--include-root` is set. A discovered repository is a traversal boundary by +default, so recursive scans do not enter its working tree. Use +`--include-nested` together with `--recursive` when nested repositories are +intentional. + +`--max-depth N` limits recursive traversal by depth measured from `DIR`; +`0` means unlimited. It requires `--recursive`. A negative depth is invalid. + +## Common discovery options + +These options are available on repository-oriented commands. `--validate` is +currently exposed by `list`; `--require-match` is exposed by commands that +can act on an empty result, as described in [Commands and +workflows](commands.md). + +| Option | Description | +| --- | --- | +| `-p, --path DIR` | Root directory to scan; defaults to `.`. | +| `-r, --recursive` | Traverse below direct children. | +| `--include-root` | Include `DIR` itself when it is a repository. | +| `--include-nested` | Continue below discovered repositories; requires `--recursive`. | +| `--max-depth N` | Limit recursive depth from `DIR`; `0` means unlimited and requires `--recursive`. | +| `--match GLOB` | Include only names matching this repeatable pattern. | +| `--exclude GLOB` | Exclude names matching this repeatable pattern; exclusion wins. | +| `--include-hidden` | Visit dot-prefixed directories, skipped by default. | +| `--follow-symlinks` | Follow directory symlinks with cycle protection. Targets outside the canonical root are skipped. | +| `--include-bare` | Include detected bare repositories. Worktrees are detected by default. | +| `--validate` | Ask Git to validate each candidate marker. Available on `list`. | +| `--require-match` | Exit with code 1 when no repositories match, where supported. | + +## Matching and boundaries + +`--match` and `--exclude` use Go's `path.Match` syntax: `*`, `?`, and +bracket classes. Patterns match slash-normalized names relative to `DIR`; +`*` does not cross `/`, and `**` has no special recursive meaning. Repeat +either flag for multiple patterns. Exclusion always wins over inclusion. + +For example, this selects only immediate children below `services` while +excluding an archived service: + +```sh +gitmulti list --path ~/src --recursive \ + --match 'services/*' --exclude 'services/archive' +``` + +A repository boundary is respected even if a match or exclusion filter removes +that repository from the results. This prevents traversal from escaping an +excluded parent unexpectedly. `--include-nested` deliberately changes that +behavior. + +## Hidden directories, symlinks, and bare repositories + +Dot-prefixed directories are skipped unless `--include-hidden` is set. This +also prevents accidental traversal of hidden trees that happen to contain +clones. + +Directory symlinks are skipped unless `--follow-symlinks` is set. Followed +links are canonicalized, cycle-checked, and confined to the canonical scan +root; links outside it produce warnings and are skipped. GitMulti stores the +canonical path for a followed repository so a later link retarget cannot +redirect the Git operation. + +Worktrees are identified by a `.git` directory or a validated `.git` file. +Malformed markers are warnings and the candidate is skipped. Bare repositories +are opt-in with `--include-bare`; a discovered bare repository is marked +`bare: true` in machine-readable output. A worktree's `.git` metadata is +never treated as another bare repository. + +## Warnings and failures + +Filesystem problems below the root, malformed markers, and Git-backed marker +validation failures are non-fatal warnings when discovery can continue. Text +warnings go to stderr; JSON and NDJSON commands retain them as structured +diagnostics. An invalid, unreadable, or non-directory root is fatal. Invalid +glob patterns and incompatible discovery options are usage errors. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..1b34149 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,86 @@ +# Getting started + +## Requirements + +- Go 1.24 or newer is required only when building or installing GitMulti. +- Git must be available on `PATH` for commands that inspect or operate on + repositories. +- A scan root must be an existing directory. The default root is `.`. + +## Install + +Install the latest published version with Go: + +```sh +go install github.com/ramonzx6/gitmulti/cmd/gitmulti@latest +``` + +Go places the binary in `go env GOBIN`, or in `go env GOPATH`/`bin` when +`GOBIN` is unset. Add that directory to `PATH` if the `gitmulti` command is +not found. The same command works from PowerShell and Unix shells. + +To build from source: + +```sh +git clone https://github.com/ramonzx6/gitmulti.git +cd gitmulti +go build -o gitmulti ./cmd/gitmulti +./gitmulti --help +``` + +On Windows, use `go build -o gitmulti.exe ./cmd/gitmulti` and run +`./gitmulti.exe`. + +## First workflows + +Start by seeing which direct child directories are repositories: + +```sh +gitmulti list +``` + +Scan below a source directory and show repository state: + +```sh +gitmulti list --path ~/src --recursive +gitmulti status --path ~/src --recursive +``` + +Run Git across the discovered repositories. Arguments after `--` are passed +as separate arguments; do not include the word `git`: + +```sh +gitmulti exec --path ~/src --recursive -- status --short +``` + +Preview a fetch before allowing any network operation: + +```sh +gitmulti sync --path ~/src --recursive --dry-run +``` + +Discovery is intentionally conservative: repositories are boundaries by +default, hidden directories and directory symlinks are skipped, and bare +repositories require an explicit option. See [Repository discovery](discovery.md) +for the full model and [Commands and workflows](commands.md) for every option. + +## Help and completion + +Use the installed binary as the authoritative source for its exact options: + +```sh +gitmulti --help +gitmulti status --help +``` + +Generate completion scripts for Bash, Zsh, Fish, or PowerShell: + +```sh +gitmulti completion bash > ~/.local/share/bash-completion/completions/gitmulti +gitmulti completion zsh > "${fpath[1]}/_gitmulti" +gitmulti completion fish > ~/.config/fish/completions/gitmulti.fish +gitmulti completion powershell > gitmulti.ps1 +``` + +Completion output is a script for the selected shell; installing it is a +shell-specific step. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5b54c49 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,28 @@ +# GitMulti CLI + +GitMulti finds Git repositories below a directory, then reports on or runs +Git operations across them. It is a single Go binary that invokes Git directly +instead of invoking a shell. + +Use the navigation to learn the complete CLI. The shortest path to a useful +first run is: + +1. [Install GitMulti](getting-started.md#install). +2. [Choose a discovery root](discovery.md#the-scan-root). +3. Run `gitmulti list --path PATH --recursive` to inspect what will be acted + on. +4. Start with a read-only command such as `status`, or preview a synchronization + with `sync --dry-run`. + +## What GitMulti does + +- Lists repositories and exports their current revision and branch. +- Summarizes clean, dirty, detached, upstream, and ahead/behind state. +- Runs one Git command across selected repositories with bounded concurrency. +- Checks repository policies for CI and preflight workflows. +- Fetches or performs conservative fast-forward-only synchronization. +- Generates shell completion scripts and reports build metadata. + +Before using `exec`, `check --fetch`, or `sync`, read [Safety and trust +boundaries](safety.md). Git configuration and hooks remain outside GitMulti's +sandbox. diff --git a/docs/output-and-exit-status.md b/docs/output-and-exit-status.md new file mode 100644 index 0000000..64ea95f --- /dev/null +++ b/docs/output-and-exit-status.md @@ -0,0 +1,90 @@ +# Output and exit status + +GitMulti keeps human output useful in a terminal while providing stable JSON +and NDJSON shapes for automation. JSON goes to stdout. Warnings, diagnostics, +and process errors go to stderr unless they are represented in a machine +document as described below. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | The command completed successfully and all requested checks passed. | +| `1` | A Git execution, synchronization, status, policy, doctor, snapshot, or `--require-match` operation failed. `--strict` warnings and unsafe `ff-only` candidates also use this code. | +| `2` | Arguments or options were invalid, including an unknown command or incompatible flags. | + +Commands render available output before returning a runtime failure where +possible. A caller should use the exit code for success/failure and parse +stdout independently from stderr. + +## Text output + +`list` prints one repository name per line by default. `--null` uses NUL +separators for safe pipelines. `status` and `check` print tab-separated +tables with headers. `exec` and `sync` support these modes: + +- `grouped` buffers each repository's output and prints it in discovery order. +- `prefixed` streams output with a repository prefix on each line. +- `raw` streams unmodified output and requires `--jobs=1`. + +`exec --interactive` inherits stdin and forces sequential raw output. `--quiet` +on `exec` suppresses grouped repository headings; `--quiet` on `check` +emits no output at all. + +## JSON documents + +Machine-readable document formats currently use schema version `1` in a +`schemaVersion` field. Discovery warnings are retained in a `diagnostics` +array when a command emits an envelope. + +| Command | JSON document | NDJSON record types | +| --- | --- | --- | +| `list` | `schemaVersion`, `repositories`, optional `diagnostics` | `repository`, `diagnostic` | +| `exec` | `schemaVersion`, `results`, optional `diagnostics` | `execution`, `diagnostic` | +| `sync` | `schemaVersion`, `results`, optional `diagnostics` | `execution`, `diagnostic` | +| `status` | `schemaVersion`, `repositories`, optional `diagnostics` | `status`, `diagnostic` | +| `check` | `schemaVersion`, `results`, optional `diagnostics` | `check`, `diagnostic` | +| `doctor` | `schemaVersion`, `findings` | — | +| `snapshot` | `schemaVersion`, `repositories`, optional `diagnostics` | `repository`, `diagnostic` | +| `version --json` | Build metadata object; no schema envelope | — | + +NDJSON emits one complete JSON object per line. A diagnostic record represents +a discovery warning and is emitted after the repository/result records. + +### Execution and synchronization records + +`exec` and `sync` records include the repository name and path, the direct +Git argument array, captured stdout and stderr where buffering is used, the Git +exit code, duration, and error details when applicable. Fail-fast cancellation +can mark work as `skipped`. JSON preserves repository discovery order even +when multiple jobs run concurrently. + +### Status and policy records + +Status records report branch, `HEAD`, upstream, dirty/changed/untracked, +detached/unborn/no-upstream state, and ahead/behind counts. With +`--no-ahead-behind`, counts are omitted from the calculation and +`aheadBehindKnown` is false rather than treating them as zero. + +Check results contain a status object, any violated policy names, and an +optional fetch error. `--fail-on` can test `dirty`, `unpushed`, `behind`, +`diverged`, `detached`, and `no-upstream`. + +### Snapshot records + +Snapshots contain each repository's name, path, current `head`, branch, and +any per-repository error. With `--include-remotes`, remote URLs are included +after redaction; see [Safety and trust boundaries](safety.md#snapshots-and-credentials). + +## Diagnostics and partial results + +Discovery continues after many descendant filesystem problems. Text commands +write warnings such as malformed markers to stderr. Machine formats include +the same information as `diagnostics` or `type: diagnostic` records so an +automation consumer does not have to scrape human text. A fatal root error +prevents a normal result from being produced. + +For `status`, `check`, and `doctor`, a repository-level inspection error is +represented in the result/finding when possible and still contributes to exit +code 1. `exec` and `sync` retain per-repository failures while continuing +other repositories unless `--fail-fast` is enabled. diff --git a/docs/safety.md b/docs/safety.md new file mode 100644 index 0000000..c3ebd73 --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,74 @@ +# Safety and trust boundaries + +GitMulti is a coordinator for Git processes, not a sandbox for Git. Read this +page before using commands that execute Git, fetch remotes, or write files. + +## Shell-free does not mean risk-free + +`exec` positional arguments are passed to Git directly with their boundaries +preserved. GitMulti does not invoke a shell, so shell operators and expansion +are not interpreted. The deprecated `--command` form only splits a command +string; it still does not invoke a shell. + +Git itself can invoke external programs through Git aliases, hooks, credential +helpers, remote helpers, configured remote URLs, and other repository or user +configuration. Run `exec`, `check --fetch`, and `sync` only on repositories +and Git configuration you trust. Git's own hooks and helpers can have side +effects even when the GitMulti command looks read-only. + +## Discovery and filesystem trust + +Use a scan root that is not concurrently writable by an untrusted user. +Discovery skips hidden directories and symlinks by default. With +`--follow-symlinks`, GitMulti canonicalizes targets, prevents cycles, and +skips targets outside the canonical root. This does not provide descriptor-level +isolation from hostile filesystem replacement races after discovery; an +attacker who can replace files or directories concurrently can still affect +what Git sees. + +Repositories are boundaries by default, which limits accidental traversal into +working trees. `--include-nested`, `--include-hidden`, +`--include-bare`, and `--follow-symlinks` intentionally widen discovery and +should be enabled only when the resulting scope is understood. + +## Synchronization and network access + +`sync` defaults to fetch-only mode. `--mode ff-only` first checks each +repository and refuses to update a dirty, detached, unborn, or upstream-less +repository; it then runs `git pull --ff-only`, never a merge or rebase. +`--dry-run` prints the planned command without running Git or safety checks, so +it does not contact remotes or read credentials. + +`check --fetch` runs `git fetch --all --prune` before checking. `sync` and +`check --fetch` use Git's configured credential helpers and environment. Do +not put credentials in command-line arguments or scripts. Use `--no-prompt` +when a non-interactive job must fail instead of waiting for a credential prompt. +Treat Git output and machine-readable results as potentially sensitive. + +## Snapshots and credentials + +`snapshot --output FILE` creates a new file with mode `0600`, installs it +atomically, and refuses to overwrite an existing file unless `--force` is +set. Symlink destinations are refused. These checks protect the output +destination, not the confidentiality of its contents. + +`--include-remotes` redacts HTTP(S) userinfo, passwords, and common token, key, +secret, signature, credential, and authorization query parameters. Suspicious +or invalid remote values are replaced rather than emitted unparsed. Even +redacted snapshots can reveal repository names, paths, and remote hosts, so +store and share them as sensitive data. + +## Timeouts and cancellation + +`exec`, `sync`, and fetches performed by `check` accept per-repository +timeouts. A timeout bounds how long GitMulti waits, including when a descendant +keeps an output pipe open. Cancellation is not guaranteed to terminate every +grandchild process on every operating system. Use process and filesystem +permissions appropriate to the environment in which GitMulti runs. + +## Status collection + +The fixed status collector disables repository-configured filesystem-monitor +hooks and optional locks while collecting status. This reduces one class of +unexpected process execution, but does not make Git a sandbox and does not +override other Git configuration or hooks. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..2236dba --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,13 @@ +site_name: GitMulti CLI +site_description: Discover Git repositories and run Git workflows across them. +repo_url: https://github.com/ramonzx6/gitmulti +theme: + name: mkdocs +nav: + - Home: index.md + - Getting started: getting-started.md + - Repository discovery: discovery.md + - Commands and workflows: commands.md + - Output and exit status: output-and-exit-status.md + - Safety and trust boundaries: safety.md + - Development: development.md diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..f2caded --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1 @@ +mkdocs==1.6.1 From 2f5d606b0a4094362d104b85948177b956c46706 Mon Sep 17 00:00:00 2001 From: ramonzx6 Date: Sat, 22 Aug 2026 09:01:20 +0000 Subject: [PATCH 7/7] test(snapshot): handle Windows file modes --- docs/commands.md | 2 +- docs/safety.md | 11 +++++++---- internal/cli/command/snapshot/command_test.go | 10 +++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 2008c7f..3fe944f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -188,7 +188,7 @@ gitmulti snapshot --recursive --include-remotes --format ndjson | Option | Default | Description | | --- | --- | --- | | `--format json\|ndjson` | `json` | Select the document or record-per-line format. | -| `-o, --output FILE` | stdout | Create a new output file with mode `0600`; refuse to overwrite unless `--force` is set. Symlink destinations are refused. | +| `-o, --output FILE` | stdout | Create a new output file with POSIX mode `0600`. On Windows, Go's owner-writable bit controls the read-only attribute; access is governed by filesystem ACLs. Existing files require `--force`, and symlink destinations are refused. | | `--include-remotes` | off | Include configured remote URLs after credential-like material is redacted. | | `--force` | off | Allow replacement of an existing regular output file. | | `--require-match` | off | Exit 1 when no repositories match. | diff --git a/docs/safety.md b/docs/safety.md index c3ebd73..d473f55 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -47,10 +47,13 @@ Treat Git output and machine-readable results as potentially sensitive. ## Snapshots and credentials -`snapshot --output FILE` creates a new file with mode `0600`, installs it -atomically, and refuses to overwrite an existing file unless `--force` is -set. Symlink destinations are refused. These checks protect the output -destination, not the confidentiality of its contents. +`snapshot --output FILE` creates a new file with POSIX mode `0600`. On +Windows, Go's owner-writable bit only controls the read-only attribute; access +is governed by the filesystem ACL. Without `--force`, GitMulti uses an atomic +no-overwrite install. Forced replacement follows Go's `os.Rename` semantics, +whose atomicity is platform-dependent. Symlink destinations are refused. +These checks protect the output destination, not the confidentiality of its +contents; use the platform's filesystem permissions or ACLs as appropriate. `--include-remotes` redacts HTTP(S) userinfo, passwords, and common token, key, secret, signature, credential, and authorization query parameters. Suspicious diff --git a/internal/cli/command/snapshot/command_test.go b/internal/cli/command/snapshot/command_test.go index 68bdc7a..7fac840 100644 --- a/internal/cli/command/snapshot/command_test.go +++ b/internal/cli/command/snapshot/command_test.go @@ -184,7 +184,7 @@ func TestSnapshotReportsRepositoryFailuresWithStructuredOutput(t *testing.T) { } } -func TestSnapshotWriterUsesPrivateModeAndAtomicInstall(t *testing.T) { +func TestSnapshotWriterUsesPlatformModeAndAtomicInstall(t *testing.T) { path := filepath.Join(t.TempDir(), "snapshot.json") writer, closeWriter, err := snapshotWriter(nil, path, false) if err != nil { @@ -205,8 +205,12 @@ func TestSnapshotWriterUsesPrivateModeAndAtomicInstall(t *testing.T) { if err != nil { t.Fatalf("stat snapshot: %v", err) } - if got := info.Mode().Perm(); got != 0o600 { - t.Fatalf("snapshot mode = %#o, want 0600", got) + if got := info.Mode().Perm(); runtime.GOOS == "windows" { + if got != 0o666 { + t.Fatalf("snapshot mode = %#o, want writable Windows mode 0666", got) + } + } else if got != 0o600 { + t.Fatalf("snapshot mode = %#o, want POSIX mode 0600", got) } }