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 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", diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..60aa330 --- /dev/null +++ b/src/check.rs @@ -0,0 +1,332 @@ +use clash_sh::{Worktree, WorktreeManager}; +use serde::Serialize; +use std::io::{IsTerminal, Read}; +use std::path::{Path, PathBuf}; + +// ============================================================================ +// Output types +// ============================================================================ + +#[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, +} + +/// 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 +// ============================================================================ + +/// Errors specific to the check command. +/// +/// 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 }, + /// Failed to read or parse hook input from stdin + HookInput(String), +} + +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 + ) + } + Self::HookInput(msg) => write!(f, "hook input error: {}", msg), + } + } +} + +// ============================================================================ +// Main entry point +// ============================================================================ + +/// Check a single file for conflicts across worktrees. +/// +/// - `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 +/// - `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(); + + 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(); + + for other_wt in worktrees.iter() { + if other_wt.id == current_wt.id { + continue; + } + + let merge_conflicts = + current_wt + .conflicts_with(other_wt) + .map_err(|e| CheckError::ConflictDetection { + worktree: other_wt.id.clone(), + reason: e.to_string(), + })?; + + 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 { + worktree: other_wt.id.clone(), + branch: other_wt.branch.clone(), + has_merge_conflict, + has_active_changes, + }); + } + } + + let has_conflicts = !conflicts.is_empty(); + + let output = CheckOutput { + file: repo_relative, + current_worktree: current_wt.id.clone(), + current_branch: current_wt.branch.clone(), + conflicts, + }; + + // Serialization of simple String/bool fields cannot fail in practice + let json = serde_json::to_string_pretty(&output).expect("CheckOutput is always serializable"); + + if hook_mode { + // Hook mode: output hook decision JSON to stdout so Claude Code prompts the user + if has_conflicts { + let reason = format_conflict_reason(&output); + let hook_output = HookOutput { + hook_specific_output: HookDecision { + hook_event_name: "PreToolUse", + permission_decision: "ask", + permission_decision_reason: reason.clone(), + additional_context: Some(reason), + }, + }; + let hook_json = + serde_json::to_string(&hook_output).expect("HookOutput is always serializable"); + println!("{}", hook_json); + } + } else { + // Manual mode: always output to stdout + println!("{}", json); + } + + Ok(has_conflicts) +} + +// ============================================================================ +// Hook stdin reading +// ============================================================================ + +/// Read a file path from Claude Code's PreToolUse hook JSON on stdin. +/// +/// Expected format: `{"tool_input": {"file_path": "src/main.rs"}, ...}` +/// Returns the extracted file_path, or an error if stdin is a TTY, +/// unreadable, or doesn't contain the expected structure. +fn read_hook_input() -> 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())) +} + +// ============================================================================ +// 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 +// ============================================================================ + +/// Resolve a file path to its containing worktree and repo-relative path. +/// +/// 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 { + 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 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, + }; + + let workdir = match repo.workdir() { + Some(p) => p.to_path_buf(), + None => return false, + }; + + let disk_path = workdir.join(file_path); + let exists_on_disk = disk_path.exists(); + let head_blob = head_file_contents(&repo, file_path); + + 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) => { + match std::fs::read(&disk_path) { + Ok(disk_data) => head_data != disk_data, + // File exists but unreadable — conservatively assume changed + Err(_) => true, + } + } + } +} + +/// 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()??; + 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..0ba24e2 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 (reads from hook stdin if omitted) + path: Option, + }, } fn main() { @@ -58,6 +64,20 @@ fn main() { std::process::exit(1); } }, + Some(Commands::Check { path }) => match WorktreeManager::discover() { + Ok(worktrees) => match check::run_check(&worktrees, path.as_deref()) { + Ok(true) if path.is_some() => std::process::exit(2), + Ok(_) => {} + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + }, + 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/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, }); } } 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: