From 8936d09951977f00f3fb09b3ca5aad7f52ab1cda Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 21:41:07 -0700 Subject: [PATCH] feat: add ecc2 hunk-level git patch actions --- ecc2/src/tui/dashboard.rs | 403 ++++++++++++++++++++++++++++++++++--- ecc2/src/worktree/mod.rs | 404 +++++++++++++++++++++++++++++++++++++- 2 files changed, 783 insertions(+), 24 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 16b6fc2c..05d4a311 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -110,6 +110,10 @@ pub struct Dashboard { selected_merge_readiness: Option, selected_git_status_entries: Vec, selected_git_status: usize, + selected_git_patch: Option, + selected_git_patch_hunk_offsets_unified: Vec, + selected_git_patch_hunk_offsets_split: Vec, + selected_git_patch_hunk: usize, output_mode: OutputMode, output_filter: OutputFilter, output_time_filter: OutputTimeFilter, @@ -179,6 +183,7 @@ enum OutputMode { WorktreeDiff, ConflictProtocol, GitStatus, + GitPatch, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -498,6 +503,10 @@ impl Dashboard { selected_merge_readiness: None, selected_git_status_entries: Vec::new(), selected_git_status: 0, + selected_git_patch: None, + selected_git_patch_hunk_offsets_unified: Vec::new(), + selected_git_patch_hunk_offsets_split: Vec::new(), + selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -743,8 +752,11 @@ impl Dashboard { self.sync_output_scroll(area.height.saturating_sub(2) as usize); if self.sessions.get(self.selected_session).is_some() - && self.output_mode == OutputMode::WorktreeDiff - && self.selected_diff_patch.is_some() + && matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) + && self.active_patch_text().is_some() && self.diff_view_mode == DiffViewMode::Split { self.render_split_diff_output(frame, area); @@ -798,6 +810,16 @@ impl Dashboard { }; (self.output_title(), content) } + OutputMode::GitPatch => { + let content = if let Some(patch) = self.selected_git_patch.as_ref() { + build_unified_diff_text(&patch.patch, self.theme_palette()) + } else { + Text::from( + "No selected-file patch available for the current git-status entry.", + ) + }; + (self.output_title(), content) + } OutputMode::ConflictProtocol => { let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { "No conflicted worktree available for the selected session.".to_string() @@ -843,7 +865,7 @@ impl Dashboard { return; } - let Some(patch) = self.selected_diff_patch.as_ref() else { + let Some(patch) = self.active_patch_text() else { return; }; let columns = build_worktree_diff_columns(patch, self.theme_palette()); @@ -883,6 +905,20 @@ impl Dashboard { ); } + if self.output_mode == OutputMode::GitPatch { + let path = self + .selected_git_patch + .as_ref() + .map(|patch| patch.display_path.as_str()) + .unwrap_or("selected file"); + return format!( + " Git patch {}{}{} ", + path, + self.diff_view_mode.title_suffix(), + self.diff_hunk_title_suffix() + ); + } + if self.output_mode == OutputMode::GitStatus { let staged = self .selected_git_status_entries @@ -1175,7 +1211,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] create PR [P] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] file patch [v] git status [z] stage [S] unstage [U] reset [R] commit [C] create PR [P] diff mode [V] hunks [{{/}}] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.pane_focus_shortcuts_label(), self.pane_move_shortcuts_label(), self.layout_label(), @@ -1303,11 +1339,12 @@ impl Dashboard { " H Restore all collapsed panes".to_string(), " y Toggle selected-session timeline view".to_string(), " E Cycle timeline event filter".to_string(), - " v Toggle selected worktree diff in output pane".to_string(), + " v Toggle selected worktree diff or selected-file patch in output pane" + .to_string(), " z Toggle selected worktree git status in output pane".to_string(), " V Toggle diff view mode between split and unified".to_string(), " {/} Jump to previous/next diff hunk in the active diff view".to_string(), - " S/U/R Stage, unstage, or reset the selected git-status entry".to_string(), + " S/U/R Stage, unstage, or reset the selected file or active diff hunk".to_string(), " C Commit staged changes for the selected worktree".to_string(), " P Create a draft PR from the selected worktree branch".to_string(), " c Show conflict-resolution protocol for selected conflicted worktree" @@ -2037,16 +2074,31 @@ impl Dashboard { self.set_operator_note("showing session output".to_string()); } OutputMode::GitStatus => { - self.output_mode = OutputMode::SessionOutput; - self.reset_output_view(); - self.set_operator_note("showing session output".to_string()); + self.sync_selected_git_patch(); + if self.selected_git_patch.is_some() { + self.output_mode = OutputMode::GitPatch; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = self.current_diff_hunk_offset(); + self.set_operator_note("showing selected file patch".to_string()); + } else { + self.set_operator_note( + "no patch hunks available for the selected git-status entry".to_string(), + ); + } + } + OutputMode::GitPatch => { + self.output_mode = OutputMode::GitStatus; + self.output_follow = false; + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note("showing selected worktree git status".to_string()); } } } pub fn toggle_git_status_mode(&mut self) { match self.output_mode { - OutputMode::GitStatus => { + OutputMode::GitStatus | OutputMode::GitPatch => { self.output_mode = OutputMode::SessionOutput; self.reset_output_view(); self.set_operator_note("showing session output".to_string()); @@ -2073,6 +2125,11 @@ impl Dashboard { } pub fn stage_selected_git_status(&mut self) { + if self.output_mode == OutputMode::GitPatch { + self.stage_selected_git_hunk(); + return; + } + if self.output_mode != OutputMode::GitStatus { self.set_operator_note( "git staging controls are only available in git status view".to_string(), @@ -2096,6 +2153,11 @@ impl Dashboard { } pub fn unstage_selected_git_status(&mut self) { + if self.output_mode == OutputMode::GitPatch { + self.unstage_selected_git_hunk(); + return; + } + if self.output_mode != OutputMode::GitStatus { self.set_operator_note( "git staging controls are only available in git status view".to_string(), @@ -2122,6 +2184,11 @@ impl Dashboard { } pub fn reset_selected_git_status(&mut self) { + if self.output_mode == OutputMode::GitPatch { + self.reset_selected_git_hunk(); + return; + } + if self.output_mode != OutputMode::GitStatus { self.set_operator_note( "git staging controls are only available in git status view".to_string(), @@ -2145,7 +2212,10 @@ impl Dashboard { } pub fn begin_commit_prompt(&mut self) { - if self.output_mode != OutputMode::GitStatus { + if !matches!( + self.output_mode, + OutputMode::GitStatus | OutputMode::GitPatch + ) { self.set_operator_note( "commit prompt is only available in git status view".to_string(), ); @@ -2199,8 +2269,69 @@ impl Dashboard { self.set_operator_note("pr mode | edit the title and press Enter".to_string()); } + fn stage_selected_git_hunk(&mut self) { + let Some((entry, worktree, _, hunk)) = self.selected_git_patch_context() else { + self.set_operator_note("no git hunk selected".to_string()); + return; + }; + + if let Err(error) = worktree::stage_hunk(&worktree, &hunk) { + tracing::warn!("Failed to stage hunk for {}: {error}", entry.path); + self.set_operator_note(format!( + "stage hunk failed for {}: {error}", + entry.display_path + )); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("staged hunk in {}", entry.display_path)); + } + + fn unstage_selected_git_hunk(&mut self) { + let Some((entry, worktree, _, hunk)) = self.selected_git_patch_context() else { + self.set_operator_note("no git hunk selected".to_string()); + return; + }; + + if let Err(error) = worktree::unstage_hunk(&worktree, &hunk) { + tracing::warn!("Failed to unstage hunk for {}: {error}", entry.path); + self.set_operator_note(format!( + "unstage hunk failed for {}: {error}", + entry.display_path + )); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("unstaged hunk in {}", entry.display_path)); + } + + fn reset_selected_git_hunk(&mut self) { + let Some((entry, worktree, _, hunk)) = self.selected_git_patch_context() else { + self.set_operator_note("no git hunk selected".to_string()); + return; + }; + + if let Err(error) = worktree::reset_hunk(&worktree, &entry, &hunk) { + tracing::warn!("Failed to reset hunk for {}: {error}", entry.path); + self.set_operator_note(format!( + "reset hunk failed for {}: {error}", + entry.display_path + )); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("reset hunk in {}", entry.display_path)); + } + pub fn toggle_diff_view_mode(&mut self) { - if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { + if !matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) || self.active_patch_text().is_none() + { self.set_operator_note("no active worktree diff view to toggle".to_string()); return; } @@ -2223,7 +2354,11 @@ impl Dashboard { } fn move_diff_hunk(&mut self, delta: isize) { - if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { + if !matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) || self.active_patch_text().is_none() + { self.set_operator_note("no active worktree diff to navigate".to_string()); return; } @@ -2236,12 +2371,14 @@ impl Dashboard { } let len = offsets.len(); - let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize; + let next = + (self.current_diff_hunk_index() as isize + delta).rem_euclid(len as isize) as usize; (len, offsets[next]) }; - let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize; - self.selected_diff_hunk = next; + let next = + (self.current_diff_hunk_index() as isize + delta).rem_euclid(len as isize) as usize; + self.set_current_diff_hunk_index(next); self.output_follow = false; self.output_scroll_offset = next_offset; self.set_operator_note(format!("diff hunk {}/{}", next + 1, len)); @@ -4136,6 +4273,7 @@ impl Dashboard { self.output_mode = OutputMode::SessionOutput; } self.sync_selected_git_status(); + self.sync_selected_git_patch(); } fn sync_selected_git_status(&mut self) { @@ -4147,11 +4285,50 @@ impl Dashboard { if self.selected_git_status >= self.selected_git_status_entries.len() { self.selected_git_status = self.selected_git_status_entries.len().saturating_sub(1); } - if self.output_mode == OutputMode::GitStatus && worktree.is_none() { + if matches!( + self.output_mode, + OutputMode::GitStatus | OutputMode::GitPatch + ) && worktree.is_none() + { self.output_mode = OutputMode::SessionOutput; } } + fn sync_selected_git_patch(&mut self) { + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.selected_git_patch = None; + self.selected_git_patch_hunk_offsets_unified.clear(); + self.selected_git_patch_hunk_offsets_split.clear(); + self.selected_git_patch_hunk = 0; + if self.output_mode == OutputMode::GitPatch { + self.output_mode = OutputMode::GitStatus; + } + return; + }; + + self.selected_git_patch = worktree::git_status_patch_view(&worktree, &entry) + .ok() + .flatten(); + self.selected_git_patch_hunk_offsets_unified = self + .selected_git_patch + .as_ref() + .map(|patch| build_unified_diff_hunk_offsets(&patch.patch)) + .unwrap_or_default(); + self.selected_git_patch_hunk_offsets_split = self + .selected_git_patch + .as_ref() + .map(|patch| { + build_worktree_diff_columns(&patch.patch, self.theme_palette()).hunk_offsets + }) + .unwrap_or_default(); + if self.selected_git_patch_hunk >= self.current_diff_hunk_offsets().len() { + self.selected_git_patch_hunk = 0; + } + if self.output_mode == OutputMode::GitPatch && self.selected_git_patch.is_none() { + self.output_mode = OutputMode::GitStatus; + } + } + fn selected_git_status_context( &self, ) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> { @@ -4164,9 +4341,24 @@ impl Dashboard { Some((entry, worktree)) } + fn selected_git_patch_context( + &self, + ) -> Option<( + worktree::GitStatusEntry, + crate::session::WorktreeInfo, + worktree::GitStatusPatchView, + worktree::GitPatchHunk, + )> { + let (entry, worktree) = self.selected_git_status_context()?; + let patch = self.selected_git_patch.clone()?; + let hunk = patch.hunks.get(self.selected_git_patch_hunk).cloned()?; + Some((entry, worktree, patch, hunk)) + } + fn refresh_after_git_status_action(&mut self, preferred_path: Option<&str>) { + let keep_patch_view = self.output_mode == OutputMode::GitPatch; + let preferred_hunk = self.selected_git_patch_hunk; self.refresh(); - self.output_mode = OutputMode::GitStatus; self.selected_pane = Pane::Output; self.output_follow = false; if let Some(path) = preferred_path { @@ -4178,19 +4370,56 @@ impl Dashboard { self.selected_git_status = index; } } + self.sync_selected_git_patch(); + if keep_patch_view && self.selected_git_patch.is_some() { + self.output_mode = OutputMode::GitPatch; + let max_index = self.current_diff_hunk_offsets().len().saturating_sub(1); + self.selected_git_patch_hunk = preferred_hunk.min(max_index); + self.output_scroll_offset = self.current_diff_hunk_offset(); + } else { + self.output_mode = OutputMode::GitStatus; + } self.sync_output_scroll(self.last_output_height.max(1)); } + fn active_patch_text(&self) -> Option<&String> { + match self.output_mode { + OutputMode::GitPatch => self.selected_git_patch.as_ref().map(|patch| &patch.patch), + OutputMode::WorktreeDiff => self.selected_diff_patch.as_ref(), + _ => None, + } + } + fn current_diff_hunk_offsets(&self) -> &[usize] { - match self.diff_view_mode { - DiffViewMode::Split => &self.selected_diff_hunk_offsets_split, - DiffViewMode::Unified => &self.selected_diff_hunk_offsets_unified, + match self.output_mode { + OutputMode::GitPatch => match self.diff_view_mode { + DiffViewMode::Split => &self.selected_git_patch_hunk_offsets_split, + DiffViewMode::Unified => &self.selected_git_patch_hunk_offsets_unified, + }, + _ => match self.diff_view_mode { + DiffViewMode::Split => &self.selected_diff_hunk_offsets_split, + DiffViewMode::Unified => &self.selected_diff_hunk_offsets_unified, + }, + } + } + + fn current_diff_hunk_index(&self) -> usize { + match self.output_mode { + OutputMode::GitPatch => self.selected_git_patch_hunk, + _ => self.selected_diff_hunk, + } + } + + fn set_current_diff_hunk_index(&mut self, index: usize) { + match self.output_mode { + OutputMode::GitPatch => self.selected_git_patch_hunk = index, + _ => self.selected_diff_hunk = index, } } fn current_diff_hunk_offset(&self) -> usize { self.current_diff_hunk_offsets() - .get(self.selected_diff_hunk) + .get(self.current_diff_hunk_index()) .copied() .unwrap_or(0) } @@ -4200,7 +4429,7 @@ impl Dashboard { if total == 0 { String::new() } else { - format!(" {}/{}", self.selected_diff_hunk + 1, total) + format!(" {}/{}", self.current_diff_hunk_index() + 1, total) } } @@ -4854,6 +5083,13 @@ impl Dashboard { fn max_output_scroll(&self) -> usize { let total_lines = if self.output_mode == OutputMode::GitStatus { self.selected_git_status_entries.len() + } else if matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) { + self.active_patch_text() + .map(|patch| patch.lines().count()) + .unwrap_or(0) } else if self.output_mode == OutputMode::Timeline { self.visible_timeline_lines().len() } else { @@ -8076,6 +8312,111 @@ mod tests { Ok(()) } + #[test] + fn toggle_output_mode_from_git_status_opens_selected_file_patch() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-patch-view-{}", Uuid::new_v4())); + init_git_repo(&root)?; + fs::write( + root.join("README.md"), + "line 1\nline 2\nline 3\nline 4\nline 5\nline 6 updated\n", + )?; + + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.working_dir = root.clone(); + session.worktree = Some(WorktreeInfo { + path: root.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }); + let mut dashboard = test_dashboard(vec![session], 0); + let stored = dashboard.sessions[0].clone(); + dashboard.db.insert_session(&stored)?; + + dashboard.toggle_git_status_mode(); + dashboard.toggle_output_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::GitPatch); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected file patch") + ); + assert!(dashboard.output_title().contains("Git patch README.md")); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Git patch README.md")); + assert!(rendered.contains("+line 6 updated")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn git_patch_mode_stages_only_selected_hunk() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-patch-stage-{}", Uuid::new_v4())); + init_git_repo(&root)?; + let original = (1..=12) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + fs::write(root.join("notes.txt"), format!("{original}\n"))?; + run_git(&root, &["add", "notes.txt"])?; + run_git(&root, &["commit", "-qm", "add notes"])?; + + let updated = (1..=12) + .map(|index| match index { + 2 => "line 2 changed".to_string(), + 11 => "line 11 changed".to_string(), + _ => format!("line {index}"), + }) + .collect::>() + .join("\n"); + fs::write(root.join("notes.txt"), format!("{updated}\n"))?; + + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.working_dir = root.clone(); + session.worktree = Some(WorktreeInfo { + path: root.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }); + let mut dashboard = test_dashboard(vec![session], 0); + let stored = dashboard.sessions[0].clone(); + dashboard.db.insert_session(&stored)?; + + dashboard.toggle_git_status_mode(); + dashboard.toggle_output_mode(); + dashboard.stage_selected_git_status(); + + assert_eq!(dashboard.output_mode, OutputMode::GitPatch); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("staged hunk in notes.txt") + ); + let cached = git_stdout(&root, &["diff", "--cached", "--", "notes.txt"])?; + assert!(cached.contains("line 2 changed")); + assert!(!cached.contains("line 11 changed")); + let working = git_stdout(&root, &["diff", "--", "notes.txt"])?; + assert!(!working.contains("line 2 changed")); + assert!(working.contains("line 11 changed")); + assert!(dashboard.output_title().contains("Git patch notes.txt")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn begin_commit_prompt_opens_commit_input_for_staged_entries() { let mut dashboard = test_dashboard( @@ -12078,6 +12419,10 @@ diff --git a/src/lib.rs b/src/lib.rs selected_merge_readiness: None, selected_git_status_entries: Vec::new(), selected_git_status: 0, + selected_git_patch: None, + selected_git_patch_hunk_offsets_unified: Vec::new(), + selected_git_patch_hunk_offsets_split: Vec::new(), + selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -12169,6 +12514,18 @@ diff --git a/src/lib.rs b/src/lib.rs Ok(()) } + fn git_stdout(path: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output()?; + if !output.status.success() { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + fn sample_session( id: &str, agent_type: &str, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 6165a559..7fe7ff6c 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -2,8 +2,9 @@ use anyhow::{Context, Result}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use crate::config::Config; use crate::session::WorktreeInfo; @@ -63,6 +64,27 @@ pub struct GitStatusEntry { pub conflicted: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitPatchSectionKind { + Staged, + Unstaged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitPatchHunk { + pub section: GitPatchSectionKind, + pub header: String, + pub patch: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusPatchView { + pub path: String, + pub display_path: String, + pub patch: String, + pub hunks: Vec, +} + /// Create a new git worktree for an agent session. pub fn create_for_session(session_id: &str, cfg: &Config) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve repository root")?; @@ -325,6 +347,104 @@ pub fn reset_path(worktree: &WorktreeInfo, entry: &GitStatusEntry) -> Result<()> } } +pub fn git_status_patch_view( + worktree: &WorktreeInfo, + entry: &GitStatusEntry, +) -> Result> { + if entry.untracked { + return Ok(None); + } + + let staged_patch = + git_diff_patch_text_for_paths(&worktree.path, &["--cached"], &[entry.path.clone()])?; + let unstaged_patch = git_diff_patch_text_for_paths(&worktree.path, &[], &[entry.path.clone()])?; + + let mut sections = Vec::new(); + let mut hunks = Vec::new(); + + if !staged_patch.trim().is_empty() { + sections.push(format!("--- Staged diff ---\n{}", staged_patch.trim_end())); + hunks.extend(extract_patch_hunks( + GitPatchSectionKind::Staged, + &staged_patch, + )); + } + if !unstaged_patch.trim().is_empty() { + sections.push(format!( + "--- Working tree diff ---\n{}", + unstaged_patch.trim_end() + )); + hunks.extend(extract_patch_hunks( + GitPatchSectionKind::Unstaged, + &unstaged_patch, + )); + } + + if sections.is_empty() { + Ok(None) + } else { + Ok(Some(GitStatusPatchView { + path: entry.path.clone(), + display_path: entry.display_path.clone(), + patch: sections.join("\n\n"), + hunks, + })) + } +} + +pub fn stage_hunk(worktree: &WorktreeInfo, hunk: &GitPatchHunk) -> Result<()> { + if hunk.section != GitPatchSectionKind::Unstaged { + anyhow::bail!("selected hunk is already staged"); + } + git_apply_patch( + &worktree.path, + &["--cached"], + &hunk.patch, + "stage selected hunk", + ) +} + +pub fn unstage_hunk(worktree: &WorktreeInfo, hunk: &GitPatchHunk) -> Result<()> { + if hunk.section != GitPatchSectionKind::Staged { + anyhow::bail!("selected hunk is not staged"); + } + git_apply_patch( + &worktree.path, + &["-R", "--cached"], + &hunk.patch, + "unstage selected hunk", + ) +} + +pub fn reset_hunk( + worktree: &WorktreeInfo, + entry: &GitStatusEntry, + hunk: &GitPatchHunk, +) -> Result<()> { + if entry.untracked { + anyhow::bail!("cannot reset hunks for untracked files"); + } + + match hunk.section { + GitPatchSectionKind::Unstaged => { + git_apply_patch(&worktree.path, &["-R"], &hunk.patch, "reset selected hunk") + } + GitPatchSectionKind::Staged => { + if entry.unstaged { + anyhow::bail!( + "cannot reset a staged hunk while the file also has unstaged changes; unstage it first" + ); + } + git_apply_patch( + &worktree.path, + &["-R", "--index"], + &hunk.patch, + "reset selected staged hunk", + ) + } + } +} + pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result { let message = message.trim(); if message.is_empty() { @@ -887,6 +1007,39 @@ fn git_diff_patch_lines(worktree_path: &Path, extra_args: &[&str]) -> Result Result { + if paths.is_empty() { + return Ok(String::new()); + } + + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .args(["--patch", "--find-renames"]); + command.args(extra_args); + command.arg("--"); + for path in paths { + command.arg(path); + } + + let output = command + .output() + .context("Failed to generate filtered git patch")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git diff failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + fn git_diff_patch_lines_for_paths( worktree_path: &Path, extra_args: &[&str], @@ -924,6 +1077,86 @@ fn git_diff_patch_lines_for_paths( Ok(parse_nonempty_lines(&output.stdout)) } +fn extract_patch_hunks(section: GitPatchSectionKind, patch_text: &str) -> Vec { + let lines: Vec<&str> = patch_text.lines().collect(); + let Some(diff_start) = lines + .iter() + .position(|line| line.starts_with("diff --git ")) + else { + return Vec::new(); + }; + let Some(first_hunk_start) = lines + .iter() + .enumerate() + .skip(diff_start) + .find_map(|(index, line)| line.starts_with("@@").then_some(index)) + else { + return Vec::new(); + }; + + let header_lines = lines[diff_start..first_hunk_start].to_vec(); + let hunk_starts = lines + .iter() + .enumerate() + .skip(first_hunk_start) + .filter_map(|(index, line)| line.starts_with("@@").then_some(index)) + .collect::>(); + + hunk_starts + .iter() + .enumerate() + .map(|(position, start)| { + let end = hunk_starts + .get(position + 1) + .copied() + .unwrap_or(lines.len()); + let mut patch_lines = header_lines + .iter() + .map(|line| (*line).to_string()) + .collect::>(); + patch_lines.extend(lines[*start..end].iter().map(|line| (*line).to_string())); + GitPatchHunk { + section, + header: lines[*start].to_string(), + patch: format!("{}\n", patch_lines.join("\n")), + } + }) + .collect() +} + +fn git_apply_patch(worktree_path: &Path, args: &[&str], patch: &str, action: &str) -> Result<()> { + let mut child = Command::new("git") + .arg("-C") + .arg(worktree_path) + .arg("apply") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to {action}"))?; + + { + let stdin = child + .stdin + .as_mut() + .context("Failed to open git apply stdin")?; + stdin + .write_all(patch.as_bytes()) + .with_context(|| format!("Failed to write patch for {action}"))?; + } + + let output = child + .wait_with_output() + .with_context(|| format!("Failed to wait for git apply while trying to {action}"))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git apply failed while trying to {action}: {stderr}"); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SharedDependencyStrategy { label: &'static str, @@ -1364,6 +1597,18 @@ mod tests { Ok(()) } + fn git_stdout(repo: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output()?; + if !output.status.success() { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + fn init_repo(root: &Path) -> Result { let repo = root.join("repo"); fs::create_dir_all(&repo)?; @@ -1917,6 +2162,163 @@ mod tests { Ok(()) } + #[test] + fn git_status_patch_view_supports_hunk_stage_and_unstage() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-hunk-stage-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }; + + let original = (1..=12) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{original}\n"))?; + run_git(&repo, &["add", "notes.txt"])?; + run_git(&repo, &["commit", "-m", "add notes"])?; + + let updated = (1..=12) + .map(|index| match index { + 2 => "line 2 changed".to_string(), + 11 => "line 11 changed".to_string(), + _ => format!("line {index}"), + }) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{updated}\n"))?; + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry"); + let patch = + git_status_patch_view(&worktree, &entry)?.expect("selected-file patch view for notes"); + assert_eq!(patch.hunks.len(), 2); + assert!(patch + .hunks + .iter() + .all(|hunk| hunk.section == GitPatchSectionKind::Unstaged)); + + stage_hunk(&worktree, &patch.hunks[0])?; + + let cached = git_stdout(&repo, &["diff", "--cached", "--", "notes.txt"])?; + assert!(cached.contains("line 2 changed")); + assert!(!cached.contains("line 11 changed")); + + let working = git_stdout(&repo, &["diff", "--", "notes.txt"])?; + assert!(!working.contains("line 2 changed")); + assert!(working.contains("line 11 changed")); + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry after stage"); + let patch = git_status_patch_view(&worktree, &entry)?.expect("patch after hunk stage"); + let staged_hunk = patch + .hunks + .iter() + .find(|hunk| hunk.section == GitPatchSectionKind::Staged) + .cloned() + .expect("staged hunk"); + + unstage_hunk(&worktree, &staged_hunk)?; + + let cached = git_stdout(&repo, &["diff", "--cached", "--", "notes.txt"])?; + assert!(cached.trim().is_empty()); + + let working = git_stdout(&repo, &["diff", "--", "notes.txt"])?; + assert!(working.contains("line 2 changed")); + assert!(working.contains("line 11 changed")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn reset_hunk_discards_unstaged_then_staged_hunks() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-hunk-reset-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }; + + let original = (1..=12) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{original}\n"))?; + run_git(&repo, &["add", "notes.txt"])?; + run_git(&repo, &["commit", "-m", "add notes"])?; + + let updated = (1..=12) + .map(|index| match index { + 2 => "line 2 changed".to_string(), + 11 => "line 11 changed".to_string(), + _ => format!("line {index}"), + }) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{updated}\n"))?; + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry"); + let patch = + git_status_patch_view(&worktree, &entry)?.expect("selected-file patch view for notes"); + stage_hunk(&worktree, &patch.hunks[0])?; + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry after stage"); + let patch = git_status_patch_view(&worktree, &entry)?.expect("patch after stage"); + let unstaged_hunk = patch + .hunks + .iter() + .find(|hunk| hunk.section == GitPatchSectionKind::Unstaged) + .cloned() + .expect("unstaged hunk"); + reset_hunk(&worktree, &entry, &unstaged_hunk)?; + + let working = git_stdout(&repo, &["diff", "--", "notes.txt"])?; + assert!(working.trim().is_empty()); + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry after unstaged reset"); + assert!(!entry.unstaged); + + let patch = git_status_patch_view(&worktree, &entry)?.expect("staged-only patch"); + let staged_hunk = patch + .hunks + .iter() + .find(|hunk| hunk.section == GitPatchSectionKind::Staged) + .cloned() + .expect("staged hunk"); + reset_hunk(&worktree, &entry, &staged_hunk)?; + + assert!(git_stdout(&repo, &["diff", "--cached", "--", "notes.txt"])? + .trim() + .is_empty()); + assert!(git_stdout(&repo, &["diff", "--", "notes.txt"])? + .trim() + .is_empty()); + assert_eq!( + fs::read_to_string(repo.join("notes.txt"))?, + format!("{original}\n") + ); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn latest_commit_subject_reads_head_subject() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-pr-subject-{}", Uuid::new_v4()));