Skip to content

feat: parallel execution hardening with run summary and exit codes#26

Merged
takecy merged 11 commits intomasterfrom
feat/parallel-exec-summary
Apr 25, 2026
Merged

feat: parallel execution hardening with run summary and exit codes#26
takecy merged 11 commits intomasterfrom
feat/parallel-exec-summary

Conversation

@takecy
Copy link
Copy Markdown
Owner

@takecy takecy commented Apr 25, 2026

Issue

なし(リポジトリ全体のコードレビューに基づく改善 / tasks/improvements-review-2026-04-26.md に対応する 13 項目中 12 項目を本 PR で対応)。

Overview

並列実行の信頼性、CLI 挙動の README 整合、内部品質を中心とした多領域の改善。各グループは独立した目的で、コミット粒度も分離してある(合計 11 コミット、A〜D グループに対応)。

A. 並列実行の信頼性(99ae654 / 99ae654 / 99ae654 / 99ae654

  • タイムアウト時に git プロセスを kill できるようにした (exec.CommandContext 化)。git fetch が hang しても ctx cancel で SIGKILL が届く。HTTPS の補助プロセス (git remote-https 階層) も SIGPIPE 経由で連鎖終了することを E2E で確認済み
  • errgroup.SetLimit に置き換えて、-c <ConNum> を超える repos がある場合に main goroutine が throttle 詰まりで全体ハングする既存バグを解消
  • per-repo タイムアウト に変更(README の "performed command during on one directory" 文言と整合)。1 repo の遅さで他が starve しない
  • 実行終了時に Summary: success=N failed=M timeout=K + 失敗・timeout リポジトリ一覧を表示。並列で逐次出力が混ざっても最後にサマリで把握可能に

B. Printer の堅牢化(599fa1d / 38063dd

  • text/template.Executesync.Mutex で直列化Execute が内部で複数回 io.Writer.Write するため mutex なしだと goroutine 間で出力が混線していた(修正前 E2E で Timeout: r1Done: 1/3 のような連結を実観測)
  • writer / errWriter で 単一 mutex を共用(stdout/stderr が同じ TTY に向いているケースの混線も防止)。concurrencyProbe を使って peak inflight Write が常に 1 であることを直接アサート
  • 6 テンプレートを package var として一度だけコンパイル。並列で大量に呼ばれる関数なので、毎回の template.Must(...).Parse(...) を排除

C. テスト独立性(fdd273e / 9f35617

  • TestFetcht.TempDir() + ローカル bare remote 構成に書き換え。ネットワーク不要・冪等になり、テスト所要時間が 2.21s → 0.15s(約14倍高速化)。refs/remotes/origin/main の解決可否で実質的な fetch 成功も強アサート
  • Gitter.IsExist の stdout 副作用(git のパスを直接書き出していた)を除去し、package-level ExistGit() error 関数に置き換え。TestExistGit_NoStdoutSideEffectos.Pipe() 経由で stdout が空であることを直接アサート(git のパスや CI 環境に依存しない検証)

D. error handling 整理(d67107a / 77e85c4

  • Sync.Run() から 3 箇所の os.Exit(1) を排除し、(*RunSummary, error) を返すシグネチャに変更。setup error は error 経由、partial failure は summary 経由で main に伝える
  • main 側で README spec の exit code (0/1/2) を実装:
    • 0 全成功 / 1 setup error (no repos / invalid timeout / invalid regex) / 2 一部 repo の失敗または timeout
    • panic(err) (= exit 2 + stack trace) を fmt.Fprintln + os.Exit(1) に修正
  • E2E で 7 シナリオ(all-success / all-failed / partial-fail / no-repos / bad-timeout / bad-regex / filter-empty)の exit code が期待通りであることを確認

E. dir.go 改善(f38ae87 / 298e76e

  • IsRepoos.ReadDir ループから os.Stat 1 発呼び出し に変更。1 syscall で済む + git worktree (.git がファイル形式) を意味的に明示対応TestIsRepoworktree with .git file サブテストで pin
  • ListDirsstrings.Index(name, ".") != 0strings.HasPrefix に置換。さらに評価順を HasPrefix → IsRepo に入れ替えて、隠しディレクトリで syscall を skip。if a && b && c を early-continue 形式に整理

検証結果

  • make test (-race): printer 5 + syncer 26 + main 0 = 全テスト PASS
  • golangci-lint run: 0 issues
  • make build: 成功
  • E2E: タイムアウトでの kill / 出力混線解消 / exit code 0/1/2 / git worktree 検出 すべて期待通り

スコープ外(別 issue で対応予定)

  • 項目 13 gih/main.go の細かい点(goversionruntime.Version() 化 / concurrency: %d timeout: の余計な2スペース / subcommand 設計の将来検討)

🤖 Generated with Claude Code

takecy and others added 11 commits April 26, 2026 03:19
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace channel-based throttle with errgroup.SetLimit so the main
goroutine no longer blocks while enqueueing repos beyond the
concurrency limit. Move the timeout boundary into each goroutine via
context.WithTimeout(parent, perRepoTimeout) so the README's "timeout
per directory" semantics actually hold and so a single slow repo cannot
starve the others.

Switch Gitter.Git to exec.CommandContext and introduce an Executor
interface so cancellation reaches the spawned git process (and its
helper subprocesses via SIGPIPE) and so tests can swap in a fake
executor without forking real git.

Aggregate per-repo outcomes into runStats (succeeded / failed /
timedOut) and emit a final summary line plus repository lists for
failed and timed-out runs, reusing the previously-unused
printer.PrintRepoErr formatter.

Add focused unit tests for execute() and filterRepos() using a
mutex-wrapped io.Discard writer so -race stays clean even though the
Printer itself is not yet mutex-protected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each Print* method previously re-parsed its template on every call via
template.Must(template.New(...).Funcs(helpers).Parse(...)). Move the six
templates to package-level vars so parsing happens once at init. The t()
helper that selected between successTmpl and errTmpl is no longer
needed; Print and Error reference successTpl / errTpl directly.

Pure refactor — no observable behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each Print* method calls text/template.Execute, which in turn issues
multiple io.Writer.Write calls. Without external synchronisation, two
goroutines emitting messages through the Printer can interleave their
Writes — observed empirically in the previous commit's E2E run as
"Timeout: r1Done: 1/3" (a result line and a Done line concatenated
without the intervening newline).

Add a single sync.Mutex to Printer, locked around every Execute call.
A single mutex (rather than separate mutexes for writer and errWriter)
is intentional: in typical CLI use both end up at the same TTY, and we
want stdout/stderr messages to also stay non-overlapping.

Drop the syncWriter helper from syncer_test.go: it was wrapping
io.Discard purely to defend against the unprotected Printer, which is
no longer needed.

Add printer/print_test.go covering each public method concurrently,
plus a TestPrinter_SharedMutex_AcrossWriterAndErrWriter test that
wires both writer and errWriter to the same probe writer and asserts
that the maximum number of in-flight Write calls observed is 1 — this
directly verifies the single-mutex design rather than just exercising
the methods in parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Gitter.IsExist had two unrelated jobs glued together:
report whether `git` is on PATH, and write that path to os.Stdout. The
stdout write was an invisible side effect — production code never
called IsExist, so the only consumer was a test, and even there the
write only ever surfaced as noise next to the test summary.

Promote the check to a package-level ExistGit() error and drop the
side effect entirely. The receiver was empty anyway, so a method
added no value. Update TestIsExist to call the new function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ut output

TestFetch used to run `git fetch --all -p` against ".", i.e. whatever
directory the test happened to start in. On CI that meant fetching the
git-here repo itself, which required network access and mutated the
local clone's remote-tracking refs. Replace this with a fully
self-contained fixture: a bare repo built in t.TempDir() acts as the
remote, a sibling working repo pushes one empty commit to it, and the
fetch under test pulls those refs back. The whole test runs in well
under a second and never touches the network.

After the fetch, resolve refs/remotes/origin/main; without this check
the test would still pass on a no-op fetch because err==nil alone is
not strong enough to detect "fetch succeeded but transferred nothing".

Replace TestIsExist with TestExistGit_NoStdoutSideEffect, which
redirects os.Stdout through an os.Pipe and asserts the captured output
is empty. This guards the #11 contract ("ExistGit has no side effect")
in a way that does not depend on which path resolves for git on the
test host (Homebrew, Xcode, /usr/bin, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Exit

Sync.Run had three early os.Exit(1) calls for setup failures
(ListDirs error, no git repositories present, invalid timeout
duration). Calling os.Exit from inside Run skips deferred work and
makes the function untestable from a test process — the public
signature (err error) was already there, so the exits were also
inconsistent with the declared contract.

Change Run's signature to (*RunSummary, error). Setup failures now
return an error; the new RunSummary type carries Succeeded / Failed /
TimedOut counts plus a HasFailures predicate for callers that need to
distinguish a clean run from a partial-failure run. Filter narrowing
to zero matches returns an empty summary and nil error (it is not an
error — there is just no work to do).

Update gih/main.go to print the error to stderr and exit 1 in the
err != nil case, replacing the earlier panic(err) which produced a
stack trace and exit code 2 — the wrong code per the README spec.
The summary value is currently ignored at the call site; the next
commit wires it up to the exit-code 2 path for partial failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The README has documented an exit-code-2 behaviour for partial
repository failures since the project's first release, but the
implementation has been silent: gih returned 0 whether all repos
succeeded or only some did, so CI couldn't distinguish the two.

Now that Sync.Run returns a RunSummary, inspect summary.HasFailures()
after the err == nil path and exit 2 if any repository ended up in
the Failed or TimedOut buckets. Pure setup failures continue to exit
1, and a clean run still exits 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IsRepo previously called os.ReadDir(dirName) and walked every entry
looking for a name match against ".git". That has two problems:
it pays full directory-listing cost just to answer a yes/no question
about a single fixed name, and the name-only match doesn't make the
worktree case explicit even though git worktrees place ".git" as a
*file* (a "gitdir: ..." pointer) rather than a directory.

Replace the loop with a single os.Stat on the joined path. One
syscall instead of N, and stat doesn't care whether the entry is a
file or a directory, so a worktree's .git file is correctly accepted
as a repository indicator.

Add syncer/dir_test.go covering both layouts plus the negative cases
(non-git directory, non-existent path), pinning the worktree-support
contract so a future "must be a directory" change would surface as a
test failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ListDirs used `strings.Index(f.Name(), ".") != 0` to drop hidden
entries — a slightly indirect way of asking the same question that
strings.HasPrefix asks more idiomatically. Switch to HasPrefix.

While here, reorder the filter chain so the cheap HasPrefix check
runs before IsRepo. IsRepo now performs an os.Stat per call, so
short-circuiting on hidden directories avoids a syscall for each
.something entry in the parent. Rewrite the conjunction as a
sequence of early-continue guards so the intent of each step reads
top-down.

Pure refactor — for non-empty names, "name starts with ." and
"strings.Index(name, \".\") == 0" are exactly the same predicate, so
ListDirs returns the same set of paths it always did.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@takecy takecy marked this pull request as ready for review April 25, 2026 19:43
Copilot AI review requested due to automatic review settings April 25, 2026 19:43
@takecy takecy merged commit 4ea80be into master Apr 25, 2026
2 checks passed
@takecy takecy deleted the feat/parallel-exec-summary branch April 25, 2026 19:43
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

並列実行時の信頼性向上(per-repo timeout / プロセス kill / 実行サマリ)、CLI の exit code 仕様の実装、出力の並列安全化、およびテストの安定化・高速化を行うPRです。

Changes:

  • Sync.Run()(*RunSummary, error) 返却に変更し、README 仕様に沿った exit code (0/1/2) を main 側で決定
  • errgroup の並列制御・per-repo timeout 化・実行結果サマリ(成功/失敗/timeout と一覧)出力を追加
  • Printer のテンプレート再利用 + 単一 mutex による並列出力混線対策、dir 判定の syscall 最適化と worktree 対応、テストの hermetic 化

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
syncer/syncer.go 並列実行の再設計(errgroup.SetLimit / per-repo timeout / 実行サマリ / RunSummary 返却)
syncer/syncer_test.go execute の成功/失敗/timeout 分類と ConNum throttle をユニットテストで検証
syncer/git.go exec.CommandContext による ctx キャンセル対応、Executor インターフェース化、ExistGit 追加
syncer/git_test.go ExistGit の stdout 副作用なしの検証、Fetch テストの hermetic 化(ローカル bare remote)
syncer/dir.go IsRepoos.Stat 化(worktree .git file 対応)、ListDirs の効率化
syncer/dir_test.go 通常 repo / worktree / 非repo の IsRepo 判定テスト追加
printer/print.go テンプレートを package var で再利用、単一 mutex で Execute を直列化
printer/print_test.go Printer の並列安全性(行構造・stdout/stderr 共通 mutex)をテストで担保
gih/main.go RunSummary/err を受けて exit code (0/1/2) を実装、panic を排除
.gitignore .worktrees/tasks/ を追加で無視

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread printer/print.go
Comment thread printer/print.go
Comment thread syncer/syncer.go
Comment thread syncer/syncer.go
Comment thread syncer/git_test.go
takecy added a commit that referenced this pull request Apr 26, 2026
fix(printer,syncer): address Copilot review feedback from PR #26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants