Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/cmd/gitfetch.go
Original file line number Diff line number Diff line change
@@ -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
},
}
28 changes: 28 additions & 0 deletions app/cmd/gitfetch_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions app/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func init() {

rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(pullCmd)
rootCmd.AddCommand(fetchCmd)
}

func InitConfig() error {
Expand Down
4 changes: 4 additions & 0 deletions gitrepo/gitrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
const (
gitDirToSearch = ".git"
gitCommand = "git"
fetchCommand = "fetch"
pullCommand = "pull"
statusCommand = "status"
)
Expand All @@ -25,6 +26,7 @@ type GitRepo struct {
}

const (
GitFetch = "fetch"
GitPull = "pull"
GitStatus = "status"
)
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions gitrepo/gitrepo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""}
Expand Down
13 changes: 13 additions & 0 deletions gitrepo/gitrepos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions openspec/changes/add-fetch-command/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-29
31 changes: 31 additions & 0 deletions openspec/changes/add-fetch-command/design.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions openspec/changes/add-fetch-command/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions openspec/changes/add-fetch-command/specs/fetch-command/spec.md
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions openspec/changes/add-fetch-command/tasks.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions openspec/specs/command-execution/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions openspec/specs/fetch-command/spec.md
Original file line number Diff line number Diff line change
@@ -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
Loading