diff --git a/app/cmd/gitfetch.go b/app/cmd/gitfetch.go new file mode 100644 index 0000000..d9d74e2 --- /dev/null +++ b/app/cmd/gitfetch.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/bjoernkarma/gitctl/config" + "github.com/bjoernkarma/gitctl/gitrepo" +) + +var fetchCmd = &cobra.Command{ + Use: "fetch", + Short: "Execute git fetch on multiple git repositories.", + RunE: func(cmd *cobra.Command, args []string) error { + baseDirs, err := config.GetBaseDirs() + if err != nil { + return err + } + if err := gitrepo.RunGitCommand(gitrepo.GitFetch, baseDirs); err != nil { + // Errors have already been displayed via the color package. + return ErrSilent + } + return nil + }, +} diff --git a/app/cmd/gitfetch_test.go b/app/cmd/gitfetch_test.go new file mode 100644 index 0000000..a37f3df --- /dev/null +++ b/app/cmd/gitfetch_test.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "bytes" + "log" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFetchCommandExecutesGitFetchOnLocalRepo(t *testing.T) { + var buf bytes.Buffer + t.Setenv("GITCTL_VERBOSITY_DEBUG", "true") + originalLogWriter := log.Writer() + log.SetOutput(&buf) + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + defer func() { + log.SetOutput(originalLogWriter) + }() + + rootCmd.SetArgs([]string{"fetch", "--local", "--debug", "--verbose"}) + err := rootCmd.Execute() + + expected := "Configuration settings:" + assert.Contains(t, buf.String(), expected, "expected %v to be contained in %v", expected, buf.String()) + assert.NoError(t, err) +} diff --git a/app/cmd/root.go b/app/cmd/root.go index ce101dd..6818c7c 100644 --- a/app/cmd/root.go +++ b/app/cmd/root.go @@ -93,6 +93,7 @@ func init() { rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(pullCmd) + rootCmd.AddCommand(fetchCmd) } func InitConfig() error { diff --git a/gitrepo/gitrepo.go b/gitrepo/gitrepo.go index 7762792..95b02b3 100644 --- a/gitrepo/gitrepo.go +++ b/gitrepo/gitrepo.go @@ -14,6 +14,7 @@ import ( const ( gitDirToSearch = ".git" gitCommand = "git" + fetchCommand = "fetch" pullCommand = "pull" statusCommand = "status" ) @@ -25,6 +26,7 @@ type GitRepo struct { } const ( + GitFetch = "fetch" GitPull = "pull" GitStatus = "status" ) @@ -80,6 +82,8 @@ func (gitRepo *GitRepo) runRaw(command string) ([]byte, error) { var gitCmd *exec.Cmd switch command { + case GitFetch: + gitCmd = exec.Command(gitCommand, fetchCommand) case GitPull: gitCmd = exec.Command(gitCommand, pullCommand) case GitStatus: diff --git a/gitrepo/gitrepo_test.go b/gitrepo/gitrepo_test.go index 4b8ff2d..87c2a92 100644 --- a/gitrepo/gitrepo_test.go +++ b/gitrepo/gitrepo_test.go @@ -91,6 +91,17 @@ func TestGitRepoRunGitStatus(t *testing.T) { assert.NotNil(t, output) } +func TestGitRepoRunGitFetch(t *testing.T) { + testDir, _ := filepath.Abs(microserviceDirPath) + gitRepo := GitRepo{path: testDir} + + output, err := gitRepo.RunGitCommand(GitFetch) + + // fetch on a repo with no remote will fail; we just verify output is returned + assert.NotNil(t, output) + _ = err +} + func TestGitRepoEmptyRunGitStatus(t *testing.T) { // Call the function under test gitRepo := GitRepo{path: ""} diff --git a/gitrepo/gitrepos_test.go b/gitrepo/gitrepos_test.go index c61315f..adfc792 100644 --- a/gitrepo/gitrepos_test.go +++ b/gitrepo/gitrepos_test.go @@ -161,3 +161,16 @@ func TestRunWithWorkerPoolPreservesDiscoveryOrder(t *testing.T) { assert.Error(t, results[1].err) assert.NoError(t, results[2].err) } + +func TestRunGitFetchCommand(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(config.GitCtlDryRun, true) + + command := GitFetch + testDir, _ := filepath.Abs(testDirPath) + baseDirs := []string{testDir} + + err := RunGitCommand(command, baseDirs) + assert.NoError(t, err) +} diff --git a/openspec/changes/add-fetch-command/.openspec.yaml b/openspec/changes/add-fetch-command/.openspec.yaml new file mode 100644 index 0000000..1194417 --- /dev/null +++ b/openspec/changes/add-fetch-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-29 diff --git a/openspec/changes/add-fetch-command/design.md b/openspec/changes/add-fetch-command/design.md new file mode 100644 index 0000000..9bd678c --- /dev/null +++ b/openspec/changes/add-fetch-command/design.md @@ -0,0 +1,31 @@ +## Context + +`gitctl` uses a uniform pattern for all git subcommands: a cobra command file calls `config.GetBaseDirs()` and `gitrepo.RunGitCommand(command, baseDirs)`. The worker pool, error handling, output formatting, and concurrency are all handled by the shared execution layer — adding a new command requires no changes to that layer. + +`git fetch` updates remote-tracking refs (e.g. `origin/main`) without modifying the working tree or local branches. It is the standard way to check what's changed upstream without committing to a merge. + +## Goals / Non-Goals + +**Goals:** +- Add a `fetch` subcommand that runs `git fetch` across all discovered repositories using the existing execution infrastructure. +- Keep the implementation as small as a copy of `gitpull.go` with `GitFetch` substituted for `GitPull`. + +**Non-Goals:** +- Fetch-specific flags (e.g. `--prune`, `--all`, `--tags`) — plain `git fetch` is sufficient for the first iteration. +- Fetch-then-status combined output. +- Any changes to the worker pool, output formatting, or config system. + +## Decisions + +### Decision 1: Reuse the existing command pattern verbatim + +`gitstatus.go` and `gitpull.go` are identical in structure — one new file `gitfetch.go` follows the same pattern. No abstraction is needed; the pattern is clear and consistent. + +### Decision 2: Add `GitFetch` constant and `fetch` case to `runRaw` + +The `runRaw` switch in `gitrepo.go` maps command strings to `exec.Cmd`. Adding a `fetchCommand = "fetch"` constant and a `case GitFetch` keeps the pattern consistent with `pull` and `status`. + +## Risks / Trade-offs + +- **Remote availability** — `git fetch` requires network access. Failures are handled identically to `git pull` failures: recorded, reported, non-zero exit. → No mitigation needed beyond existing error handling. +- **Scope creep** — fetch flags (`--prune`, `--all`) will likely be requested. Keeping this first implementation flag-free makes the scope clear. → Document as a non-goal; handle in a future change. diff --git a/openspec/changes/add-fetch-command/proposal.md b/openspec/changes/add-fetch-command/proposal.md new file mode 100644 index 0000000..9dfec07 --- /dev/null +++ b/openspec/changes/add-fetch-command/proposal.md @@ -0,0 +1,26 @@ +## Why + +`gitctl` supports `status` and `pull` across multiple repositories, but lacks `fetch` — a safe, read-only operation that updates remote-tracking refs without touching the working tree. This makes it impossible to check whether repos are behind their remotes without also running a full pull. + +## What Changes + +- Add a `fetch` subcommand that runs `git fetch` on all discovered repositories. +- Register `fetch` alongside `status` and `pull` in the root command. +- Extend the `GitRepo.runRaw` command switch to handle `fetch`. + +## Capabilities + +### New Capabilities + +- `fetch-command`: The `gitctl fetch` subcommand — runs `git fetch` concurrently across all discovered repositories, collects results in discovery order, and reports success/failures in the standard output format. + +### Modified Capabilities + +- `command-execution`: A new supported command (`fetch`) is added to the command table. No behavioral changes to the execution model itself. + +## Impact + +- `app/cmd/gitfetch.go`: New file defining the `fetchCmd` cobra command. +- `app/cmd/root.go`: Register `fetchCmd`. +- `gitrepo/gitrepo.go`: Add `GitFetch` constant and handle it in the `runRaw` switch. +- `openspec/specs/command-execution/spec.md`: Add `fetch` to the supported commands table. diff --git a/openspec/changes/add-fetch-command/specs/command-execution/spec.md b/openspec/changes/add-fetch-command/specs/command-execution/spec.md new file mode 100644 index 0000000..ac70695 --- /dev/null +++ b/openspec/changes/add-fetch-command/specs/command-execution/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: Fetch is a supported command +The `fetch` command SHALL be a valid value for the git command executed by the worker pool, alongside `status` and `pull`. + +#### Scenario: gitctl fetch maps to git fetch +- **WHEN** the `fetch` subcommand is invoked +- **THEN** `git fetch` SHALL be executed in each discovered repository's directory diff --git a/openspec/changes/add-fetch-command/specs/fetch-command/spec.md b/openspec/changes/add-fetch-command/specs/fetch-command/spec.md new file mode 100644 index 0000000..fe307a2 --- /dev/null +++ b/openspec/changes/add-fetch-command/specs/fetch-command/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Fetch command runs git fetch across all repositories +The `gitctl fetch` subcommand SHALL run `git fetch` on every discovered repository using the shared worker pool execution model. Results SHALL be collected in discovery order and reported in the standard output format. + +#### Scenario: Successful fetch across multiple repos +- **WHEN** `gitctl fetch` is invoked with one or more base directories containing git repositories +- **THEN** `git fetch` SHALL be executed on each repository and output SHALL be printed in discovery order + +#### Scenario: Fetch respects dry-run mode +- **WHEN** `run_mode.dry_run` is true +- **THEN** no `git fetch` process SHALL be spawned and a dry-run message SHALL be printed for each repository + +#### Scenario: Fetch respects concurrency setting +- **WHEN** `run_mode.concurrency` is set to N > 1 +- **THEN** up to N fetch operations SHALL run simultaneously + +#### Scenario: Single repository fetch failure does not stop others +- **WHEN** one repository's `git fetch` fails (e.g. no network, remote not configured) +- **THEN** remaining repositories SHALL still be processed and the command SHALL exit non-zero after all are complete diff --git a/openspec/changes/add-fetch-command/tasks.md b/openspec/changes/add-fetch-command/tasks.md new file mode 100644 index 0000000..e0b6188 --- /dev/null +++ b/openspec/changes/add-fetch-command/tasks.md @@ -0,0 +1,24 @@ +## 1. Core Implementation + +- [x] 1.1 Add `GitFetch = "fetch"` constant and `fetchCommand = "fetch"` in `gitrepo/gitrepo.go` +- [x] 1.2 Add `case GitFetch` to the `runRaw` command switch in `gitrepo/gitrepo.go` +- [x] 1.3 Create `app/cmd/gitfetch.go` defining `fetchCmd` following the same pattern as `gitpull.go` +- [x] 1.4 Register `fetchCmd` with `rootCmd.AddCommand(fetchCmd)` in `app/cmd/root.go` + +## 2. Tests + +- [x] 2.1 Add `TestRunGitFetch` in `app/cmd/gitfetch_test.go` (dry-run mode, mirrors `gitpull_test.go`) +- [x] 2.2 Add `TestGitRepoRunGitFetch` in `gitrepo/gitrepo_test.go` verifying `GitFetch` executes against a valid repo path +- [x] 2.3 Add `TestRunGitFetchCommand` in `gitrepo/gitrepos_test.go` verifying `RunGitCommand(GitFetch, baseDirs)` succeeds in dry-run + +## 3. Spec Promotion + +- [x] 3.1 Add `fetch` to the supported commands table in `openspec/specs/command-execution/spec.md` +- [x] 3.2 Create `openspec/specs/fetch-command/spec.md` (promote from change specs) + +## 4. Verification + +- [x] 4.1 Run `go build ./...` — no compile errors +- [x] 4.2 Run `go test -race ./...` — all tests pass, no races +- [x] 4.3 Run `go fmt ./...` on changed files +- [x] 4.4 Verify `gitctl fetch --help` shows the correct usage description diff --git a/openspec/specs/command-execution/spec.md b/openspec/specs/command-execution/spec.md index 83af251..deba5ff 100644 --- a/openspec/specs/command-execution/spec.md +++ b/openspec/specs/command-execution/spec.md @@ -10,6 +10,7 @@ After discovering repositories, `gitctl` runs a Git command against each one usi |------------|----------------------| | `status` | `git status` | | `pull` | `git pull` | +| `fetch` | `git fetch` | ## Execution Model diff --git a/openspec/specs/fetch-command/spec.md b/openspec/specs/fetch-command/spec.md new file mode 100644 index 0000000..fe307a2 --- /dev/null +++ b/openspec/specs/fetch-command/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Fetch command runs git fetch across all repositories +The `gitctl fetch` subcommand SHALL run `git fetch` on every discovered repository using the shared worker pool execution model. Results SHALL be collected in discovery order and reported in the standard output format. + +#### Scenario: Successful fetch across multiple repos +- **WHEN** `gitctl fetch` is invoked with one or more base directories containing git repositories +- **THEN** `git fetch` SHALL be executed on each repository and output SHALL be printed in discovery order + +#### Scenario: Fetch respects dry-run mode +- **WHEN** `run_mode.dry_run` is true +- **THEN** no `git fetch` process SHALL be spawned and a dry-run message SHALL be printed for each repository + +#### Scenario: Fetch respects concurrency setting +- **WHEN** `run_mode.concurrency` is set to N > 1 +- **THEN** up to N fetch operations SHALL run simultaneously + +#### Scenario: Single repository fetch failure does not stop others +- **WHEN** one repository's `git fetch` fails (e.g. no network, remote not configured) +- **THEN** remaining repositories SHALL still be processed and the command SHALL exit non-zero after all are complete