diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52b9116 --- /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 ./cmd/gitmulti --help + - run: go run ./cmd/gitmulti version --json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8b6a176 --- /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/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 + (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/.gitignore b/.gitignore index 8e1e017..5a3e5be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env -gitmulti +/.venv/ +/gitmulti +/site/ test/ -gitmulti.exe +/gitmulti.exe reviewtmp/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7385b1a..c7099c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,92 @@ # 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 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. -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. + +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 +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 +~~~ + +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 +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/Makefile b/Makefile new file mode 100644 index 0000000..6d28719 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +GO ?= go +GOFMT ?= gofmt +DOCS_PYTHON ?= python3 +DOCS_REQUIREMENTS ?= requirements-docs.txt +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/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 docs-install docs-build docs-serve 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)" ./cmd/gitmulti + +smoke: build + @./$(BINARY) --help >/dev/null + @./$(BINARY) version --json >/dev/null + +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 c9bd4bf..2a54e05 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,76 @@ # 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 +## Install -- List child Git repositories in a directory -- Execute Git commands across child repositories -- Optional recursive repository scanning -- Support for custom paths -- Cross-platform compatibility +GitMulti requires Go 1.24 or newer to build or install, and Git on `PATH` when +a command inspects or operates on repositories. -## Installation +Install the latest version with Go: -### Prerequisites - -- Go 1.24 or higher -- Git - -### Build and Install +```sh +go install github.com/ramonzx6/gitmulti/cmd/gitmulti@latest +``` -#### Option 1: Install globally +To build from a checkout: -```bash +```sh git clone https://github.com/ramonzx6/gitmulti.git cd gitmulti -go install -``` - -#### Option 2: Build locally - -```bash -go build -o gitmulti main.go -./gitmulti list +go build -o gitmulti ./cmd/gitmulti ``` -### Add to PATH on Windows - -If `go install` does not automatically add the binary to your PATH: - -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 - ``` - -## Usage - -```bash -# Get help -gitmulti --help - -# Print version -gitmulti version -gitmulti -v - -# List child repositories in the current directory -gitmulti list - -# Execute a Git command in each child repository -gitmulti exec -- status --short -``` - -`gitmulti` scans child directories of the selected path. The selected path itself is not included, even if it is a Git repository. - -## Command Reference - -### `list` / `ls` - -Lists Git repositories inside the selected directory. +On Windows, use `go build -o gitmulti.exe ./cmd/gitmulti` and run +`./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`. -Flags: +## Quick start -- `-p, --path`: Path to scan for repositories. Defaults to the current working directory. -- `-r, --recursive`: Scan nested directories recursively. - -Examples: - -```bash +```sh +# Discover direct child repositories. gitmulti list -gitmulti list -p C:\Users\username\Documents\projects -gitmulti list -p .. -gitmulti list --recursive -gitmulti ls -``` - -### `exec` / `ex` - -Executes a Git command in each repository inside the selected directory. -Flags: +# Include repositories below the selected directory. +gitmulti list --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. +# Show branch, dirty state, upstream, and ahead/behind counts. +gitmulti status --path ~/src --recursive -You can pass the Git command in either style: +# Run Git with argument boundaries preserved; do not include the word git. +gitmulti exec --path ~/src --recursive -- status --short -```bash -# Positional style. Use -- before the Git command. -gitmulti exec -- status --short - -# String style. -gitmulti exec -c "status --short" +# Preview a fetch without changing any repository. +gitmulti sync --path ~/src --recursive --dry-run ``` -Do not include `git` in the command. `gitmulti` runs `git` for you. - -More examples: +GitMulti invokes Git directly, without a shell. Read the documentation before +running commands that can change repositories or contact remotes. -```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" -``` +## Documentation -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. +The [GitMulti documentation](docs/index.md) is the complete command manual: -### `version` / `v` +- [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. -Prints the version number. - -```bash -gitmulti version -gitmulti v -gitmulti -v -``` - ---- - -#### `version` - -Print the version number and build information of gitmulti. - -**Usage:** -```bash -gitmulti version -gitmulti --version -gitmulti -v -``` - -**Output:** -``` -gitmulti version 0.1.0 -https://github.com/ramonzx6/gitmulti -``` +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). - -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). Read the complete license before +using or redistributing this source. diff --git a/cmd/exec.go b/cmd/exec.go deleted file mode 100644 index ced6417..0000000 --- a/cmd/exec.go +++ /dev/null @@ -1,74 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "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 - } - - 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() - - repositories, err := findRepositories(execPath, execRecursive) - if err != nil { - return err - } - - if len(repositories) == 0 { - fmt.Println("no git repositories found") - return nil - } - - failures := []string{} - for _, repo := range repositories { - fmt.Println("executing git command on " + repo.Name) - - output, err := executeGitCommand(repo.Path, gitArgs) - if output != "" { - fmt.Print(output) - if !strings.HasSuffix(output, "\n") { - fmt.Println() - } - } - if err != nil { - fmt.Printf("failed to execute git command on %s: %v\n", repo.Name, err) - failures = append(failures, repo.Name) - } - - fmt.Println() - fmt.Println("--------------------------------") - fmt.Println() - } - - if len(failures) > 0 { - return fmt.Errorf("git command failed in %d repositories: %v", len(failures), failures) - } - - 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) -} 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.go b/cmd/list.go deleted file mode 100644 index 2c4576a..0000000 --- a/cmd/list.go +++ /dev/null @@ -1,45 +0,0 @@ -package cmd - -import ( - "fmt" - - "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 - } - - if len(repositories) == 0 { - fmt.Println("no git repositories found") - return nil - } - - for _, repo := range repositories { - fmt.Println(repo.Name + " is a git repository") - } - - return nil - }, -} - -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) -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 10bd1af..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,33 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "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 - } - - cmd.Help() - }, -} - -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 init() { - rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "print the version number of gitmulti") -} diff --git a/cmd/utils.go b/cmd/utils.go deleted file mode 100644 index f4015f1..0000000 --- a/cmd/utils.go +++ /dev/null @@ -1,114 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/google/shlex" -) - -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") - } - - if trimmedCommand == "" { - if len(args) == 0 { - return nil, errors.New("git command cannot be empty") - } - return args, nil - } - - parsedArgs, err := shlex.Split(trimmedCommand) - if err != nil { - return nil, fmt.Errorf("invalid git command %q: %w", command, err) - } - - 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 - - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), err - } - - return string(output), 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 deleted file mode 100644 index 2d35c25..0000000 --- a/cmd/version.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import ( - "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 init() { - rootCmd.AddCommand(versionCmd) -} diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..3fe944f --- /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 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. | + +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..d473f55 --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,77 @@ +# 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 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 +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/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/cli/app/app.go b/internal/cli/app/app.go new file mode 100644 index 0000000..91a76c8 --- /dev/null +++ b/internal/cli/app/app.go @@ -0,0 +1,52 @@ +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..9e4dc78 --- /dev/null +++ b/internal/cli/buildinfo/buildinfo.go @@ -0,0 +1,43 @@ +package buildinfo + +import "runtime/debug" + +// These values may be replaced with -ldflags when producing release builds. +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) + +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/internal/cli/command/check/command.go b/internal/cli/command/check/command.go new file mode 100644 index 0000000..7ab7b08 --- /dev/null +++ b/internal/cli/command/check/command.go @@ -0,0 +1,250 @@ +package check + +import ( + "encoding/json" + "fmt" + "sort" + "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 result struct { + Status gitstatus.State `json:"status"` + Violations []string `json:"violations,omitempty"` + FetchError string `json:"fetchError,omitempty"` +} + +type document struct { + SchemaVersion int `json:"schemaVersion"` + Results []result `json:"results"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} + +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags + 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: clierr.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if jobs < 1 { + return clierr.Usage("--jobs must be at least 1") + } + if timeout < 0 { + return clierr.Usage("--timeout cannot be negative") + } + runApp := application + if noPrompt { + runApp.Git = runApp.Git.WithEnv("GIT_TERMINAL_PROMPT=0") + } + if format != "table" && format != "json" && format != "ndjson" { + return clierr.Usage("unsupported check format %q", format) + } + policies, err := normalizePolicies(failOn) + if err != nil { + return err + } + discovered, err := scan.Discover(command.Context(), application, command, quiet) + if err != nil { + return err + } + if len(discovered.Repositories) == 0 && requireMatch { + 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 := 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) + } + } + } + } + + 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, result{Status: state, Violations: violations, FetchError: fetchErrors[state.Name]}) + } + if !quiet { + if err := render(command, results, format, discovered.Warnings); err != nil { + return err + } + } + collectionFailures := statusset.ErrorCount(states) + if fetchFailures > 0 { + return clierr.Runtime("fetch failed in %d repositories", fetchFailures) + } + if collectionFailures > 0 { + if quiet { + return clierr.New(clierr.RuntimeExitCode, "") + } + return clierr.Runtime("check could not inspect %d repositories", collectionFailures) + } + if violationCount > 0 { + if quiet { + return clierr.New(clierr.RuntimeExitCode, "") + } + return clierr.Runtime("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, clierr.Usage("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 render(command *cobra.Command, results []result, 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(document{SchemaVersion: output.SchemaVersion, Results: results, Diagnostics: warnings}) + case "ndjson": + encoder := json.NewEncoder(command.OutOrStdout()) + for _, value := range results { + if err := encoder.Encode(struct { + Type string `json:"type"` + result + }{Type: "check", result: value}); 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 _, value := range results { + outcome := "ok" + violations := strings.Join(value.Violations, ",") + if value.FetchError != "" { + outcome = "error" + violations = "fetch: " + value.FetchError + } else if value.Status.Err != nil { + outcome = "error" + violations = value.Status.Error + } else if len(value.Violations) > 0 { + outcome = "fail" + } + 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 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/internal/cli/command/completion/command.go b/internal/cli/command/completion/command.go new file mode 100644 index 0000000..2ee8100 --- /dev/null +++ b/internal/cli/command/completion/command.go @@ -0,0 +1,30 @@ +package completion + +import ( + "github.com/ramonzx6/gitmulti/internal/cli/app" + "github.com/ramonzx6/gitmulti/internal/cli/clierr" + "github.com/spf13/cobra" +) + +func New(_ app.App) *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: clierr.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 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..e12734d --- /dev/null +++ b/internal/cli/command/doctor/command.go @@ -0,0 +1,126 @@ +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"` +} + +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..21abded --- /dev/null +++ b/internal/cli/command/exec/command.go @@ -0,0 +1,113 @@ +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" +) + +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/internal/cli/command/list/command.go b/internal/cli/command/list/command.go new file mode 100644 index 0000000..81f0ebf --- /dev/null +++ b/internal/cli/command/list/command.go @@ -0,0 +1,139 @@ +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 New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags + var format string + var absolute bool + var nullTerminated bool + var count bool + var requireMatch bool + + command := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List discovered Git repositories", + Args: clierr.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !validFormat(format) { + return clierr.Usage("unsupported list format %q", format) + } + if absolute { + if format != "relative" && format != "absolute" { + return clierr.Usage("--absolute cannot be combined with --format %s", format) + } + format = "absolute" + } + if nullTerminated && format != "name" && format != "relative" && format != "absolute" { + return clierr.Usage("--null is only supported with name, relative, or absolute output") + } + if count && nullTerminated { + return clierr.Usage("--count cannot be combined with --null") + } + if count && absolute { + return clierr.Usage("--count cannot be combined with --absolute") + } + if count && format != "relative" { + return clierr.Usage("--count cannot be combined with --format %s", format) + } + + result, err := scan.Discover(command.Context(), application, command) + if err != nil { + return err + } + if requireMatch && len(result.Repositories) == 0 { + return clierr.Runtime("no Git repositories matched") + } + if count { + _, err := fmt.Fprintln(command.OutOrStdout(), len(result.Repositories)) + return err + } + return render(command, result, format, nullTerminated) + }, + } + 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(&requireMatch, "require-match", false, "fail when no repositories match") + return command +} + +func validFormat(format string) bool { + switch format { + case "name", "relative", "absolute", "json", "ndjson": + return true + default: + return false + } +} + +func render(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: output.SchemaVersion, 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 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..3014aa4 --- /dev/null +++ b/internal/cli/command/snapshot/command.go @@ -0,0 +1,184 @@ +// 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"` +} + +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..7fac840 --- /dev/null +++ b/internal/cli/command/snapshot/command_test.go @@ -0,0 +1,278 @@ +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 TestSnapshotWriterUsesPlatformModeAndAtomicInstall(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(); 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) + } +} + +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/internal/cli/command/status/command.go b/internal/cli/command/status/command.go new file mode 100644 index 0000000..9261cbd --- /dev/null +++ b/internal/cli/command/status/command.go @@ -0,0 +1,153 @@ +package status + +import ( + "encoding/json" + "fmt" + + "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" +) + +type document struct { + SchemaVersion int `json:"schemaVersion"` + Repositories []gitstatus.State `json:"repositories"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} + +type line struct { + Type string `json:"type"` + Status *gitstatus.State `json:"status,omitempty"` + Path string `json:"path,omitempty"` + Message string `json:"message,omitempty"` +} + +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags + 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: clierr.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if jobs < 1 { + return clierr.Usage("--jobs must be at least 1") + } + if format != "table" && format != "json" && format != "ndjson" { + return clierr.Usage("unsupported status 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") + } + states := statusset.Collect(command.Context(), application, 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 := render(command, states, discovered.Warnings, format, noAheadBehind); err != nil { + return err + } + if failures := statusset.ErrorCount(states); failures > 0 { + return clierr.Runtime("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 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(document{SchemaVersion: output.SchemaVersion, Repositories: states, Diagnostics: warnings}) + case "ndjson": + encoder := json.NewEncoder(command.OutOrStdout()) + for index := range states { + if err := encoder.Encode(line{Type: "status", Status: &states[index]}); err != nil { + return err + } + } + for _, warning := range warnings { + if err := encoder.Encode(line{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 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/internal/cli/command/sync/command.go b/internal/cli/command/sync/command.go new file mode 100644 index 0000000..69025b1 --- /dev/null +++ b/internal/cli/command/sync/command.go @@ -0,0 +1,164 @@ +package sync + +import ( + "fmt" + "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/scanflags" + "github.com/spf13/cobra" +) + +func New(application app.App) *cobra.Command { + application = application.Normalized() + var scan scanflags.Flags + 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: clierr.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if mode != "fetch" && mode != "ff-only" { + return clierr.Usage("unsupported sync mode %q", mode) + } + if jobs < 1 { + return clierr.Usage("--jobs must be at least 1") + } + if timeout < 0 { + return clierr.Usage("--timeout cannot be negative") + } + if strings.HasPrefix(remote, "-") { + return clierr.Usage("--remote cannot begin with '-'") + } + + runApp := application + if noPrompt { + runApp.Git = runApp.Git.WithEnv("GIT_TERMINAL_PROMPT=0") + } + 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 + } + + args := syncArgs(mode, remote, prune, tags) + if dryRun { + for _, repo := range discovered.Repositories { + if _, err := fmt.Fprintln(command.OutOrStdout(), execution.DisplayCommand(repo.Path, args)); err != nil { + return err + } + } + return nil + } + if _, err := runApp.Git.LookPath(); err != nil { + return err + } + + var records []execution.Record + if mode == "ff-only" { + records = executeSafeFastForward(command, runApp, discovered.Repositories, args, jobs, timeout, failFast, output) + } else { + records = execution.ExecuteAcross(command, runApp, discovered.Repositories, args, jobs, timeout, failFast, output, false) + } + if err := execution.Render(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, skipped := countFailures(records) + if failures > 0 || skipped > 0 { + 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) + 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 +} + +// 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" { + 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 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/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/internal/cli/command/version/command.go b/internal/cli/command/version/command.go new file mode 100644 index 0000000..324c5d2 --- /dev/null +++ b/internal/cli/command/version/command.go @@ -0,0 +1,43 @@ +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 New(_ app.App, info buildinfo.Info) *cobra.Command { + var short bool + var asJSON bool + + command := &cobra.Command{ + Use: "version", + Aliases: []string{"v"}, + Short: "Print version and build information", + Args: clierr.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if short && asJSON { + return clierr.Usage("--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/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/internal/cli/execution/execution.go b/internal/cli/execution/execution.go new file mode 100644 index 0000000..f590d5f --- /dev/null +++ b/internal/cli/execution/execution.go @@ -0,0 +1,347 @@ +package execution + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "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 Record 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"` +} + +type Document struct { + SchemaVersion int `json:"schemaVersion"` + Results []Record `json:"results"` + Diagnostics []repository.Warning `json:"diagnostics,omitempty"` +} + +func ValidOutput(value string) bool { + switch value { + case "grouped", "prefixed", "raw", "json", "ndjson": + return true + default: + return false + } +} + +// 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([]Record, 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 := 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 + switch outputMode { + case "raw": + var input io.Reader + if interactive { + input = command.InOrStdin() + } + 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 = 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 + } + if flushErr := stderr.Flush(); flushErr != nil && result.Err == nil { + result.Err = flushErr + result.ExitCode = -1 + } + 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 + 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 + } + }() + } + + for index := range repositories { + if failFast && ctx.Err() != nil { + 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 + } + close(indices) + wait.Wait() + return records +} + +// 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 outputMode { + case "raw": + return nil + case "json": + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetIndent("", " ") + 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"` + Record + }{Type: "execution", Record: 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 clierr.Usage("unsupported output mode %q", outputMode) + } +} + +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 +} + +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 + shared *sync.Mutex + pending bytes.Buffer + lineAtStart bool +} + +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} +} + +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 (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/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..5d995f5 --- /dev/null +++ b/internal/cli/output/output.go @@ -0,0 +1,5 @@ +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..c159973 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,86 @@ +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 + +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 +} + +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/internal/cli/scanflags/scanflags.go b/internal/cli/scanflags/scanflags.go new file mode 100644 index 0000000..13395f0 --- /dev/null +++ b/internal/cli/scanflags/scanflags.go @@ -0,0 +1,118 @@ +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" +) + +// 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 + includeNested bool + maxDepth int + match []string + exclude []string + includeHidden bool + followSymlinks bool + includeBare bool + validate bool +} + +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") + 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") +} + +// 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, + 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, + } +} + +// 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{}, clierr.Usage("--max-depth cannot be negative") + } + if flags.maxDepth > 0 && !flags.recursive { + return repository.Result{}, clierr.Usage("--max-depth requires --recursive") + } + if flags.includeNested && !flags.recursive { + return repository.Result{}, clierr.Usage("--include-nested requires --recursive") + } + + application = application.Normalized() + options := flags.Options() + if options.Validate { + options.ValidateRepository = func(ctx context.Context, path string) error { + result := application.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 := application.Discover(ctx, options) + if err != nil { + return repository.Result{}, err + } + quiet := len(suppressWarnings) > 0 && suppressWarnings[0] + 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..570ee8b --- /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 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) + } + 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..9a43946 --- /dev/null +++ b/internal/cli/statusset/statusset.go @@ -0,0 +1,61 @@ +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 +} + +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/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go new file mode 100644 index 0000000..2ca8ca3 --- /dev/null +++ b/internal/gitexec/gitexec.go @@ -0,0 +1,247 @@ +// 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} +} + +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..9ccf0a1 --- /dev/null +++ b/internal/gitstatus/status.go @@ -0,0 +1,287 @@ +// 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 +} + +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 +} + +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..8d8fcf6 --- /dev/null +++ b/internal/gitstatus/status_test.go @@ -0,0 +1,226 @@ +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) + } +} + +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 deleted file mode 100644 index d1eb115..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "gitmulti/cmd" - -func main() { - cmd.Execute() -} 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