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
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Repository Guidelines

## Project Structure & Module Organization
`gih/main.go` is the CLI entry point. Core behavior lives in `syncer/`: `dir.go` discovers direct child repositories, `git.go` runs git commands, and `syncer.go` coordinates parallel execution. Output formatting lives in `printer/`. Tests sit next to the code as `*_test.go`. CI and templates are under `.github/`.

## Build, Test, and Development Commands
Use Go 1.26.

- `make build`: builds a development binary as `./gih_dev`.
- `make install`: installs `gih` from `./gih`.
- `make test`: runs `go test -v -race ./...`.
- `make lint`: runs `golangci-lint` as used in CI.
- `make tidy`: normalizes `go.mod` and `go.sum`.
- `DEBUG=* go run ./gih status`: handy for manual CLI checks.

Before opening a PR, run `make build && make test && make lint`.

## Coding Style & Naming Conventions
Follow standard Go formatting with `gofmt`; keep packages small and focused. Prefer the standard library over new dependencies when possible. Use clear package scopes such as `feat(syncer)` or `fix(printer)` when naming changes. Keep filenames lowercase, and keep tests in the same package unless black-box coverage is required.

## Testing Guidelines
This repository uses Go’s `testing` package plus `github.com/matryer/is`. Write focused tests with `t.Run(...)`; avoid table-driven tests here. Use `t.Parallel()` whenever the case is safe to parallelize. Name receiver-related tests as `Test{Struct}_Xxx`, for example `TestSync_Execute`. Run targeted checks with `go test -v ./syncer/` and use `go test -cover ./...` when touching behavior broadly.

## Commit & Pull Request Guidelines
Recent history follows Conventional Commits, for example `feat(gih): ...`, `refactor(syncer): ...`, and `fix(printer): ...`. Keep commit titles in English and scoped when helpful. PRs should follow `.github/PULL_REQUEST_TEMPLATE.md` with `## Issue` and `## Overview`, link the related issue, and describe behavioral changes briefly in Japanese. Include tests for functional changes and update `README.md` when CLI behavior changes.

## Worktree Workflow
Do not work directly on the default branch (currently `master`). Start each non-trivial change from a dedicated branch and worktree under `.worktrees/`, for example `git worktree add .worktrees/feat-x -b feat/x`.
1 change: 1 addition & 0 deletions CLAUDE.md
10 changes: 6 additions & 4 deletions printer/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,27 +114,29 @@ func (p *Printer) PrintMsg(msg string) {
}
}

// PrintMsgErr prints error message
// PrintMsgErr prints an error message to errWriter so that callers can
// redirect stderr separately from stdout (consistent with Printer.Error).
func (p *Printer) PrintMsgErr(msg string) {
type message struct {
Msg string
}
p.mu.Lock()
defer p.mu.Unlock()
if err := msgErrTpl.Execute(p.writer, message{Msg: msg}); err != nil {
if err := msgErrTpl.Execute(p.errWriter, message{Msg: msg}); err != nil {
log.Println(err)
}
}

// PrintRepoErr prints error message
// PrintRepoErr prints a header plus a list of repository paths to errWriter
// (matching the stderr semantics of PrintMsgErr / Error).
func (p *Printer) PrintRepoErr(msg string, repos []string) {
type message struct {
Msg string
Repos []string
}
p.mu.Lock()
defer p.mu.Unlock()
if err := repoErrTpl.Execute(p.writer, message{Msg: msg, Repos: repos}); err != nil {
if err := repoErrTpl.Execute(p.errWriter, message{Msg: msg, Repos: repos}); err != nil {
log.Println(err)
}
}
Expand Down
3 changes: 3 additions & 0 deletions syncer/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ func TestExistGit_NoStdoutSideEffect(t *testing.T) {

r, w, err := os.Pipe()
is.NoErr(err)
// io.ReadAll reaches EOF but the read-end fd still needs closing.
// Close error is irrelevant in tests — we only care about fd release.
defer func() { _ = r.Close() }()
orig := os.Stdout
os.Stdout = w
defer func() { os.Stdout = orig }()
Expand Down
26 changes: 9 additions & 17 deletions syncer/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,14 @@ type Sync struct {
Gitter Executor
}

// failedRepo carries the repository path together with the error that caused
// the failure so that it can be reported in the final summary.
type failedRepo struct {
Repo string
Err error
}

// runStats accumulates per-repository outcomes safely from concurrent goroutines.
// Each bucket is just a list of repository paths — the per-repo error message
// is already streamed to stderr at execution time via PrintMsgErr, so there is
// no need to retain it here.
type runStats struct {
mu sync.Mutex
succeeded []string
failed []failedRepo
failed []string
timedOut []string
}

Expand All @@ -76,10 +72,10 @@ func (s *runStats) addSuccess(r string) {
s.succeeded = append(s.succeeded, r)
}

func (s *runStats) addFailed(r string, e error) {
func (s *runStats) addFailed(r string) {
s.mu.Lock()
defer s.mu.Unlock()
s.failed = append(s.failed, failedRepo{Repo: r, Err: e})
s.failed = append(s.failed, r)
}

func (s *runStats) addTimedOut(r string) {
Expand Down Expand Up @@ -198,12 +194,12 @@ func (s *Sync) execute(parent context.Context, repos []string, perRepoTimeout ti
switch {
case err == nil:
stats.addSuccess(r)
s.Writer.PrintMsg(fmt.Sprintf("Success: %s\n", r))
s.Writer.PrintMsg(fmt.Sprintf("Success: %s", r))
case errors.Is(ctx.Err(), context.DeadlineExceeded):
stats.addTimedOut(r)
s.Writer.PrintMsgErr(fmt.Sprintf("Timeout: %s", r))
default:
stats.addFailed(r, err)
stats.addFailed(r)
s.Writer.PrintMsgErr(fmt.Sprintf("Failed: %s\n%v", r, err))
}

Expand Down Expand Up @@ -245,11 +241,7 @@ func (s *Sync) printSummary(stats *runStats) {
))

if len(stats.failed) > 0 {
repos := make([]string, len(stats.failed))
for i, f := range stats.failed {
repos[i] = f.Repo
}
s.Writer.PrintRepoErr("Failed repositories:", repos)
s.Writer.PrintRepoErr("Failed repositories:", stats.failed)
}
if len(stats.timedOut) > 0 {
s.Writer.PrintRepoErr("Timed out repositories:", stats.timedOut)
Expand Down
2 changes: 1 addition & 1 deletion syncer/syncer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestSync_Execute(t *testing.T) {
stats := s.execute(context.Background(), []string{"a", "b", "c"}, time.Second)
is.Equal(len(stats.succeeded), 2)
is.Equal(len(stats.failed), 1)
is.Equal(filepath.Base(stats.failed[0].Repo), "b")
is.Equal(filepath.Base(stats.failed[0]), "b")
})

t.Run("timeout is classified separately", func(t *testing.T) {
Expand Down
Loading