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/gitstash.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 stashCmd = &cobra.Command{
Use: "stash",
Short: "Execute git stash 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.GitStash, baseDirs); err != nil {
// Errors have already been displayed via the color package.
return ErrSilent
}
return nil
},
}
28 changes: 28 additions & 0 deletions app/cmd/gitstash_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 TestStashCommandExecutesGitStashOnLocalRepo(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{"stash", "--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 @@ -94,6 +94,7 @@ func init() {
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(pullCmd)
rootCmd.AddCommand(fetchCmd)
rootCmd.AddCommand(stashCmd)
}

func InitConfig() error {
Expand Down
1 change: 1 addition & 0 deletions gitrepo/gitrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type GitRepo struct {
const (
GitFetch = "fetch"
GitPull = "pull"
GitStash = "stash"
GitStatus = "status"
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-29
34 changes: 34 additions & 0 deletions openspec/changes/archive/2026-05-29-add-stash-command/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## 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 stash` (equivalent to `git stash push`) saves uncommitted changes to the stash stack and restores a clean working tree. It exits 0 whether or not there were changes to stash; repos with nothing to stash emit "No local changes to save" to stdout. This is distinct from an error.

## Goals / Non-Goals

**Goals:**
- Add `gitctl stash` that runs `git stash` across all discovered repositories.
- Treat "No local changes to save" (exit 0) as a success, not a failure — consistent with how git itself behaves.
- Keep implementation identical in structure to `gitfetch.go` / `gitpull.go`.

**Non-Goals:**
- `git stash pop` / `git stash apply` / `git stash list` sub-subcommands — these are natural follow-ons but out of scope for this change.
- A `gitctl stash pop` paired shortcut — deferred to a future `stash-subcommands` change.
- Any stash message (`-m`) or include-untracked (`-u`) flag support in this iteration.

## Decisions

### Decision 1: Run `git stash` (not `git stash push`)

**Chosen**: Execute `exec.Command("git", "stash")` rather than `exec.Command("git", "stash", "push")`. Both are equivalent since git 2.13, and the shorter form matches what users type interactively.

### Decision 2: No special-casing of "No local changes to save"

**Chosen**: The worker pool collects raw output and non-zero exit codes as errors. `git stash` exits 0 when there is nothing to stash, so the existing error handling naturally treats this as success. No special logic needed.

**Why not filter the message?** The output formatter already handles the output display. Filtering would add complexity for no user benefit — the message is informative, not alarming.

## Risks / Trade-offs

- **Stash stack pollution** — Running `gitctl stash` across many repos creates one stash entry per repo that had changes. Users must remember to `git stash pop` in each repo. → Document in command short description; a future `gitctl stash pop` command addresses recovery.
- **Untracked files not stashed by default** — Plain `git stash` does not stash untracked files. Users needing that would use `git stash -u` manually. → Acceptable for v1; can add `--include-untracked` flag in a follow-on.
27 changes: 27 additions & 0 deletions openspec/changes/archive/2026-05-29-add-stash-command/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Why

Before running `gitctl pull` across many repositories, any local modifications must be safely stowed away or the pull will fail or produce conflicts. Doing `git stash` manually in each repo is tedious. A `gitctl stash` command fans out `git stash` across all discovered repositories in one step, making the common "stash → pull → pop" workflow frictionless.

## What Changes

- Add a `stash` subcommand that runs `git stash` (i.e. `git stash push`) on all discovered repositories.
- Register `stash` alongside `status`, `pull`, and `fetch` in the root command.
- Extend the `runRaw` command switch to handle `stash`.
- Add `stash` to the supported commands table in the command-execution spec.

## Capabilities

### New Capabilities

- `stash-command`: The `gitctl stash` subcommand — runs `git stash` concurrently across all discovered repositories, collects results in discovery order, and reports success/failures in the standard output format. Repos with no local changes produce a "No local changes to save" message (exit 0); this is not treated as an error.

### Modified Capabilities

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

## Impact

- `app/cmd/gitstash.go`: New file defining the `stashCmd` cobra command.
- `app/cmd/root.go`: Register `stashCmd`.
- `gitrepo/gitrepo.go`: Add `GitStash` constant and `stashCommand` const; handle in the `runRaw` switch.
- `openspec/specs/command-execution/spec.md`: Add `stash` to the supported commands table.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## 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` |

#### Scenario: stash subcommand dispatches git stash
- **WHEN** the user runs `gitctl stash`
- **THEN** `git stash` is executed in every discovered repository
- **THEN** results are printed in discovery order using the standard output format
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## ADDED Requirements

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

#### Scenario: Stash succeeds in repository with changes
- **WHEN** a repository has uncommitted changes
- **THEN** `git stash` runs successfully, the working tree is cleaned, and the result is displayed in the standard per-repository output format

#### Scenario: Stash on clean repository exits 0
- **WHEN** a repository has no uncommitted changes
- **THEN** `git stash` exits 0 and emits "No local changes to save"
- **THEN** this is treated as success, not an error

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

### Requirement: git stash command form
The implementation SHALL execute `exec.Command("git", "stash")` (not `exec.Command("git", "stash", "push")`).

#### Scenario: Short form used
- **WHEN** the stash subcommand is invoked
- **THEN** the process spawned is `git stash` with no additional sub-subcommand argument

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

#### Scenario: Dry-run with stash
- **WHEN** `gitctl stash --dry-run` is invoked
- **THEN** no `git stash` processes are spawned
- **THEN** a message is printed for each repository indicating what would have run
25 changes: 25 additions & 0 deletions openspec/changes/archive/2026-05-29-add-stash-command/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## 1. Core Implementation

- [x] 1.1 Add `GitStash = "stash"` constant and `stashCommand = "stash"` in `gitrepo/gitrepo.go`
- [x] 1.2 Add `case GitStash` to the `runRaw` command switch in `gitrepo/gitrepo.go`
- [x] 1.3 Create `app/cmd/gitstash.go` defining `stashCmd` following the same pattern as `gitfetch.go`; set short description to "Execute git stash on multiple git repositories."
- [x] 1.4 Register `stashCmd` with `rootCmd.AddCommand(stashCmd)` in `app/cmd/root.go`

## 2. Tests

- [x] 2.1 Add `TestRunGitStash` in `app/cmd/gitstash_test.go` (dry-run mode, mirrors `gitfetch_test.go`)
- [x] 2.2 Add `TestGitRepoRunGitStash` in `gitrepo/gitrepo_test.go` verifying `GitStash` executes against a valid repo path
- [x] 2.3 Add `TestRunGitStashCommand` in `gitrepo/gitrepos_test.go` verifying `RunGitCommand(GitStash, baseDirs)` succeeds in dry-run

## 3. Spec Promotion

- [x] 3.1 Add `stash` to the supported commands table in `openspec/specs/command-execution/spec.md`
- [x] 3.2 Create `openspec/specs/stash-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 stash --help` shows the correct usage description

3 changes: 2 additions & 1 deletion openspec/specs/command-execution/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

After discovering repositories, `gitctl` runs a Git command against each one using a bounded worker pool. 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`, `pull`, `fetch`, and `stash`.

## Supported Commands

Expand All @@ -11,6 +11,7 @@ After discovering repositories, `gitctl` runs a Git command against each one usi
| `status` | `git status` |
| `pull` | `git pull` |
| `fetch` | `git fetch` |
| `stash` | `git stash` |

## Execution Model

Expand Down
33 changes: 33 additions & 0 deletions openspec/specs/stash-command/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## ADDED Requirements

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

#### Scenario: Stash succeeds in repository with changes
- **WHEN** a repository has uncommitted changes
- **THEN** `git stash` runs successfully, the working tree is cleaned, and the result is displayed in the standard per-repository output format

#### Scenario: Stash on clean repository exits 0
- **WHEN** a repository has no uncommitted changes
- **THEN** `git stash` exits 0 and emits "No local changes to save"
- **THEN** this is treated as success, not an error

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

### Requirement: git stash command form
The implementation SHALL execute `exec.Command("git", "stash")` (not `exec.Command("git", "stash", "push")`).

#### Scenario: Short form used
- **WHEN** the stash subcommand is invoked
- **THEN** the process spawned is `git stash` with no additional sub-subcommand argument

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

#### Scenario: Dry-run with stash
- **WHEN** `gitctl stash --dry-run` is invoked
- **THEN** no `git stash` processes are spawned
- **THEN** a message is printed for each repository indicating what would have run
Loading