diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 746aa29..9c75a4b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,3 +1,7 @@ +# Copyright 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + name: Run CI on: @@ -8,6 +12,8 @@ on: tags: - 'v*' pull_request: + schedule: + - cron: '0 0 * * *' # Runs every day at midnight UTC permissions: contents: read diff --git a/.github/workflows/reusable-go-ci.yaml b/.github/workflows/reusable-go-ci.yaml index ae16ec5..b17467e 100644 --- a/.github/workflows/reusable-go-ci.yaml +++ b/.github/workflows/reusable-go-ci.yaml @@ -287,7 +287,7 @@ jobs: ${{ runner.os }}-go-${{ inputs.module }}- - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: languages: go build-mode: manual # Set to manual as we provide a build step @@ -308,7 +308,7 @@ jobs: fi - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: category: "/language:go/${{ inputs.name }}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 1ee47a6..054c101 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -68,6 +68,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.31.1 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.31.1 with: sarif_file: results.sarif \ No newline at end of file diff --git a/app/cmd/root.go b/app/cmd/root.go index 8d4c051..ce101dd 100644 --- a/app/cmd/root.go +++ b/app/cmd/root.go @@ -31,7 +31,7 @@ var ( local bool dryRun bool color bool - concurrency string + concurrency int baseDirs []string ) @@ -71,7 +71,7 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&local, "local", "l", false, "run with working directory used as base directory") rootCmd.PersistentFlags().BoolVarP(&dryRun, "dryRun", "D", false, "run with dry run mode") rootCmd.PersistentFlags().BoolVarP(&color, "color", "c", true, "color output") - rootCmd.PersistentFlags().StringVarP(&concurrency, "concurrency", "C", "1", "number of concurrent operations") + rootCmd.PersistentFlags().IntVarP(&concurrency, "concurrency", "C", 1, "number of concurrent operations") rootCmd.PersistentFlags().StringSliceVar(&baseDirs, "base.dirs", []string{}, "base directories for git repositories") // Bind flags to Viper settings diff --git a/config/env.go b/config/env.go index f7c3819..639b0cd 100644 --- a/config/env.go +++ b/config/env.go @@ -56,9 +56,9 @@ func IsColored() bool { return viper.GetBool(GitCtlColor) } -// GetConcurrency returns the concurrency level as a string -func GetConcurrency() string { - return viper.GetString(GitCtlConcurrency) +// GetConcurrency returns the concurrency level as an integer +func GetConcurrency() int { + return viper.GetInt(GitCtlConcurrency) } // GetBaseDirs returns the base directories as a slice of strings diff --git a/config/env_test.go b/config/env_test.go index 3f94bf1..e41abcd 100644 --- a/config/env_test.go +++ b/config/env_test.go @@ -92,7 +92,7 @@ func TestIsColoredReturnsFalseWhenDisabled(t *testing.T) { } func TestGetConcurrencyReturnsCorrectValue(t *testing.T) { - expected := "4" + expected := 4 viper.Set(GitCtlConcurrency, expected) result := GetConcurrency() if result != expected { @@ -100,6 +100,16 @@ func TestGetConcurrencyReturnsCorrectValue(t *testing.T) { } } +func TestGetConcurrencyReturnsZeroWhenUnset(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + result := GetConcurrency() + // viper returns 0 for unset int keys; clamping to 1 is the caller's responsibility + if result != 0 { + t.Errorf("expected 0 (unset), got %v", result) + } +} + func TestGetBaseDirsReturnsCorrectValueWhenLocal(t *testing.T) { viper.Reset() t.Cleanup(viper.Reset) diff --git a/gitrepo/gitrepo.go b/gitrepo/gitrepo.go index ac002e4..7762792 100644 --- a/gitrepo/gitrepo.go +++ b/gitrepo/gitrepo.go @@ -55,7 +55,9 @@ func FindGitRepos(root string) ([]GitRepo, error) { } } -func (gitRepo *GitRepo) RunGitCommand(command string) ([]byte, error) { +// runRaw executes the git command and returns raw combined output without any +// color formatting or global state mutations. Safe to call from goroutines. +func (gitRepo *GitRepo) runRaw(command string) ([]byte, error) { verbose := config.IsVerbose() dryRun := config.IsDryRun() repoPath := "" @@ -88,10 +90,26 @@ func (gitRepo *GitRepo) RunGitCommand(command string) ([]byte, error) { gitCmd.Dir = repoPath out, err := gitCmd.CombinedOutput() - // Format the output with headers and separators and color - formattedOutput := FormatOutput(repoPath, out) if err != nil { - return []byte(formattedOutput), fmt.Errorf("git %s failed for %s: %w", command, repoPath, err) + return out, fmt.Errorf("git %s failed for %s: %w", command, repoPath, err) + } + return out, nil +} + +// RunGitCommand executes the git command and returns color-formatted output. +// Not safe to call from concurrent goroutines (mutates global color state). +func (gitRepo *GitRepo) RunGitCommand(command string) ([]byte, error) { + repoPath := "" + if gitRepo != nil { + repoPath = gitRepo.path + } + raw, err := gitRepo.runRaw(command) + if raw == nil && err == nil { + return nil, nil + } + formattedOutput := FormatOutput(repoPath, raw) + if err != nil { + return []byte(formattedOutput), err } return []byte(formattedOutput), nil } diff --git a/gitrepo/gitrepos.go b/gitrepo/gitrepos.go index 89884d3..5f052d4 100644 --- a/gitrepo/gitrepos.go +++ b/gitrepo/gitrepos.go @@ -4,11 +4,17 @@ import ( "errors" "fmt" "strings" + "sync" "github.com/bjoernkarma/gitctl/color" "github.com/bjoernkarma/gitctl/config" ) +type repoResult struct { + rawOutput []byte + err error +} + func RunGitCommand(command string, baseDirs []string) error { allGitRepos, findErr := findGitReposInBaseDirs(baseDirs) if findErr != nil { @@ -21,25 +27,28 @@ func RunGitCommand(command string, baseDirs []string) error { if isVerbose && !isQuiet { fmt.Printf("\n============ GIT OUTPUT (VERBOSE) ============\n") } + + results := runWithWorkerPool(command, allGitRepos) + var commandErrors []error if findErr != nil { commandErrors = append(commandErrors, findErr) } - for _, gitRepo := range allGitRepos { - output, err := gitRepo.RunGitCommand(command) - if err != nil { - commandErrors = append(commandErrors, err) - errorMsg := extractErrorMessage(string(output)) - color.AddGitCommandFailure(gitRepo.path, errorMsg, string(output)) - // In verbose mode, show the full formatted output immediately + for i, result := range results { + // FormatOutput mutates global color state — must stay in the main goroutine. + formattedOutput := FormatOutput(allGitRepos[i].path, result.rawOutput) + if result.err != nil { + commandErrors = append(commandErrors, result.err) + errorMsg := extractErrorMessage(formattedOutput) + color.AddGitCommandFailure(allGitRepos[i].path, errorMsg, formattedOutput) if isVerbose && !isQuiet { - fmt.Printf("%s", output) + fmt.Printf("%s", formattedOutput) } } else if isVerbose && !isQuiet { - fmt.Printf("%s", output) + fmt.Printf("%s", formattedOutput) } - } + if isVerbose && !isQuiet { fmt.Printf("\n============ GIT OUTPUT END ============\n") } @@ -51,6 +60,49 @@ func RunGitCommand(command string, baseDirs []string) error { return errors.Join(commandErrors...) } +// runWithWorkerPool executes the git command across all repos using a bounded +// goroutine pool. Results are stored at each repo's discovery index so that +// the caller can iterate them in deterministic order. Workers call runRaw to +// avoid concurrent mutations of global color state. +func runWithWorkerPool(command string, repos []GitRepo) []repoResult { + results := make([]repoResult, len(repos)) + if len(repos) == 0 { + return results + } + + concurrency := config.GetConcurrency() + if concurrency < 1 { + concurrency = 1 + } + + type job struct { + index int + repo GitRepo + } + + jobs := make(chan job, len(repos)) + var wg sync.WaitGroup + + for range concurrency { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobs { + raw, err := j.repo.runRaw(command) + results[j.index] = repoResult{rawOutput: raw, err: err} + } + }() + } + + for i, repo := range repos { + jobs <- job{index: i, repo: repo} + } + close(jobs) + + wg.Wait() + return results +} + func findGitReposInBaseDirs(baseDirs []string) ([]GitRepo, error) { var allGitRepos []GitRepo verbose := config.IsVerbose() diff --git a/gitrepo/gitrepos_test.go b/gitrepo/gitrepos_test.go index a06d058..c61315f 100644 --- a/gitrepo/gitrepos_test.go +++ b/gitrepo/gitrepos_test.go @@ -99,3 +99,65 @@ func TestRunGitCommandAggregatesErrorsFromInvalidAndValidBaseDirs(t *testing.T) assert.Error(t, err) assert.True(t, strings.Contains(err.Error(), "failed to find repositories")) } + +func TestRunGitCommandWithConcurrencyGreaterThanOneProcessesAllRepos(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(config.GitCtlDryRun, true) + viper.Set(config.GitCtlConcurrency, 3) + + testDir, _ := filepath.Abs(testDirPath) + baseDirs := []string{testDir} + + err := RunGitCommand(GitStatus, baseDirs) + assert.NoError(t, err) +} + +func TestRunWithWorkerPoolClampsNegativeConcurrencyToOne(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(config.GitCtlDryRun, true) + viper.Set(config.GitCtlConcurrency, -1) + + testDir, _ := filepath.Abs(testDirPath) + repos, err := findGitReposInBaseDirs([]string{testDir}) + assert.NoError(t, err) + + results := runWithWorkerPool(GitStatus, repos) + assert.Len(t, results, len(repos)) +} + +func TestRunWithWorkerPoolClampsZeroConcurrencyToOne(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(config.GitCtlDryRun, true) + viper.Set(config.GitCtlConcurrency, 0) + + testDir, _ := filepath.Abs(testDirPath) + repos, err := findGitReposInBaseDirs([]string{testDir}) + assert.NoError(t, err) + + results := runWithWorkerPool(GitStatus, repos) + assert.Len(t, results, len(repos)) +} + +func TestRunWithWorkerPoolPreservesDiscoveryOrder(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(config.GitCtlConcurrency, 3) + + testDir, _ := filepath.Abs(microserviceDirPath) + // Mix valid and invalid repos to confirm results are indexed by discovery order. + repos := []GitRepo{ + {path: testDir}, // index 0: valid — no error expected + {path: invalidPath}, // index 1: invalid — error expected + {path: testDir}, // index 2: valid — no error expected + } + + results := runWithWorkerPool(GitStatus, repos) + + assert.Len(t, results, 3) + assert.NoError(t, results[0].err) + assert.Error(t, results[1].err) + assert.NoError(t, results[2].err) +} diff --git a/go.mod b/go.mod index 1dbc0fd..2db2e9e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bjoernkarma/gitctl -go 1.26.2 +go 1.26.3 require ( github.com/charmbracelet/lipgloss v1.1.0 diff --git a/openspec/changes/concurrent-execution/.openspec.yaml b/openspec/changes/concurrent-execution/.openspec.yaml new file mode 100644 index 0000000..352690f --- /dev/null +++ b/openspec/changes/concurrent-execution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-28 diff --git a/openspec/changes/concurrent-execution/design.md b/openspec/changes/concurrent-execution/design.md new file mode 100644 index 0000000..678bdde --- /dev/null +++ b/openspec/changes/concurrent-execution/design.md @@ -0,0 +1,63 @@ +## Context + +`gitctl` discovers git repositories across one or more base directories and runs a git command (`status` or `pull`) on each. Currently, execution is strictly sequential — one repo at a time. Git operations are I/O-bound (network for pull, filesystem for status), making them natural candidates for concurrency. + +The configuration layer already exposes `run_mode.concurrency` (YAML, env, and CLI flag), and `config.GetConcurrency()` exists but returns a `string` and is never called by the execution layer. The groundwork is there; it just needs to be wired up correctly. + +## Goals / Non-Goals + +**Goals:** +- Implement a bounded goroutine worker pool in `gitrepo/gitrepos.go`, limited by `run_mode.concurrency`. +- Print results in deterministic discovery order regardless of completion order. +- Fix `GetConcurrency()` return type from `string` to `int`. +- Keep `concurrency=1` behaviour identical to current sequential behaviour. + +**Non-Goals:** +- Streaming live output as repos complete (deferred; collect-then-print is chosen for stability). +- Progress bars or live status indicators. +- Per-command concurrency limits (single limit applies to all commands). +- Changing how errors are reported or aggregated. + +## Decisions + +### Decision 1: Worker pool with result collection (vs. fan-out goroutines) + +**Chosen**: A fixed-size worker pool reads from a job channel. Each worker writes its result (output + error) to a pre-allocated result slice at the repo's discovery index. After all workers finish, the main goroutine iterates the result slice in order and prints. + +**Why not unbounded goroutines (one per repo)?** With hundreds of repos, spawning unlimited goroutines risks exhausting file descriptors or memory. The pool naturally applies backpressure. + +**Why not channels for results?** A pre-allocated `[]result` indexed by position gives us ordered output for free, without sorting or a second coordination step. + +``` +Discovery order: [repo0, repo1, repo2, repo3, repo4] + │ + job channel + ┌──────────┐ + worker 1 ◀───┤ ├───▶ results[0], results[2], results[4] + worker 2 ◀───┤ ├───▶ results[1], results[3] + └──────────┘ + │ + (all done) + │ + print results[0..4] in order +``` + +### Decision 2: Fix `GetConcurrency()` to return `int` + +**Chosen**: Change signature to `GetConcurrency() int` using `viper.GetInt`. Update the CLI flag binding and default from `"1"` (string) to `1` (int). The flag type changes from `StringVarP` to `IntVarP`. + +**Why now?** The type is only used by the new worker pool. Fixing it as part of this change avoids a later migration. + +### Decision 3: Minimum concurrency of 1 + +**Chosen**: If the configured value is `< 1`, clamp to `1`. This prevents deadlocks from a zero-worker pool and makes the behaviour predictable. + +## Risks / Trade-offs + +- **Interleaved filesystem access** — Multiple `git pull` operations on repos sharing a common remote could hit rate limits on the remote. This is a user-configurable concern; the default of `1` is safe. → _No mitigation needed; document in help text._ +- **Type change is breaking** — Any external code importing `config.GetConcurrency()` must update. Since this is a CLI tool (not a library), impact is limited to internal callers. → _Fix all internal call sites as part of this change._ +- **Result slice pre-allocation** — Requires knowing the full repo list upfront (already the case). Not a concern for typical repo counts. + +## Open Questions + +- None. Design is fully resolved for the agreed scope. diff --git a/openspec/changes/concurrent-execution/proposal.md b/openspec/changes/concurrent-execution/proposal.md new file mode 100644 index 0000000..03fa206 --- /dev/null +++ b/openspec/changes/concurrent-execution/proposal.md @@ -0,0 +1,27 @@ +## Why + +`gitctl` runs git commands on all discovered repositories sequentially, even though the operations are independent and I/O-bound. With many repositories, this is unnecessarily slow. The `concurrency` configuration key and CLI flag already exist as a stub but have no effect. + +## What Changes + +- Wire `run_mode.concurrency` (and `--concurrency` / `-C` flag) to an actual worker pool that runs git commands in parallel. +- Fix the type of `GetConcurrency()` from `string` to `int` (**BREAKING** for any code calling that function). +- Collect results from all workers and print them in discovery order (not completion order) to keep output stable and readable. +- Update the `command-execution` spec to reflect the new concurrent execution model. + +## Capabilities + +### New Capabilities + +- `concurrent-execution`: A bounded worker pool that runs git commands across repositories in parallel, limited to `run_mode.concurrency` goroutines, with results collected and printed in deterministic discovery order. + +### Modified Capabilities + +- `command-execution`: Execution model changes from strictly sequential to concurrent. The sequential guarantee is replaced by an ordered-output guarantee. + +## Impact + +- `config/env.go`: `GetConcurrency()` return type changes from `string` to `int`. +- `gitrepo/gitrepos.go`: `RunGitCommand` gains a goroutine worker pool; result collection replaces the current inline print loop. +- `app/cmd/root.go`: Flag default and bind type updated to match `int`. +- `openspec/specs/command-execution/spec.md`: Updated to describe concurrent execution model. diff --git a/openspec/changes/concurrent-execution/specs/command-execution/spec.md b/openspec/changes/concurrent-execution/specs/command-execution/spec.md new file mode 100644 index 0000000..a06d52b --- /dev/null +++ b/openspec/changes/concurrent-execution/specs/command-execution/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Execution Model +`gitctl` SHALL execute git commands across all discovered repositories using a bounded worker pool. The number of concurrent workers SHALL be controlled by `run_mode.concurrency`. Results SHALL be collected and printed in discovery order after all repositories have been processed. + +For each repository a worker SHALL: +1. Run `git ` in the repository's directory. +2. Capture combined stdout+stderr output. +3. Store the result (output + error) at the repository's discovery index. +4. If the command fails, record the failure; continue to the next job. + +After all workers complete, the main goroutine SHALL iterate results in discovery order, print output, and aggregate errors. + +#### Scenario: All repos processed with concurrency > 1 +- **WHEN** `run_mode.concurrency` is 3 and 10 repositories are discovered +- **THEN** all 10 repositories SHALL be processed and their output printed in discovery order + +#### Scenario: Single failure does not stop other repos +- **WHEN** one repository's git command fails +- **THEN** remaining repositories in the pool SHALL still be processed + +#### Scenario: All errors collected and returned +- **WHEN** multiple repositories fail +- **THEN** all errors SHALL be collected and the command SHALL exit with a non-zero exit code after all repositories are processed diff --git a/openspec/changes/concurrent-execution/specs/concurrent-execution/spec.md b/openspec/changes/concurrent-execution/specs/concurrent-execution/spec.md new file mode 100644 index 0000000..07e13f5 --- /dev/null +++ b/openspec/changes/concurrent-execution/specs/concurrent-execution/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Bounded worker pool execution +`gitctl` SHALL execute git commands across repositories using a bounded goroutine worker pool. The pool size SHALL be determined by `run_mode.concurrency`. When concurrency is `1`, behaviour SHALL be identical to sequential execution. + +#### Scenario: Concurrent execution with pool size > 1 +- **WHEN** `run_mode.concurrency` is set to `N` (N > 1) and there are M repositories (M > N) +- **THEN** at most N git commands SHALL run simultaneously at any given time + +#### Scenario: Default sequential behaviour preserved +- **WHEN** `run_mode.concurrency` is `1` (the default) +- **THEN** repositories SHALL be processed one at a time, matching previous sequential behaviour + +#### Scenario: Concurrency value below minimum is clamped +- **WHEN** `run_mode.concurrency` is set to `0` or a negative value +- **THEN** the tool SHALL clamp the pool size to `1` and proceed without error + +### Requirement: Ordered output regardless of completion order +Results SHALL be printed in the original discovery order, regardless of the order in which repositories complete. + +#### Scenario: Output order matches discovery order +- **WHEN** multiple repositories are processed concurrently and complete in arbitrary order +- **THEN** their output SHALL be printed in the same order as they were discovered (deterministic) + +### Requirement: Concurrency configuration type is integer +The `run_mode.concurrency` setting and its corresponding `--concurrency` / `-C` CLI flag SHALL be typed as an integer throughout the configuration layer. + +#### Scenario: Integer value accepted from config file +- **WHEN** `gitctl.yaml` contains `run_mode: {concurrency: 4}` +- **THEN** the pool SHALL use 4 workers + +#### Scenario: Integer value accepted from environment variable +- **WHEN** `GITCTL_RUN_MODE_CONCURRENCY=4` is set +- **THEN** the pool SHALL use 4 workers + +#### Scenario: Integer value accepted from CLI flag +- **WHEN** `--concurrency 4` or `-C 4` is passed +- **THEN** the pool SHALL use 4 workers diff --git a/openspec/changes/concurrent-execution/tasks.md b/openspec/changes/concurrent-execution/tasks.md new file mode 100644 index 0000000..7facdf8 --- /dev/null +++ b/openspec/changes/concurrent-execution/tasks.md @@ -0,0 +1,31 @@ +## 1. Fix Concurrency Configuration Type + +- [x] 1.1 Change `GetConcurrency()` in `config/env.go` to return `int` using `viper.GetInt` +- [x] 1.2 Update `concurrency` variable in `app/cmd/root.go` from `string` to `int` +- [x] 1.3 Change the CLI flag registration from `StringVarP` to `IntVarP` with default value `1` +- [x] 1.4 Verify config file, env var (`GITCTL_RUN_MODE_CONCURRENCY`), and flag all bind correctly as integers + +## 2. Implement Worker Pool in gitrepos.go + +- [x] 2.1 Define a `repoResult` struct holding `index int`, `output []byte`, and `err error` +- [x] 2.2 Replace the sequential `for` loop in `RunGitCommand` with a job channel fed by the discovered repo slice +- [x] 2.3 Spawn `GetConcurrency()` workers (clamped to minimum 1) as goroutines; each reads from the job channel and writes to a pre-allocated `[]repoResult` slice at the repo's discovery index +- [x] 2.4 Use a `sync.WaitGroup` to wait for all workers to finish before proceeding +- [x] 2.5 After all workers complete, iterate `[]repoResult` in index order: print output, collect errors, and register failures with the color package + +## 3. Update Specs + +- [x] 3.1 Update `openspec/specs/command-execution/spec.md` to reflect the concurrent execution model (remove the "sequential" guarantee, add ordered-output guarantee) +- [x] 3.2 Add `openspec/specs/concurrent-execution/spec.md` as the permanent spec for the worker pool capability (promote from change specs) + +## 4. Tests + +- [x] 4.1 Add a unit test in `gitrepo/gitrepos_test.go` verifying that results are printed in discovery order when concurrency > 1 +- [x] 4.2 Add a unit test verifying that a concurrency value of `0` or negative is clamped to `1` +- [x] 4.3 Update or add config tests in `config/env_test.go` to verify `GetConcurrency()` returns an `int` correctly from viper + +## 5. Verification + +- [x] 5.1 Run `go build ./...` — no compile errors +- [x] 5.2 Run `go test ./...` — all tests pass +- [x] 5.3 Run `gitctl --concurrency 3 status` against a directory with multiple repos and confirm output is in consistent order across runs diff --git a/openspec/specs/command-execution/spec.md b/openspec/specs/command-execution/spec.md index e497124..83af251 100644 --- a/openspec/specs/command-execution/spec.md +++ b/openspec/specs/command-execution/spec.md @@ -2,7 +2,7 @@ ## Overview -After discovering repositories, `gitctl` runs a Git command against each one. Currently supported commands are `status` and `pull`. +After discovering repositories, `gitctl` runs a Git command against each one using a bounded worker pool. Currently supported commands are `status` and `pull`. ## Supported Commands @@ -13,15 +13,15 @@ After discovering repositories, `gitctl` runs a Git command against each one. Cu ## Execution Model -Repositories are executed **sequentially** in discovery order. +Repositories are executed using a bounded goroutine worker pool controlled by `run_mode.concurrency` (default: `1`). When concurrency is `1`, behaviour is identical to sequential execution. -For each repository: -1. Run `git ` in the repository's directory. -2. Capture combined stdout+stderr output. -3. Format the output (see [output spec](../output/spec.md)). -4. If the command fails, record the failure; continue to the next repository. +For each repository a worker: +1. Runs `git ` in the repository's directory. +2. Captures combined stdout+stderr output. +3. Stores the result (output + error) at the repository's discovery index. +4. If the command fails, records the failure and continues to the next job. -All repository errors are collected. After all repositories are processed, errors are joined and returned as a single error. +After all workers complete, results are iterated in discovery order: output is printed and errors are aggregated. ## Dry-Run Mode @@ -32,16 +32,20 @@ When `run_mode.dry_run` is true: ## Concurrency -The configuration key `run_mode.concurrency` (default: `1`) and corresponding `--concurrency` flag exist but **concurrency is not yet implemented**. All execution is currently sequential regardless of this setting. +The `run_mode.concurrency` setting (default: `1`) and `--concurrency` / `-C` flag control the worker pool size. Values less than `1` are clamped to `1`. -See the concurrency change proposal for the planned implementation. +See the [concurrent-execution spec](../concurrent-execution/spec.md) for the full worker pool specification. ## Error Handling -- A failure in one repository does not stop execution for others. +- A failure in one repository does not stop others from being processed. - Exit code is non-zero if any repository command failed. - The error message per failure is extracted from git output: explicit `fatal:` / `error:` lines take priority; otherwise the first non-empty output line is used. +## Output Order + +Results are always printed in discovery order, regardless of the order in which workers complete. + ## Empty Repository List If no repositories are discovered (all base dirs empty or skipped), no commands are run and the tool exits with code 0. diff --git a/openspec/specs/concurrent-execution/spec.md b/openspec/specs/concurrent-execution/spec.md new file mode 100644 index 0000000..07e13f5 --- /dev/null +++ b/openspec/specs/concurrent-execution/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Bounded worker pool execution +`gitctl` SHALL execute git commands across repositories using a bounded goroutine worker pool. The pool size SHALL be determined by `run_mode.concurrency`. When concurrency is `1`, behaviour SHALL be identical to sequential execution. + +#### Scenario: Concurrent execution with pool size > 1 +- **WHEN** `run_mode.concurrency` is set to `N` (N > 1) and there are M repositories (M > N) +- **THEN** at most N git commands SHALL run simultaneously at any given time + +#### Scenario: Default sequential behaviour preserved +- **WHEN** `run_mode.concurrency` is `1` (the default) +- **THEN** repositories SHALL be processed one at a time, matching previous sequential behaviour + +#### Scenario: Concurrency value below minimum is clamped +- **WHEN** `run_mode.concurrency` is set to `0` or a negative value +- **THEN** the tool SHALL clamp the pool size to `1` and proceed without error + +### Requirement: Ordered output regardless of completion order +Results SHALL be printed in the original discovery order, regardless of the order in which repositories complete. + +#### Scenario: Output order matches discovery order +- **WHEN** multiple repositories are processed concurrently and complete in arbitrary order +- **THEN** their output SHALL be printed in the same order as they were discovered (deterministic) + +### Requirement: Concurrency configuration type is integer +The `run_mode.concurrency` setting and its corresponding `--concurrency` / `-C` CLI flag SHALL be typed as an integer throughout the configuration layer. + +#### Scenario: Integer value accepted from config file +- **WHEN** `gitctl.yaml` contains `run_mode: {concurrency: 4}` +- **THEN** the pool SHALL use 4 workers + +#### Scenario: Integer value accepted from environment variable +- **WHEN** `GITCTL_RUN_MODE_CONCURRENCY=4` is set +- **THEN** the pool SHALL use 4 workers + +#### Scenario: Integer value accepted from CLI flag +- **WHEN** `--concurrency 4` or `-C 4` is passed +- **THEN** the pool SHALL use 4 workers