From 9150913a35dabe7a3db08e64e08a73ff0b51c495 Mon Sep 17 00:00:00 2001 From: aloekun Date: Mon, 30 Mar 2026 21:09:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20push=20=E5=89=8D=E3=83=91?= =?UTF-8?q?=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4=E3=83=B3=E3=83=8F=E3=83=BC?= =?UTF-8?q?=E3=83=8D=E3=82=B9=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUse の jj-push-guard プリセットで直接の push をブロックし、 hooks-push-pipeline (スタンドアロン Rust exe) で push 前パイプラインを 実行する2段構成で、Claude Code hooks に存在しない push hook を補完する。 - PreToolUse: jj-push-guard プリセット追加 - hooks-push-pipeline: command 型/ai 型ステップの順次実行 + 最終 push - hooks-config.toml: [push_pipeline] セクション追加 - ビルド・配布統合: package.json, .gitignore, deploy-hooks.ts 更新 - ADR-008: 設計判断を記録 Co-Authored-By: Claude Opus 4.6 (1M context) fix: CodeRabbit レビュー指摘4件を修正 - push_cmd/cmd の空文字バリデーション追加 (#1 Major) - jj-push-guard に環境変数プレフィックスバイパス対策 (#3 Nitpick) - ADR-008 コードブロックに言語指定追加 (#4 Nitpick) - hooks-push-pipeline 全関数に docstring 追加 (#5 Pre-merge) --- .claude/hooks-config.toml | 31 ++ .claude/hooks-pre-tool-validate/src/main.rs | 41 +- .claude/hooks-push-pipeline/Cargo.lock | 189 +++++++++ .claude/hooks-push-pipeline/Cargo.toml | 14 + .claude/hooks-push-pipeline/src/main.rs | 422 ++++++++++++++++++++ .gitignore | 2 + CLAUDE.md | 1 + docs/adr/adr-008-push-pipeline-harness.md | 100 +++++ package.json | 4 +- scripts/deploy-hooks.ts | 1 + 10 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 .claude/hooks-push-pipeline/Cargo.lock create mode 100644 .claude/hooks-push-pipeline/Cargo.toml create mode 100644 .claude/hooks-push-pipeline/src/main.rs create mode 100644 docs/adr/adr-008-push-pipeline-harness.md diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 3a4e83fc..06150b11 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -11,6 +11,7 @@ # "git" — git コマンドブロック (jj 利用プロジェクト向け) # "jj-immutable" — jj --ignore-immutable # "jj-main-guard" — jj new main / jj edit main +# "jj-push-guard" — jj git push / jj push 禁止 (pnpm push に誘導) # "electron" — Electron GUI 実行ブロック # プリセット名以外の文字列は正規表現としてカスタムパターン扱い blocked_patterns = [ @@ -18,6 +19,7 @@ blocked_patterns = [ "git", "jj-immutable", "jj-main-guard", + "jj-push-guard", "electron", ] @@ -69,3 +71,32 @@ cmd = "pnpm test:e2e" [[stop_quality.steps]] name = "build" cmd = "pnpm build" + +# ─── Push Pipeline: push 前パイプライン ─── +# +# pnpm push で起動されるパイプライン。 +# PreToolUse の "jj-push-guard" プリセットにより直接の jj git push はブロックされ、 +# このパイプラインを通すことが強制される。 +# +# ステップの type: +# "command" — シェルコマンドを実行。exit code 0 で成功判定 +# "ai" — AI 処理が必要なステップ (将来実装、現在は placeholder) + +[push_pipeline] +step_timeout = 120 +push_cmd = "jj git push" + +[[push_pipeline.steps]] +name = "integration_test" +type = "command" +cmd = "pnpm test" + +[[push_pipeline.steps]] +name = "ai_review" +type = "ai" +prompt = "review_changes" + +[[push_pipeline.steps]] +name = "commit_refactor" +type = "ai" +prompt = "optimize_commit_structure" diff --git a/.claude/hooks-pre-tool-validate/src/main.rs b/.claude/hooks-pre-tool-validate/src/main.rs index 8b44590e..13f6d6fb 100644 --- a/.claude/hooks-pre-tool-validate/src/main.rs +++ b/.claude/hooks-pre-tool-validate/src/main.rs @@ -231,6 +231,39 @@ GUI アプリケーションは Claude Code のヘッドレス環境では動作 ] } +/// プリセット: jj-push-guard (jj git push / jj push を禁止し pnpm push に誘導) +fn preset_jj_push_guard() -> Vec { + vec![ + BlockedPattern { + pattern: Regex::new(r#"(?im)(^|&&|;|\|\||\||&)\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+|command\s+|env\s+)*jj\s+git\s+push(\s|$)"#).unwrap(), + message: r#"**jj git push がブロックされました** + +直接の push は禁止されています。push 前パイプライン(テスト・レビュー)を通す必要があります。 + +**代わりに以下を実行してください:** +``` +pnpm push +``` + +これにより、テスト実行 → レビュー → push が一括で行われます。"#, + }, + BlockedPattern { + pattern: Regex::new(r#"(?im)(^|&&|;|\|\||\||&)\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+|command\s+|env\s+)*jj\s+push(\s|$)"#).unwrap(), + message: r#"**jj push がブロックされました** + +`jj push` は非推奨です。代わりに `jj git push` を使用しますが、 +直接の push は禁止されています。push 前パイプラインを通す必要があります。 + +**代わりに以下を実行してください:** +``` +pnpm push +``` + +これにより、テスト実行 → レビュー → push が一括で行われます。"#, + }, + ] +} + /// 設定ファイルに基づいてブロックパターンを構築 fn build_blocked_patterns(config: &Config) -> Vec { let preset_names: Vec = config @@ -245,6 +278,7 @@ fn build_blocked_patterns(config: &Config) -> Vec { "git".to_string(), "jj-immutable".to_string(), "jj-main-guard".to_string(), + "jj-push-guard".to_string(), "electron".to_string(), ] }); @@ -256,6 +290,7 @@ fn build_blocked_patterns(config: &Config) -> Vec { "git" => patterns.extend(preset_git()), "jj-immutable" => patterns.extend(preset_jj_immutable()), "jj-main-guard" => patterns.extend(preset_jj_main_guard()), + "jj-push-guard" => patterns.extend(preset_jj_push_guard()), "electron" => patterns.extend(preset_electron()), custom => { // プリセット名以外はカスタム正規表現として扱う @@ -630,8 +665,10 @@ mod tests { // --- git: allowed commands (should NOT block) --- #[test] - fn allows_jj_git_push() { - assert!(!is_blocked("jj git push")); + fn blocks_jj_git_push() { + // jj-push-guard プリセットにより、直接の jj git push はブロックされる + // pnpm push 経由でのみ push を許可する設計 + assert!(is_blocked("jj git push")); } #[test] diff --git a/.claude/hooks-push-pipeline/Cargo.lock b/.claude/hooks-push-pipeline/Cargo.lock new file mode 100644 index 00000000..ff36f9d9 --- /dev/null +++ b/.claude/hooks-push-pipeline/Cargo.lock @@ -0,0 +1,189 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hooks-push-pipeline" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/.claude/hooks-push-pipeline/Cargo.toml b/.claude/hooks-push-pipeline/Cargo.toml new file mode 100644 index 00000000..9dc5830a --- /dev/null +++ b/.claude/hooks-push-pipeline/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "hooks-push-pipeline" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/.claude/hooks-push-pipeline/src/main.rs b/.claude/hooks-push-pipeline/src/main.rs new file mode 100644 index 00000000..46696a3c --- /dev/null +++ b/.claude/hooks-push-pipeline/src/main.rs @@ -0,0 +1,422 @@ +//! Push Pipeline ランナー (スタンドアロン exe) +//! +//! pnpm push から呼び出され、push 前のパイプラインを実行します。 +//! hooks-config.toml の [push_pipeline] セクションから設定を読み込みます。 +//! +//! 処理フロー: +//! 1. command 型ステップを順次実行(失敗時は即座に終了) +//! 2. ai 型ステップは placeholder メッセージを出力(将来実装) +//! 3. 全 command ステップ成功 → push_cmd を実行 +//! +//! 終了コード: +//! 0 - パイプライン成功 & push 完了 +//! 1 - パイプライン失敗(テスト失敗等) +//! 2 - 設定エラー + +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +// ─── 設定 ─── + +/// hooks-config.toml のトップレベル構造 +#[derive(Deserialize, Default)] +struct Config { + push_pipeline: Option, +} + +/// `[push_pipeline]` セクションの設定 +#[derive(Deserialize, Default)] +struct PushPipelineConfig { + step_timeout: Option, + push_cmd: Option, + steps: Option>, +} + +/// パイプラインの個別ステップ定義 +#[derive(Deserialize, Clone)] +struct PipelineStepConfig { + name: String, + #[serde(rename = "type")] + step_type: String, + cmd: Option, + prompt: Option, +} + +/// デフォルトのステップタイムアウト(秒) +const DEFAULT_STEP_TIMEOUT_SECS: u64 = 120; + +/// デフォルトの push コマンド +const DEFAULT_PUSH_CMD: &str = "jj git push"; + +// ─── ログ出力ヘルパー ─── + +/// ステップ単位のログ出力 (`[push-pipeline] [label] STATUS — message`) +fn log_step(name: &str, status: &str, message: &str) { + if message.is_empty() { + eprintln!("[push-pipeline] [{}] {}", name, status); + } else { + eprintln!("[push-pipeline] [{}] {} — {}", name, status, message); + } +} + +/// パイプライン全体のログ出力 +fn log_info(message: &str) { + eprintln!("[push-pipeline] {}", message); +} + +// ─── パイプ排出 (hooks-stop-quality から移植) ─── + +/// サブプロセス出力の最大収集行数 +const MAX_LINES: usize = 40; + +/// サブプロセスの stdout/stderr を別スレッドで収集する(最大 MAX_LINES 行) +fn drain_pipe(pipe: impl std::io::Read + Send + 'static) -> std::thread::JoinHandle { + std::thread::spawn(move || { + use std::io::BufRead; + let mut reader = std::io::BufReader::new(pipe); + let mut collected = Vec::with_capacity(MAX_LINES); + let mut buf = Vec::new(); + + loop { + buf.clear(); + match reader.read_until(b'\n', &mut buf) { + Ok(0) => break, + Ok(_) => { + if collected.len() < MAX_LINES { + collected.push( + String::from_utf8_lossy(&buf) + .trim_end_matches(&['\r', '\n'][..]) + .to_string(), + ); + } + } + Err(_) => break, + } + } + collected.join("\n") + }) +} + +// ─── コマンド実行 (hooks-stop-quality から移植) ─── + +/// シェルコマンドを実行し、タイムアウト付きで結果を返す +fn run_cmd(name: &str, cmd: &str, timeout_secs: u64) -> (bool, String) { + let mut child = match Command::new("cmd") + .args(["/c", cmd]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => return (false, format!("Failed to execute {}: {}", cmd, e)), + }; + + let stdout_handle = drain_pipe(child.stdout.take().unwrap()); + let stderr_handle = drain_pipe(child.stderr.take().unwrap()); + + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + let timed_out = loop { + match child.try_wait() { + Ok(Some(_)) => break false, + Ok(None) => { + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break true; + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) => return (false, format!("Failed to wait for {}: {}", name, e)), + } + }; + + if timed_out { + let stdout = stdout_handle.join().unwrap_or_default(); + let stderr = stderr_handle.join().unwrap_or_default(); + let combined = combine_output(&stdout, &stderr); + let mut msg = format!("timed out after {}s", timeout_secs); + if !combined.is_empty() { + msg = format!("{}\n{}", msg, combined); + } + return (false, msg); + } + + let success = child.wait().map(|s| s.success()).unwrap_or(false); + + let stdout = stdout_handle.join().unwrap_or_default(); + let stderr = stderr_handle.join().unwrap_or_default(); + let combined = combine_output(&stdout, &stderr); + + (success, combined) +} + +/// stdout と stderr を結合する +fn combine_output(stdout: &str, stderr: &str) -> String { + if stdout.is_empty() { + stderr.to_string() + } else if stderr.is_empty() { + stdout.to_string() + } else { + format!("{}\n{}", stdout, stderr) + } +} + +// ─── 設定ファイル読み込み ─── + +/// exe と同じディレクトリにある hooks-config.toml のパスを返す +fn config_path() -> PathBuf { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("hooks-config.toml") +} + +/// hooks-config.toml を読み込みパースする +fn load_config() -> Result { + let path = config_path(); + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("hooks-config.toml の読み込みに失敗: {} ({})", path.display(), e))?; + toml::from_str(&content) + .map_err(|e| format!("hooks-config.toml のパースに失敗: {}", e)) +} + +// ─── パイプライン実行 ─── + +/// パイプラインのメインループ。全ステップ実行後に push を行う +fn run_pipeline() -> i32 { + let config = match load_config() { + Ok(c) => c, + Err(e) => { + log_info(&format!("設定エラー: {}", e)); + return 2; + } + }; + + let pipeline = match config.push_pipeline { + Some(p) => p, + None => { + log_info("設定エラー: [push_pipeline] セクションが hooks-config.toml に見つかりません"); + return 2; + } + }; + + let steps = pipeline.steps.unwrap_or_default(); + let timeout = pipeline.step_timeout.unwrap_or(DEFAULT_STEP_TIMEOUT_SECS); + let push_cmd = pipeline + .push_cmd + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_PUSH_CMD) + .to_string(); + + if steps.is_empty() { + log_info("警告: パイプラインステップが定義されていません。push のみ実行します。"); + } + + log_info(&format!( + "パイプライン開始 ({} ステップ)", + steps.len() + )); + + // ステップを順次実行 + for (i, step) in steps.iter().enumerate() { + let label = format!("{}/{} {}", i + 1, steps.len(), step.name); + + match step.step_type.as_str() { + "command" => { + let trimmed_cmd = step.cmd.as_deref().map(str::trim).filter(|c| !c.is_empty()); + let cmd = match trimmed_cmd { + Some(c) => c, + None => { + log_step(&label, "ERROR", "cmd が未定義または空です"); + return 1; + } + }; + + log_step(&label, "RUN", cmd); + + let (success, output) = run_cmd(&step.name, cmd, timeout); + + if success { + log_step(&label, "PASS", ""); + } else { + log_step(&label, "FAIL", ""); + if !output.is_empty() { + eprintln!("{}", output); + } + log_info(&format!( + "パイプライン中断: {} が失敗しました。問題を修正して pnpm push を再実行してください。", + step.name + )); + return 1; + } + } + "ai" => { + let prompt = step.prompt.as_deref().unwrap_or("(未定義)"); + log_step( + &label, + "SKIP", + &format!( + "AI ステップ (prompt: {}) — 将来実装予定。現在はスキップします。", + prompt + ), + ); + } + unknown => { + log_step( + &label, + "ERROR", + &format!("未知のステップタイプ: {}", unknown), + ); + return 1; + } + } + } + + // 全ステップ成功 → push 実行 + log_info(&format!("全ステップ成功。push を実行します: {}", push_cmd)); + + let (success, output) = run_cmd("push", &push_cmd, timeout); + + if success { + log_info("push 完了"); + if !output.is_empty() { + eprintln!("{}", output); + } + 0 + } else { + log_info("push 失敗:"); + if !output.is_empty() { + eprintln!("{}", output); + } + 1 + } +} + +fn main() { + std::process::exit(run_pipeline()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_parses_push_pipeline() { + let toml_str = r#" +[push_pipeline] +step_timeout = 60 +push_cmd = "jj git push" + +[[push_pipeline.steps]] +name = "test" +type = "command" +cmd = "pnpm test" + +[[push_pipeline.steps]] +name = "review" +type = "ai" +prompt = "review_changes" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let pipeline = config.push_pipeline.unwrap(); + assert_eq!(pipeline.step_timeout.unwrap(), 60); + assert_eq!(pipeline.push_cmd.unwrap(), "jj git push"); + + let steps = pipeline.steps.unwrap(); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].name, "test"); + assert_eq!(steps[0].step_type, "command"); + assert_eq!(steps[0].cmd.as_deref(), Some("pnpm test")); + assert_eq!(steps[1].name, "review"); + assert_eq!(steps[1].step_type, "ai"); + assert_eq!(steps[1].prompt.as_deref(), Some("review_changes")); + } + + #[test] + fn config_defaults_when_empty() { + let toml_str = r#" +[push_pipeline] +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let pipeline = config.push_pipeline.unwrap(); + assert_eq!( + pipeline.step_timeout.unwrap_or(DEFAULT_STEP_TIMEOUT_SECS), + DEFAULT_STEP_TIMEOUT_SECS + ); + assert_eq!( + pipeline.push_cmd.unwrap_or_else(|| DEFAULT_PUSH_CMD.to_string()), + DEFAULT_PUSH_CMD + ); + assert!(pipeline.steps.unwrap_or_default().is_empty()); + } + + #[test] + fn config_missing_push_pipeline_section() { + let toml_str = r#" +[stop_quality] +step_timeout = 60 +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.push_pipeline.is_none()); + } + + #[test] + fn combine_output_both_present() { + let result = combine_output("stdout line", "stderr line"); + assert_eq!(result, "stdout line\nstderr line"); + } + + #[test] + fn combine_output_only_stdout() { + let result = combine_output("stdout line", ""); + assert_eq!(result, "stdout line"); + } + + #[test] + fn combine_output_only_stderr() { + let result = combine_output("", "stderr line"); + assert_eq!(result, "stderr line"); + } + + #[test] + fn combine_output_both_empty() { + let result = combine_output("", ""); + assert_eq!(result, ""); + } + + #[test] + fn step_type_command_requires_cmd() { + let toml_str = r#" +[push_pipeline] + +[[push_pipeline.steps]] +name = "test" +type = "command" +cmd = "pnpm test" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let steps = config.push_pipeline.unwrap().steps.unwrap(); + assert!(steps[0].cmd.is_some()); + } + + #[test] + fn step_type_ai_has_prompt() { + let toml_str = r#" +[push_pipeline] + +[[push_pipeline.steps]] +name = "review" +type = "ai" +prompt = "review_changes" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let steps = config.push_pipeline.unwrap().steps.unwrap(); + assert_eq!(steps[0].step_type, "ai"); + assert_eq!(steps[0].prompt.as_deref(), Some("review_changes")); + } +} diff --git a/.gitignore b/.gitignore index e2a9072b..960bf72d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .claude/hooks-pre-tool-validate.exe .claude/hooks-post-tool-linter.exe .claude/hooks-stop-quality.exe +.claude/hooks-push-pipeline.exe # Deploy targets (contains local paths; create from deploy-targets.template.json) scripts/deploy-targets.json @@ -23,3 +24,4 @@ __pycache__/ .claude/hooks-pre-tool-validate/target/ .claude/hooks-post-tool-linter/target/ .claude/hooks-stop-quality/target/ +.claude/hooks-push-pipeline/target/ diff --git a/CLAUDE.md b/CLAUDE.md index db54bb2a..94cd3c51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ - [ADR-005: hooks の exe パスをテンプレートから自動生成](docs/adr/adr-005-hooks-path-resolution-with-template.md) - [ADR-006: hooks の設定駆動型アーキテクチャ](docs/adr/adr-006-config-driven-hooks.md) - [ADR-007: カスタムリンターの正規表現層/AST層の線引き](docs/adr/adr-007-custom-linter-layer-boundary.md) +- [ADR-008: Push Pipeline ハーネスの実装](docs/adr/adr-008-push-pipeline-harness.md) ## Build diff --git a/docs/adr/adr-008-push-pipeline-harness.md b/docs/adr/adr-008-push-pipeline-harness.md new file mode 100644 index 00000000..55318094 --- /dev/null +++ b/docs/adr/adr-008-push-pipeline-harness.md @@ -0,0 +1,100 @@ +# ADR-008: Push Pipeline ハーネスの実装 + +## ステータス + +承認済み (2026-03-30) + +## コンテキスト + +VCS に Jujutsu (jj) を採用しているプロジェクトで、push 前に「テスト通過」と「ローカルレビュー」を強制したい。 +Git には `pre-push` フックがあり、Lefthook のような OSS でパイプライン的に管理できるが、Claude Code hooks には push イベントが存在しない。 + +### 検討した選択肢 + +1. **Claude Code の PreToolUse/PostToolUse/Stop のいずれかに push パイプラインを組み込む** + - Stop hook に push を組み込む案: Stop は「応答終了時」に発火するため、push のタイミングと合わない + - PostToolUse に組み込む案: PostToolUse は個別のツール実行後に発火するため、パイプライン全体の制御に不向き + - → いずれも Claude Code hooks のイベントモデルと push パイプラインの性質が合致しない + +2. **PreToolUse でブロック + スタンドアロン exe で パイプラインを実行** + - PreToolUse で `jj git push` をブロックし、「`pnpm push` を使え」と誘導 + - `pnpm push` がスタンドアロン exe (hooks-push-pipeline) を呼び出し、テスト → レビュー → push を順次実行 + - → Claude Code hooks の制約内で push パイプラインを実現可能 + +3. **Skill (`/push`) として実装** + - Claude Code の Skill 機構を使い、`/push` コマンドとして実装 + - AI ステップ(レビュー、コミット整理)との親和性が高い + - → パイプラインのハーネス部分とは独立に検討可能。将来的に Skill が exe を呼び出す形での統合もありうる + +## 決定 + +**選択肢 2 を採用する。** PreToolUse の `jj-push-guard` プリセットで直接の push をブロックし、`hooks-push-pipeline` (スタンドアロン Rust exe) で push 前パイプラインを実行する。 + +### アーキテクチャ + +```text +Claude が "jj git push" を実行しようとする + │ + ▼ +PreToolUse (hooks-pre-tool-validate) + ├─ "jj-push-guard" プリセットでブロック + └─ エラーメッセージ: 「pnpm push を使用してください」 + │ + ▼ +Claude が "pnpm push" を実行する + │ + ▼ +hooks-push-pipeline.exe (スタンドアロン) + ├─ hooks-config.toml [push_pipeline] を読み込み + ├─ command 型ステップを順次実行 + ├─ ai 型ステップは現在スキップ (将来実装) + ├─ 全 command ステップ成功 → push_cmd を実行 + └─ 失敗 → エラー出力 (exit code 1) +``` + +### Claude Code hooks プロトコルとの違い + +| | Claude Code hooks (Pre/Post/Stop) | hooks-push-pipeline | +|---|---|---| +| 起動方法 | Claude Code が自動的に呼び出す | `pnpm push` から手動/Claude 経由で呼び出す | +| 入力 | stdin に JSON | なし (hooks-config.toml から設定読み込み) | +| 出力 | stdout に JSON (`decision`, `reason`) | stderr にログ出力 | +| 終了コード | hooks により意味が異なる | 0 = 成功, 1 = 失敗, 2 = 設定エラー | + +### ステップタイプ + +- `type = "command"`: シェルコマンドを実行。exit code 0 で成功判定。失敗時はパイプライン中断。 +- `type = "ai"`: AI 処理が必要なステップ。現在は placeholder としてスキップ。将来的に Skill 統合や Claude API 呼び出しで実装予定。 + +### 前提条件 + +- push は Claude 経由でのみ行う(ユーザーが手動でターミナルから push することは想定しない) +- ハードブロック: パイプラインを通さない push は技術的に不可能にする + +## 影響 + +### Positive + +- Claude Code hooks に push イベントがない制約下で、事実上の push hook を実現できる +- 既存のビルド・配布フロー (`pnpm build:hooks`, `pnpm deploy:hooks`) にそのまま乗る +- `hooks-config.toml` の `[push_pipeline]` セクションで、プロジェクトごとにステップをカスタマイズできる +- `type = "ai"` ステップの導入により、将来の AI レビュー・コミット整理統合への拡張ポイントが確保されている + +### Negative + +- PreToolUse ブロックは Claude 経由の push にのみ有効。ユーザーが直接ターミナルから `jj git push` を叩いた場合はバイパスされる(前提条件により許容) +- `hooks-push-pipeline` は Claude Code hooks プロトコルに準拠しないスタンドアロン exe であり、hooks 群の中で唯一の例外的な存在になる +- `run_step()` / `drain_pipe()` ロジックが `hooks-stop-quality` と重複する(ADR-003 の独立 Cargo プロジェクト方針に従い、共通クレート化は見送り) + +### 将来の検討事項 + +- **AI ステップの実装**: Skill `/push` との統合、または exe 内から Claude API を呼び出す方式 +- **グローバル設定**: `~/.claude/push_pipeline.toml` のような共通設定と、プロジェクトローカル設定のマージ機構 +- **共通クレート化**: hooks 間で重複するユーティリティ (`run_step`, `drain_pipe`, `config_path` 等) の共通化 + +## 参考 + +- ADR-001 — hooks の実装言語として Rust を採用 +- ADR-003 — hooks の配置規則とビルド戦略 +- ADR-006 — hooks の設定駆動型アーキテクチャ +- [Lefthook](https://github.com/evilmartians/lefthook) — Git hooks マネージャー(今回の設計の着想元) diff --git a/package.json b/package.json index 2474221e..b59ea5d7 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "build:hooks-pre-tool-validate": "cd .claude/hooks-pre-tool-validate && cargo build --release && cp target/release/hooks-pre-tool-validate.exe ../hooks-pre-tool-validate.exe", "build:hooks-post-tool-linter": "cd .claude/hooks-post-tool-linter && cargo build --release && cp target/release/hooks-post-tool-linter.exe ../hooks-post-tool-linter.exe", "build:hooks-stop-quality": "cd .claude/hooks-stop-quality && cargo build --release && cp target/release/hooks-stop-quality.exe ../hooks-stop-quality.exe", + "build:hooks-push-pipeline": "cd .claude/hooks-push-pipeline && cargo build --release && cp target/release/hooks-push-pipeline.exe ../hooks-push-pipeline.exe", "build:hooks-settings": "node -e \"const fs=require('fs');const t=fs.readFileSync('.claude/settings.local.json.template','utf8');const p=process.cwd().replace(/\\\\/g,'\\\\\\\\');fs.writeFileSync('.claude/settings.local.json',t.replace(/\\{\\{PROJECT_DIR\\}\\}/g,p))\" && echo settings.local.json generated", - "build:hooks": "pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-stop-quality && pnpm build:hooks-settings", + "build:hooks": "pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-stop-quality && pnpm build:hooks-push-pipeline && pnpm build:hooks-settings", + "push": ".\\.claude\\hooks-push-pipeline.exe", "deploy:hooks": "npx tsx scripts/deploy-hooks.ts", "py-lint": "ruff check src/", "py-test": "python -m pytest tests/", diff --git a/scripts/deploy-hooks.ts b/scripts/deploy-hooks.ts index 4c5e04ce..72411991 100644 --- a/scripts/deploy-hooks.ts +++ b/scripts/deploy-hooks.ts @@ -23,6 +23,7 @@ const EXE_FILES = [ "hooks-pre-tool-validate.exe", "hooks-post-tool-linter.exe", "hooks-stop-quality.exe", + "hooks-push-pipeline.exe", ]; const SETTINGS_TEMPLATE = "settings.local.json.template";