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/gitbranch.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 branchCmd = &cobra.Command{
Use: "branch",
Short: "Show current branch for 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.GitBranch, baseDirs); err != nil {
// Errors have already been displayed via the color package.
return ErrSilent
}
return nil
},
}
28 changes: 28 additions & 0 deletions app/cmd/gitbranch_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 TestBranchCommandExecutesGitBranchOnLocalRepo(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{"branch", "--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 @@ -95,6 +95,7 @@ func init() {
rootCmd.AddCommand(pullCmd)
rootCmd.AddCommand(fetchCmd)
rootCmd.AddCommand(stashCmd)
rootCmd.AddCommand(branchCmd)
}

func InitConfig() error {
Expand Down
7 changes: 7 additions & 0 deletions gitrepo/gitrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import (
const (
gitDirToSearch = ".git"
gitCommand = "git"
branchCommand = "branch"
fetchCommand = "fetch"
pullCommand = "pull"
stashCommand = "stash"
statusCommand = "status"
)

Expand All @@ -26,6 +28,7 @@ type GitRepo struct {
}

const (
GitBranch = "branch"
GitFetch = "fetch"
GitPull = "pull"
GitStash = "stash"
Expand Down Expand Up @@ -83,10 +86,14 @@ func (gitRepo *GitRepo) runRaw(command string) ([]byte, error) {

var gitCmd *exec.Cmd
switch command {
case GitBranch:
gitCmd = exec.Command(gitCommand, branchCommand, "--show-current")
case GitFetch:
gitCmd = exec.Command(gitCommand, fetchCommand)
case GitPull:
gitCmd = exec.Command(gitCommand, pullCommand)
case GitStash:
gitCmd = exec.Command(gitCommand, stashCommand)
case GitStatus:
gitCmd = exec.Command(gitCommand, statusCommand)
default:
Expand Down
11 changes: 11 additions & 0 deletions gitrepo/gitrepo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ func TestGitRepoRunGitFetch(t *testing.T) {
_ = err
}

func TestGitRepoRunGitBranch(t *testing.T) {
testDir, _ := filepath.Abs(microserviceDirPath)
gitRepo := GitRepo{path: testDir}

output, err := gitRepo.RunGitCommand(GitBranch)

// branch --show-current on a bare fixture repo may return empty output; just verify no crash
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 @@ -162,6 +162,19 @@ func TestRunWithWorkerPoolPreservesDiscoveryOrder(t *testing.T) {
assert.NoError(t, results[2].err)
}

func TestRunGitBranchCommand(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
viper.Set(config.GitCtlDryRun, true)

command := GitBranch
testDir, _ := filepath.Abs(testDirPath)
baseDirs := []string{testDir}

err := RunGitCommand(command, baseDirs)
assert.NoError(t, err)
}

func TestRunGitFetchCommand(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-29
45 changes: 45 additions & 0 deletions openspec/changes/archive/2026-05-29-add-branch-command/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## Context

`gitctl` provides uniform fan-out of git commands across multiple repositories via a shared worker pool in `gitrepo/gitrepos.go`. Adding a new command follows a well-established three-step pattern: add a constant + switch case in `gitrepo/gitrepo.go`, create a cobra command file in `app/cmd/`, and register it in `root.go`.

`git branch --show-current` prints the name of the currently checked-out branch and exits 0. In detached HEAD state it prints an empty string and still exits 0.

## Goals / Non-Goals

**Goals:**
- Add `gitctl branch` that runs `git branch --show-current` across all discovered repositories.
- Show the current branch name per repo using the standard output format.
- Keep implementation structurally identical to `gitfetch.go` / `gitstash.go`.

**Non-Goals:**
- Listing all branches (`git branch --all`) — that is a distinct use case for a future change.
- Switching branches across repos — out of scope.
- Filtering or grouping repos by branch name — deferred to a future change.
- Special-casing detached HEAD with a human-friendly label — acceptable edge case; empty output is unambiguous to git users.

## Decisions

### Decision 1: Use `git branch --show-current` (not `git rev-parse --abbrev-ref HEAD`)

**Chosen**: `exec.Command("git", "branch", "--show-current")`.

`--show-current` was introduced in git 2.22 (2019) and is the canonical modern form. It is clean, predictable, and unambiguous. `git rev-parse --abbrev-ref HEAD` is a workaround for older gits and returns the literal string `"HEAD"` in detached state rather than empty — less clean.

**Trade-off**: Requires git ≥ 2.22. gitctl has no explicit minimum git version today; this is acceptable given git 2.22 is over 5 years old.

### Decision 2: Pass `--show-current` as a second argument in the switch case

**Chosen**: The `runRaw` switch handles `GitBranch` with `exec.Command(gitCommand, branchCommand, "--show-current")`. The `branchCommand` constant is `"branch"` (consistent with other single-word constants). The flag is passed inline in the case — no additional constant needed.

**Why not a separate constant?** The flag is not reused elsewhere; inlining it keeps the code readable without adding noisy constants.

### Decision 3: Detached HEAD emits empty output — no special handling

**Chosen**: `git branch --show-current` exits 0 with empty stdout in detached HEAD state. The existing output formatter will display an empty body for that repo. No special-casing is added.

**Why not add a "(detached HEAD)" label?** Would require touching the `color` package global state or adding logic to `FormatOutput`, adding complexity for a rare edge case. A future `output-enhancements` change can address this.

## Risks / Trade-offs

- **git < 2.22** — `--show-current` is not recognised; git exits non-zero and reports an error. → Documented in command description; acceptable given the age of git 2.22.
- **Empty output for detached HEAD** — may confuse users who expect to see "HEAD" or similar. → Acceptable for v1; document in short description or a future follow-on.
27 changes: 27 additions & 0 deletions openspec/changes/archive/2026-05-29-add-branch-command/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Why

When working across many feature branches simultaneously, it's essential to know which branch each repository is on at a glance. `git status` provides this but includes working-tree noise; there's no clean `gitctl` command that shows just the current branch per repo.

## What Changes

- Add a `branch` subcommand that runs `git branch --show-current` on all discovered repositories and reports the current branch name for each.
- Register `branch` alongside `status`, `pull`, `fetch`, and `stash` in the root command.
- Extend the `runRaw` command switch to handle `branch`.
- Add `branch` to the supported commands table in the command-execution spec.

## Capabilities

### New Capabilities

- `branch-command`: The `gitctl branch` subcommand — runs `git branch --show-current` concurrently across all discovered repositories, collects results in discovery order, and reports the current branch name per repo using the standard output format.

### Modified Capabilities

- `command-execution`: A new supported command (`branch`) is added to the command table. No behavioral changes to the execution model itself.

## Impact

- `app/cmd/gitbranch.go`: New file defining the `branchCmd` cobra command.
- `app/cmd/root.go`: Register `branchCmd`.
- `gitrepo/gitrepo.go`: Add `GitBranch` constant and `branchCommand` const (`--show-current` flag passed); handle in the `runRaw` switch.
- `openspec/specs/command-execution/spec.md`: Add `branch` to the supported commands table.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## ADDED Requirements

### Requirement: gitctl branch subcommand exists
The CLI SHALL expose a `branch` subcommand that runs `git branch --show-current` concurrently across all repositories discovered from the configured base directories.

#### Scenario: Branch shows current branch name
- **WHEN** a repository is on a named branch
- **THEN** `git branch --show-current` outputs the branch name
- **THEN** the result is displayed in the standard per-repository output format

#### Scenario: Branch on detached HEAD exits 0
- **WHEN** a repository is in detached HEAD state
- **THEN** `git branch --show-current` exits 0 with empty stdout
- **THEN** this is treated as success, not an error

#### Scenario: Branch runs concurrently across all repositories
- **WHEN** multiple repositories are discovered
- **THEN** `git branch --show-current` is dispatched to all repositories via the existing worker pool
- **THEN** results are collected and printed in discovery order

### Requirement: git branch command form
The implementation SHALL execute `exec.Command("git", "branch", "--show-current")`.

#### Scenario: Correct command form used
- **WHEN** the branch subcommand is invoked
- **THEN** the process spawned is `git branch --show-current` with no additional arguments

### Requirement: Branch honours dry-run mode
When `--dry-run` is set, the branch subcommand SHALL not spawn any git processes, following the existing dry-run contract.

#### Scenario: Dry-run with branch
- **WHEN** `gitctl branch --dry-run` is invoked
- **THEN** no `git branch` processes are spawned
- **THEN** a message is printed for each repository indicating what would have run
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## MODIFIED Requirements

### Requirement: Supported commands
`gitctl` SHALL support the following subcommands, each mapping to a single `git` invocation per repository.

| Subcommand | Git command executed |
|------------|-------------------------------|
| `status` | `git status` |
| `pull` | `git pull` |
| `fetch` | `git fetch` |
| `stash` | `git stash` |
| `branch` | `git branch --show-current` |

#### Scenario: branch subcommand dispatches git branch --show-current
- **WHEN** the user runs `gitctl branch`
- **THEN** `git branch --show-current` is executed in every discovered repository
- **THEN** results are printed in discovery order using the standard output format
24 changes: 24 additions & 0 deletions openspec/changes/archive/2026-05-29-add-branch-command/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## 1. Core Implementation

- [x] 1.1 Add `GitBranch = "branch"` constant and `branchCommand = "branch"` in `gitrepo/gitrepo.go`
- [x] 1.2 Add `case GitBranch` to the `runRaw` command switch in `gitrepo/gitrepo.go`, executing `exec.Command(gitCommand, branchCommand, "--show-current")`
- [x] 1.3 Create `app/cmd/gitbranch.go` defining `branchCmd` following the same pattern as `gitfetch.go`; set short description to "Show current branch for multiple git repositories."
- [x] 1.4 Register `branchCmd` with `rootCmd.AddCommand(branchCmd)` in `app/cmd/root.go`

## 2. Tests

- [x] 2.1 Add `TestBranchCommandExecutesGitBranchOnLocalRepo` in `app/cmd/gitbranch_test.go` (dry-run mode, mirrors `gitfetch_test.go`)
- [x] 2.2 Add `TestGitRepoRunGitBranch` in `gitrepo/gitrepo_test.go` verifying `GitBranch` executes against a valid repo path
- [x] 2.3 Add `TestRunGitBranchCommand` in `gitrepo/gitrepos_test.go` verifying `RunGitCommand(GitBranch, baseDirs)` succeeds in dry-run

## 3. Spec Promotion

- [x] 3.1 Add `branch` to the supported commands table in `openspec/specs/command-execution/spec.md`
- [x] 3.2 Create `openspec/specs/branch-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 branch --help` shows the correct usage description
34 changes: 34 additions & 0 deletions openspec/specs/branch-command/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## ADDED Requirements

### Requirement: gitctl branch subcommand exists
The CLI SHALL expose a `branch` subcommand that runs `git branch --show-current` concurrently across all repositories discovered from the configured base directories.

#### Scenario: Branch shows current branch name
- **WHEN** a repository is on a named branch
- **THEN** `git branch --show-current` outputs the branch name
- **THEN** the result is displayed in the standard per-repository output format

#### Scenario: Branch on detached HEAD exits 0
- **WHEN** a repository is in detached HEAD state
- **THEN** `git branch --show-current` exits 0 with empty stdout
- **THEN** this is treated as success, not an error

#### Scenario: Branch runs concurrently across all repositories
- **WHEN** multiple repositories are discovered
- **THEN** `git branch --show-current` is dispatched to all repositories via the existing worker pool
- **THEN** results are collected and printed in discovery order

### Requirement: git branch command form
The implementation SHALL execute `exec.Command("git", "branch", "--show-current")`.

#### Scenario: Correct command form used
- **WHEN** the branch subcommand is invoked
- **THEN** the process spawned is `git branch --show-current` with no additional arguments

### Requirement: Branch honours dry-run mode
When `--dry-run` is set, the branch subcommand SHALL not spawn any git processes, following the existing dry-run contract.

#### Scenario: Dry-run with branch
- **WHEN** `gitctl branch --dry-run` is invoked
- **THEN** no `git branch` processes are spawned
- **THEN** a message is printed for each repository indicating what would have run
15 changes: 8 additions & 7 deletions openspec/specs/command-execution/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

## Overview

After discovering repositories, `gitctl` runs a Git command against each one using a bounded worker pool. Currently supported commands are `status`, `pull`, `fetch`, and `stash`.
After discovering repositories, `gitctl` runs a Git command against each one using a bounded worker pool. Currently supported commands are `status`, `pull`, `fetch`, `stash`, and `branch`.

## Supported Commands

| Subcommand | Git command executed |
|------------|----------------------|
| `status` | `git status` |
| `pull` | `git pull` |
| `fetch` | `git fetch` |
| `stash` | `git stash` |
| Subcommand | Git command executed |
|------------|-------------------------------|
| `status` | `git status` |
| `pull` | `git pull` |
| `fetch` | `git fetch` |
| `stash` | `git stash` |
| `branch` | `git branch --show-current` |

## Execution Model

Expand Down
Loading