From 4574df060f037a550875db774c5565de690cddb0 Mon Sep 17 00:00:00 2001 From: Mathew Kuriakose Date: Sat, 7 Feb 2026 13:18:44 +0000 Subject: [PATCH 1/6] Add `clash check` command for single-file conflict detection Checks a file against all worktrees for merge conflicts (via git merge-tree) and active uncommitted changes (via gix HEAD comparison). Outputs JSON with exit code 0 (clear) or 2 (conflicted). Building block for Claude Code hook integration (#5). Also adds WorktreeManager::find_containing() for identifying which worktree a directory belongs to. Closes #2 Co-Authored-By: Claude Opus 4.6 --- src/check.rs | 147 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 17 +++++ src/worktree/manager.rs | 15 ++++ 3 files changed, 179 insertions(+) create mode 100644 src/check.rs diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..a00f039 --- /dev/null +++ b/src/check.rs @@ -0,0 +1,147 @@ +use clash_sh::WorktreeManager; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Serialize)] +struct CheckOutput { + file: String, + current_worktree: String, + current_branch: String, + conflicts: Vec, +} + +#[derive(Debug, Serialize)] +struct FileConflict { + worktree: String, + branch: String, + has_merge_conflict: bool, + has_active_changes: bool, +} + +/// Run the check command - checks a single file for conflicts across worktrees. +/// +/// Returns true if conflicts were found (caller should exit with code 2). +pub fn run_check(worktrees: &WorktreeManager, path: &str) -> bool { + let current_dir = match std::env::current_dir() { + Ok(dir) => dir.canonicalize().unwrap_or(dir), + Err(e) => { + eprintln!("Error: failed to get current directory: {}", e); + return true; + } + }; + + // Find which worktree we're running from + let current_wt = match worktrees.find_containing(¤t_dir) { + Some(wt) => wt, + None => { + eprintln!("Error: not running from within a known worktree"); + return true; + } + }; + + // Make path relative to worktree root + let normalized_path = to_repo_relative(path, ¤t_wt.path); + + let mut conflicts = Vec::new(); + + for other_wt in worktrees.iter() { + if other_wt.id == current_wt.id { + continue; + } + + // Check merge conflicts between current and other worktree + let conflicting_files = current_wt.conflicts_with(other_wt).unwrap_or_default(); + let has_merge_conflict = conflicting_files.iter().any(|f| f == &normalized_path); + + // Check if file has uncommitted changes in other worktree + let has_active_changes = check_file_active(&other_wt.path, &normalized_path); + + if has_merge_conflict || has_active_changes { + conflicts.push(FileConflict { + worktree: other_wt.id.clone(), + branch: other_wt.branch.clone(), + has_merge_conflict, + has_active_changes, + }); + } + } + + let is_conflicted = !conflicts.is_empty(); + + let output = CheckOutput { + file: normalized_path, + current_worktree: current_wt.id.clone(), + current_branch: current_wt.branch.clone(), + conflicts, + }; + + match serde_json::to_string_pretty(&output) { + Ok(json) => println!("{}", json), + Err(e) => eprintln!("Error serializing output: {}", e), + } + + is_conflicted +} + +/// Convert a file path to be relative to the worktree root. +/// +/// Relative paths are returned as-is (assumed repo-relative). +/// Absolute paths are stripped of the worktree root prefix. +fn to_repo_relative(path: &str, worktree_root: &Path) -> String { + let path_buf = PathBuf::from(path); + + if path_buf.is_absolute() { + path_buf + .strip_prefix(worktree_root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string()) + } else { + path.to_string() + } +} + +/// Check if a file has uncommitted changes in a worktree. +/// +/// Compares the file on disk against its blob in HEAD using gix. +/// Returns true if the file differs from HEAD (modified/new/deleted). +fn check_file_active(worktree_path: &Path, file_path: &str) -> bool { + let repo = match gix::open(worktree_path) { + Ok(r) => r, + Err(_) => return false, + }; + + let workdir = match repo.workdir() { + Some(p) => p.to_path_buf(), + None => return false, + }; + + let disk_path = workdir.join(file_path); + let file_on_disk = disk_path.exists(); + + // Get the file's blob from HEAD + let head_blob = head_file_contents(&repo, file_path); + + match (head_blob, file_on_disk) { + (None, false) => false, // Not in HEAD, not on disk: no change + (None, true) => true, // New file (not tracked in HEAD) + (Some(_), false) => true, // Deleted from disk + (Some(head_data), true) => { + // Compare HEAD blob with file on disk + match std::fs::read(&disk_path) { + Ok(disk_data) => head_data != disk_data, + Err(_) => false, + } + } + } +} + +/// Get the contents of a file from HEAD in the given repository. +fn head_file_contents(repo: &gix::Repository, file_path: &str) -> Option> { + let mut head = repo.head().ok()?; + let head_id = head.try_peel_to_id().ok()??; + let commit = repo.find_object(head_id).ok()?.try_into_commit().ok()?; + let mut tree = commit.tree().ok()?; + let entry = tree.peel_to_entry_by_path(file_path).ok()??; + let blob = repo.find_object(entry.id()).ok()?; + Some(blob.data.to_vec()) +} diff --git a/src/main.rs b/src/main.rs index fa56736..b8c77e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use clap::{Parser, Subcommand}; use clash_sh::WorktreeManager; use colored::control; +mod check; mod status; mod watch; @@ -28,6 +29,11 @@ enum Commands { // we should wait for changes to settle before rechecking conflicts, // currently hardcoded to 1s }, + /// Check a single file for conflicts and active work across worktrees (JSON output) + Check { + /// File path to check + path: String, + }, } fn main() { @@ -58,6 +64,17 @@ fn main() { std::process::exit(1); } }, + Some(Commands::Check { path }) => match WorktreeManager::discover() { + Ok(worktrees) => { + if check::run_check(&worktrees, &path) { + std::process::exit(2); + } + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + }, None => { println!("Clash v{}", env!("CARGO_PKG_VERSION")); println!("Try 'clash --help' for more information."); diff --git a/src/worktree/manager.rs b/src/worktree/manager.rs index 691358a..6bf559b 100644 --- a/src/worktree/manager.rs +++ b/src/worktree/manager.rs @@ -148,6 +148,21 @@ impl WorktreeManager { pub fn iter(&self) -> std::slice::Iter<'_, Worktree> { self.items.iter() } + + /// Find the worktree containing the given directory. + /// + /// Walks up from `dir` checking each directory against known worktree paths. + /// This handles subdirectories and avoids ambiguity with nested worktrees. + pub fn find_containing(&self, dir: &std::path::Path) -> Option<&Worktree> { + let mut current = Some(dir); + while let Some(d) = current { + if let Some(wt) = self.items.iter().find(|wt| wt.path == d) { + return Some(wt); + } + current = d.parent(); + } + None + } } // WorktreeManager methods are extended in other modules: From c85ed2af3211143afb304304cb3477b0d4f7a7e5 Mon Sep 17 00:00:00 2001 From: Mathew Kuriakose Date: Sat, 7 Feb 2026 14:15:43 +0000 Subject: [PATCH 2/6] check re-written; status updated --- src/check.rs | 186 +++++++++++++++++++++++++++------------ src/main.rs | 11 ++- src/status.rs | 78 ++++++++++------ src/worktree/conflict.rs | 13 ++- 4 files changed, 196 insertions(+), 92 deletions(-) diff --git a/src/check.rs b/src/check.rs index a00f039..86d75fb 100644 --- a/src/check.rs +++ b/src/check.rs @@ -1,7 +1,11 @@ -use clash_sh::WorktreeManager; +use clash_sh::{Worktree, WorktreeManager}; use serde::Serialize; use std::path::{Path, PathBuf}; +// ============================================================================ +// Output types +// ============================================================================ + #[derive(Debug, Serialize)] struct CheckOutput { file: String, @@ -18,29 +22,63 @@ struct FileConflict { has_active_changes: bool, } -/// Run the check command - checks a single file for conflicts across worktrees. +// ============================================================================ +// Error type +// ============================================================================ + +/// Errors specific to the check command. /// -/// Returns true if conflicts were found (caller should exit with code 2). -pub fn run_check(worktrees: &WorktreeManager, path: &str) -> bool { - let current_dir = match std::env::current_dir() { - Ok(dir) => dir.canonicalize().unwrap_or(dir), - Err(e) => { - eprintln!("Error: failed to get current directory: {}", e); - return true; - } - }; +/// These map to exit code 1 (operational error) — distinct from +/// exit code 2 (conflicts found) and exit code 0 (clear). +#[derive(Debug)] +pub enum CheckError { + /// Could not determine current directory + CurrentDir(std::io::Error), + /// The resolved file path is not inside any known worktree + NotInWorktree(PathBuf), + /// Could not strip worktree prefix from path + PathResolution(PathBuf), + /// Merge conflict detection failed for a worktree pair + ConflictDetection { worktree: String, reason: String }, +} - // Find which worktree we're running from - let current_wt = match worktrees.find_containing(¤t_dir) { - Some(wt) => wt, - None => { - eprintln!("Error: not running from within a known worktree"); - return true; +impl std::fmt::Display for CheckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CurrentDir(e) => write!(f, "failed to get current directory: {}", e), + Self::NotInWorktree(p) => { + write!(f, "path '{}' is not inside any known worktree", p.display()) + } + Self::PathResolution(p) => { + write!( + f, + "could not resolve '{}' relative to worktree root", + p.display() + ) + } + Self::ConflictDetection { worktree, reason } => { + write!( + f, + "conflict check failed for worktree '{}': {}", + worktree, reason + ) + } } - }; + } +} + +// ============================================================================ +// Main entry point +// ============================================================================ - // Make path relative to worktree root - let normalized_path = to_repo_relative(path, ¤t_wt.path); +/// Check a single file for conflicts across worktrees. +/// +/// Prints JSON to stdout and returns whether conflicts were found. +/// - `Ok(false)` — no conflicts, exit 0 +/// - `Ok(true)` — conflicts found, exit 2 +/// - `Err(e)` — operational error, caller prints to stderr and exits 1 +pub fn run_check(worktrees: &WorktreeManager, path: &str) -> Result { + let (current_wt, repo_relative) = resolve_file_path(path, worktrees)?; let mut conflicts = Vec::new(); @@ -49,12 +87,16 @@ pub fn run_check(worktrees: &WorktreeManager, path: &str) -> bool { continue; } - // Check merge conflicts between current and other worktree - let conflicting_files = current_wt.conflicts_with(other_wt).unwrap_or_default(); - let has_merge_conflict = conflicting_files.iter().any(|f| f == &normalized_path); + let merge_conflicts = + current_wt + .conflicts_with(other_wt) + .map_err(|e| CheckError::ConflictDetection { + worktree: other_wt.id.clone(), + reason: e.to_string(), + })?; - // Check if file has uncommitted changes in other worktree - let has_active_changes = check_file_active(&other_wt.path, &normalized_path); + let has_merge_conflict = merge_conflicts.iter().any(|f| f == &repo_relative); + let has_active_changes = file_has_active_changes(&other_wt.path, &repo_relative); if has_merge_conflict || has_active_changes { conflicts.push(FileConflict { @@ -66,45 +108,77 @@ pub fn run_check(worktrees: &WorktreeManager, path: &str) -> bool { } } - let is_conflicted = !conflicts.is_empty(); + let has_conflicts = !conflicts.is_empty(); let output = CheckOutput { - file: normalized_path, + file: repo_relative, current_worktree: current_wt.id.clone(), current_branch: current_wt.branch.clone(), conflicts, }; - match serde_json::to_string_pretty(&output) { - Ok(json) => println!("{}", json), - Err(e) => eprintln!("Error serializing output: {}", e), - } + // Serialization of simple String/bool fields cannot fail in practice + let json = serde_json::to_string_pretty(&output).expect("CheckOutput is always serializable"); + println!("{}", json); - is_conflicted + Ok(has_conflicts) } -/// Convert a file path to be relative to the worktree root. +// ============================================================================ +// Path resolution +// ============================================================================ + +/// Resolve a file path to its containing worktree and repo-relative path. /// -/// Relative paths are returned as-is (assumed repo-relative). -/// Absolute paths are stripped of the worktree root prefix. -fn to_repo_relative(path: &str, worktree_root: &Path) -> String { - let path_buf = PathBuf::from(path); - - if path_buf.is_absolute() { - path_buf - .strip_prefix(worktree_root) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.to_string()) +/// Handles both absolute paths (from hooks, e.g. `/repo/src/auth.rs`) +/// and relative paths (from CLI, e.g. `src/auth.rs` or `../lib/file.rs`). +/// +/// Strategy: +/// 1. If relative, join with canonicalized cwd to make absolute +/// 2. Canonicalize if possible (resolves symlinks and `..` components) +/// 3. Walk up the path to find the containing worktree +/// 4. Strip the worktree prefix to get the repo-relative path +fn resolve_file_path<'a>( + path: &str, + worktrees: &'a WorktreeManager, +) -> Result<(&'a Worktree, String), CheckError> { + let input = Path::new(path); + + let abs_path = if input.is_absolute() { + PathBuf::from(path) } else { - path.to_string() - } + let cwd = std::env::current_dir() + .and_then(|d| d.canonicalize().or(Ok(d))) + .map_err(CheckError::CurrentDir)?; + cwd.join(input) + }; + + // Canonicalize if possible (resolves symlinks and .. components). + // Fall back to raw path — file might not exist yet (PreToolUse on Write). + let abs_path = abs_path.canonicalize().unwrap_or(abs_path); + + let wt = worktrees + .find_containing(&abs_path) + .ok_or_else(|| CheckError::NotInWorktree(abs_path.clone()))?; + + let rel = abs_path + .strip_prefix(&wt.path) + .map_err(|_| CheckError::PathResolution(abs_path.clone()))? + .to_string_lossy() + .to_string(); + + Ok((wt, rel)) } +// ============================================================================ +// Active changes detection +// ============================================================================ + /// Check if a file has uncommitted changes in a worktree. /// -/// Compares the file on disk against its blob in HEAD using gix. -/// Returns true if the file differs from HEAD (modified/new/deleted). -fn check_file_active(worktree_path: &Path, file_path: &str) -> bool { +/// Compares the file on disk against HEAD. Returns true if the file +/// differs from HEAD (modified, new, or deleted). +fn file_has_active_changes(worktree_path: &Path, file_path: &str) -> bool { let repo = match gix::open(worktree_path) { Ok(r) => r, Err(_) => return false, @@ -116,26 +190,24 @@ fn check_file_active(worktree_path: &Path, file_path: &str) -> bool { }; let disk_path = workdir.join(file_path); - let file_on_disk = disk_path.exists(); - - // Get the file's blob from HEAD + let exists_on_disk = disk_path.exists(); let head_blob = head_file_contents(&repo, file_path); - match (head_blob, file_on_disk) { - (None, false) => false, // Not in HEAD, not on disk: no change - (None, true) => true, // New file (not tracked in HEAD) + match (head_blob, exists_on_disk) { + (None, false) => false, // Not tracked, not on disk + (None, true) => true, // New untracked file (Some(_), false) => true, // Deleted from disk (Some(head_data), true) => { - // Compare HEAD blob with file on disk match std::fs::read(&disk_path) { Ok(disk_data) => head_data != disk_data, - Err(_) => false, + // File exists but unreadable — conservatively assume changed + Err(_) => true, } } } } -/// Get the contents of a file from HEAD in the given repository. +/// Read a file's contents from HEAD in the given repository. fn head_file_contents(repo: &gix::Repository, file_path: &str) -> Option> { let mut head = repo.head().ok()?; let head_id = head.try_peel_to_id().ok()??; diff --git a/src/main.rs b/src/main.rs index b8c77e0..1e830a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,11 +65,14 @@ fn main() { } }, Some(Commands::Check { path }) => match WorktreeManager::discover() { - Ok(worktrees) => { - if check::run_check(&worktrees, &path) { - std::process::exit(2); + Ok(worktrees) => match check::run_check(&worktrees, &path) { + Ok(true) => std::process::exit(2), + Ok(false) => {} + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); } - } + }, Err(e) => { eprintln!("Error: {}", e); std::process::exit(1); diff --git a/src/status.rs b/src/status.rs index 1d545fa..7482921 100644 --- a/src/status.rs +++ b/src/status.rs @@ -25,6 +25,8 @@ struct ConflictInfo { wt1_id: String, wt2_id: String, conflicting_files: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, } /// Handles the display of status information for worktrees and conflicts @@ -111,17 +113,21 @@ impl<'a> StatusDisplay<'a> { let mut matrix: Vec>>> = vec![vec![None; self.worktrees.len()]; self.worktrees.len()]; - // Map worktree branches to indices - let branch_to_index: HashMap = self + // Map worktree IDs to indices (IDs are unique, branches might not be) + let id_to_index: HashMap = self .worktrees .iter() .enumerate() - .map(|(i, wt)| (wt.branch.clone(), i)) + .map(|(i, wt)| (wt.id.clone(), i)) .collect(); for result in pair_results { - let i = branch_to_index[&result.wt1.branch]; - let j = branch_to_index[&result.wt2.branch]; + // Skip errored pairs — they stay as None ("?" in display) + if result.error.is_some() { + continue; + } + let i = id_to_index[&result.wt1.id]; + let j = id_to_index[&result.wt2.id]; matrix[i][j] = Some(result.conflicting_files.clone()); matrix[j][i] = Some(result.conflicting_files.clone()); } @@ -290,31 +296,46 @@ impl<'a> StatusDisplay<'a> { /// Display summary statistics fn display_summary(&self, pair_results: &[WorktreePairConflict]) { - let checked_pairs = pair_results.len(); - let total_conflicts: usize = pair_results.iter().map(|r| r.conflicting_files.len()).sum(); + if pair_results.is_empty() { + return; + } - if checked_pairs > 0 { - print!("\n{}: ", "Summary".bright_cyan().bold()); - print!( - "Checked {} pair{}, ", - checked_pairs.to_string().bright_blue().bold(), - if checked_pairs == 1 { "" } else { "s" } - ); + let error_count = pair_results.iter().filter(|r| r.error.is_some()).count(); + let checked_pairs = pair_results.len() - error_count; + let total_conflicts: usize = pair_results + .iter() + .filter(|r| r.error.is_none()) + .map(|r| r.conflicting_files.len()) + .sum(); - if total_conflicts == 0 { - println!("found {} conflicts ✅", "no".bright_green().bold()); + print!("\n{}: ", "Summary".bright_cyan().bold()); + print!( + "Checked {} pair{}, ", + checked_pairs.to_string().bright_blue().bold(), + if checked_pairs == 1 { "" } else { "s" } + ); + + if total_conflicts == 0 { + println!("found {} conflicts ✅", "no".bright_green().bold()); + } else { + let conflict_color = if total_conflicts == 1 { + total_conflicts.to_string().bright_yellow().bold() } else { - let conflict_color = if total_conflicts == 1 { - total_conflicts.to_string().bright_yellow().bold() - } else { - total_conflicts.to_string().bright_red().bold() - }; - println!( - "found {} total conflict{}", - conflict_color, - if total_conflicts == 1 { "" } else { "s" } - ); - } + total_conflicts.to_string().bright_red().bold() + }; + println!( + "found {} total conflict{}", + conflict_color, + if total_conflicts == 1 { "" } else { "s" } + ); + } + + if error_count > 0 { + println!( + " {} {} failed to check", + error_count.to_string().bright_red().bold(), + if error_count == 1 { "pair" } else { "pairs" } + ); } } @@ -375,11 +396,12 @@ pub fn run_status(worktrees: &WorktreeManager, json: bool) { let conflicts: Vec = worktrees .check_all_conflicts() .into_iter() - .filter(|c| !c.conflicting_files.is_empty()) // Only include actual conflicts + .filter(|c| !c.conflicting_files.is_empty() || c.error.is_some()) .map(|c| ConflictInfo { wt1_id: c.wt1.id, wt2_id: c.wt2.id, conflicting_files: c.conflicting_files, + error: c.error, }) .collect(); diff --git a/src/worktree/conflict.rs b/src/worktree/conflict.rs index edf2976..6b6edf0 100644 --- a/src/worktree/conflict.rs +++ b/src/worktree/conflict.rs @@ -14,6 +14,11 @@ pub struct WorktreePairConflict { pub wt1: Worktree, pub wt2: Worktree, pub conflicting_files: Vec, + /// Error message if conflict detection failed for this pair. + /// When set, `conflicting_files` is empty and should not be trusted. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub error: Option, } // ============================================================================ @@ -83,14 +88,16 @@ impl WorktreeManager { for i in 0..all.len() { for j in (i + 1)..all.len() { - let files = all[i] - .conflicts_with(&all[j]) - .unwrap_or_else(|_| Vec::new()); + let (files, error) = match all[i].conflicts_with(&all[j]) { + Ok(files) => (files, None), + Err(e) => (Vec::new(), Some(e.to_string())), + }; results.push(WorktreePairConflict { wt1: all[i].clone(), wt2: all[j].clone(), conflicting_files: files, + error, }); } } From 0d5bfedd7e2a7fab18f4c4ec63407fb4d291ae76 Mon Sep 17 00:00:00 2001 From: Mathew Kuriakose Date: Sat, 7 Feb 2026 23:45:41 +0000 Subject: [PATCH 3/6] Make `clash check` read from hook stdin when no path argument given This allows the PreToolUse hook config to be just `"command": "clash check"` without needing a shell script or jq to extract the file path. When no path is provided, reads JSON from stdin and extracts tool_input.file_path. In hook mode, conflicts are output to stderr (which Claude sees on exit 2) and clean results are silent. Includes a TTY guard to show helpful usage instead of blocking when run interactively without arguments. Co-Authored-By: Claude Opus 4.6 --- src/check.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 6 ++--- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/check.rs b/src/check.rs index 86d75fb..4f43737 100644 --- a/src/check.rs +++ b/src/check.rs @@ -1,5 +1,6 @@ use clash_sh::{Worktree, WorktreeManager}; use serde::Serialize; +use std::io::{IsTerminal, Read}; use std::path::{Path, PathBuf}; // ============================================================================ @@ -40,6 +41,8 @@ pub enum CheckError { PathResolution(PathBuf), /// Merge conflict detection failed for a worktree pair ConflictDetection { worktree: String, reason: String }, + /// Failed to read or parse hook input from stdin + HookInput(String), } impl std::fmt::Display for CheckError { @@ -63,6 +66,7 @@ impl std::fmt::Display for CheckError { worktree, reason ) } + Self::HookInput(msg) => write!(f, "hook input error: {}", msg), } } } @@ -73,12 +77,22 @@ impl std::fmt::Display for CheckError { /// Check a single file for conflicts across worktrees. /// -/// Prints JSON to stdout and returns whether conflicts were found. +/// - `Some(path)` — manual mode: resolve path, output JSON to stdout +/// - `None` — hook mode: read file path from stdin JSON, output conflicts to stderr +/// +/// Returns whether conflicts were found: /// - `Ok(false)` — no conflicts, exit 0 /// - `Ok(true)` — conflicts found, exit 2 /// - `Err(e)` — operational error, caller prints to stderr and exits 1 -pub fn run_check(worktrees: &WorktreeManager, path: &str) -> Result { - let (current_wt, repo_relative) = resolve_file_path(path, worktrees)?; +pub fn run_check(worktrees: &WorktreeManager, path: Option<&str>) -> Result { + let hook_mode = path.is_none(); + + let path = match path { + Some(p) => p.to_string(), + None => read_hook_input()?, + }; + + let (current_wt, repo_relative) = resolve_file_path(&path, worktrees)?; let mut conflicts = Vec::new(); @@ -119,11 +133,55 @@ pub fn run_check(worktrees: &WorktreeManager, path: &str) -> Result Result { + let stdin = std::io::stdin(); + if stdin.is_terminal() { + return Err(CheckError::HookInput( + "no path argument and stdin is a terminal\n\ + Usage: clash check (manual mode)\n\ + Usage: echo '{...}' | clash check (hook mode)" + .to_string(), + )); + } + + let mut buf = String::new(); + stdin + .lock() + .read_to_string(&mut buf) + .map_err(|e| CheckError::HookInput(format!("failed to read stdin: {}", e)))?; + + let json: serde_json::Value = serde_json::from_str(&buf) + .map_err(|e| CheckError::HookInput(format!("invalid JSON on stdin: {}", e)))?; + + json["tool_input"]["file_path"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| CheckError::HookInput("stdin JSON missing tool_input.file_path".to_string())) +} + // ============================================================================ // Path resolution // ============================================================================ diff --git a/src/main.rs b/src/main.rs index 1e830a2..8e867a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,8 +31,8 @@ enum Commands { }, /// Check a single file for conflicts and active work across worktrees (JSON output) Check { - /// File path to check - path: String, + /// File path to check (reads from hook stdin if omitted) + path: Option, }, } @@ -65,7 +65,7 @@ fn main() { } }, Some(Commands::Check { path }) => match WorktreeManager::discover() { - Ok(worktrees) => match check::run_check(&worktrees, &path) { + Ok(worktrees) => match check::run_check(&worktrees, path.as_deref()) { Ok(true) => std::process::exit(2), Ok(false) => {} Err(e) => { From 845616ba802eb7794b978a2cfd0ef624ed7ea85c Mon Sep 17 00:00:00 2001 From: Mathew Kuriakose Date: Sun, 8 Feb 2026 00:24:29 +0000 Subject: [PATCH 4/6] Use hook decision JSON with "ask" instead of hard-blocking exit 2 In hook mode, output Claude Code's hookSpecificOutput JSON with permissionDecision "ask" so the user can override conflicts instead of being hard-blocked. Manual mode (`clash check `) still uses exit 2. Note: permissionDecisionReason doesn't render in the permission prompt yet (anthropics/claude-code#17356). Co-Authored-By: Claude Opus 4.6 --- src/check.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 4 ++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/check.rs b/src/check.rs index 4f43737..60aa330 100644 --- a/src/check.rs +++ b/src/check.rs @@ -23,6 +23,26 @@ struct FileConflict { has_active_changes: bool, } +/// Claude Code hook JSON output format. +/// +/// When output on stdout with exit 0, Claude Code interprets +/// `permissionDecision` to decide whether to allow, deny, or ask. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HookOutput { + hook_specific_output: HookDecision, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HookDecision { + hook_event_name: &'static str, + permission_decision: &'static str, + permission_decision_reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + additional_context: Option, +} + // ============================================================================ // Error type // ============================================================================ @@ -77,12 +97,12 @@ impl std::fmt::Display for CheckError { /// Check a single file for conflicts across worktrees. /// -/// - `Some(path)` — manual mode: resolve path, output JSON to stdout -/// - `None` — hook mode: read file path from stdin JSON, output conflicts to stderr +/// - `Some(path)` — manual mode: JSON to stdout, exit 2 if conflicts +/// - `None` — hook mode: hook decision JSON to stdout (ask on conflicts), always exit 0 /// /// Returns whether conflicts were found: -/// - `Ok(false)` — no conflicts, exit 0 -/// - `Ok(true)` — conflicts found, exit 2 +/// - `Ok(false)` — no conflicts +/// - `Ok(true)` — conflicts found /// - `Err(e)` — operational error, caller prints to stderr and exits 1 pub fn run_check(worktrees: &WorktreeManager, path: Option<&str>) -> Result { let hook_mode = path.is_none(); @@ -135,9 +155,20 @@ pub fn run_check(worktrees: &WorktreeManager, path: Option<&str>) -> Result Result { .ok_or_else(|| CheckError::HookInput("stdin JSON missing tool_input.file_path".to_string())) } +// ============================================================================ +// Hook output formatting +// ============================================================================ + +/// Build a human-readable conflict reason for the hook prompt. +fn format_conflict_reason(output: &CheckOutput) -> String { + let mut parts: Vec = Vec::new(); + for c in &output.conflicts { + let kind = match (c.has_merge_conflict, c.has_active_changes) { + (true, true) => "merge conflict + active changes", + (true, false) => "merge conflict", + (false, true) => "active changes", + (false, false) => continue, + }; + parts.push(format!("{} [{}]: {}", c.worktree, c.branch, kind)); + } + format!( + "Conflicts on {} with {} worktree(s):\n{}", + output.file, + parts.len(), + parts.join("\n") + ) +} + // ============================================================================ // Path resolution // ============================================================================ diff --git a/src/main.rs b/src/main.rs index 8e867a6..0ba24e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,8 +66,8 @@ fn main() { }, Some(Commands::Check { path }) => match WorktreeManager::discover() { Ok(worktrees) => match check::run_check(&worktrees, path.as_deref()) { - Ok(true) => std::process::exit(2), - Ok(false) => {} + Ok(true) if path.is_some() => std::process::exit(2), + Ok(_) => {} Err(e) => { eprintln!("Error: {}", e); std::process::exit(1); From 494ce0baaacccf3a1eaa792e485bb31ec69edc7c Mon Sep 17 00:00:00 2001 From: Mathew Kuriakose Date: Sun, 8 Feb 2026 00:38:19 +0000 Subject: [PATCH 5/6] Update time crate to 0.3.47 to fix RUSTSEC-2026-0009 Fixes Denial of Service via Stack Exhaustion vulnerability in the time crate (transitive dep via ratatui). Resolves CI failures in Dependency Check and Security Audit jobs. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72585a5..d4dd4df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2872,9 +2872,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "libc", From 8c988c8716f3a93126054f30028131228f9fe5a1 Mon Sep 17 00:00:00 2001 From: Mathew Kuriakose Date: Sun, 8 Feb 2026 00:44:53 +0000 Subject: [PATCH 6/6] Add CI summary job for branch protection status check Branch protection requires a status called "CI" but the workflow only reported individual job names. Add a summary job that depends on all other jobs and reports as "CI". Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d87fee..b41a6c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,4 +85,18 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Build - run: cargo build --release \ No newline at end of file + run: cargo build --release + + # Summary job for branch protection — reports a single "CI" status + ci: + name: CI + if: always() + needs: [test, fmt, clippy, audit, deny, build] + runs-on: ubuntu-latest + steps: + - name: Check results + run: | + if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more jobs failed" + exit 1 + fi \ No newline at end of file