diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index d7b4e6ea..646ac399 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -91,7 +91,12 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(), (_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), + (_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(), (_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(), + (_, KeyCode::Char('S')) => dashboard.stage_selected_git_status(), + (_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(), + (_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(), + (_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(), (_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(), (_, KeyCode::Char('}')) => dashboard.next_diff_hunk(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f207f0fd..008a48e4 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -82,6 +82,8 @@ pub struct Dashboard { diff_view_mode: DiffViewMode, selected_conflict_protocol: Option, selected_merge_readiness: Option, + selected_git_status_entries: Vec, + selected_git_status: usize, output_mode: OutputMode, output_filter: OutputFilter, output_time_filter: OutputTimeFilter, @@ -101,6 +103,7 @@ pub struct Dashboard { collapsed_panes: HashSet, search_input: Option, spawn_input: Option, + commit_input: Option, search_query: Option, search_scope: SearchScope, search_agent_filter: SearchAgentFilter, @@ -144,6 +147,7 @@ enum OutputMode { Timeline, WorktreeDiff, ConflictProtocol, + GitStatus, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -346,6 +350,8 @@ impl Dashboard { diff_view_mode: DiffViewMode::Split, selected_conflict_protocol: None, selected_merge_readiness: None, + selected_git_status_entries: Vec::new(), + selected_git_status: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -365,6 +371,7 @@ impl Dashboard { collapsed_panes: HashSet::new(), search_input: None, spawn_input: None, + commit_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, @@ -641,6 +648,14 @@ impl Dashboard { }); (" Conflict Protocol ".to_string(), Text::from(content)) } + OutputMode::GitStatus => { + let content = if self.selected_git_status_entries.is_empty() { + Text::from(self.empty_git_status_message()) + } else { + Text::from(self.visible_git_status_lines()) + }; + (self.output_title(), content) + } } } else { ( @@ -712,6 +727,28 @@ impl Dashboard { ); } + if self.output_mode == OutputMode::GitStatus { + let staged = self + .selected_git_status_entries + .iter() + .filter(|entry| entry.staged) + .count(); + let unstaged = self + .selected_git_status_entries + .iter() + .filter(|entry| entry.unstaged || entry.untracked) + .count(); + let total = self.selected_git_status_entries.len(); + let current = if total == 0 { + 0 + } else { + self.selected_git_status.min(total.saturating_sub(1)) + 1 + }; + return format!( + " Git status staged:{staged} unstaged:{unstaged} {current}/{total} " + ); + } + let filter = format!( "{}{}", self.output_filter.title_suffix(), @@ -757,6 +794,10 @@ impl Dashboard { } } + fn empty_git_status_message(&self) -> &'static str { + "No staged or unstaged changes for this worktree." + } + fn empty_timeline_message(&self) -> &'static str { match ( self.timeline_scope, @@ -980,7 +1021,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 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] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] 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(), @@ -989,6 +1030,8 @@ impl Dashboard { let search_prefix = if let Some(input) = self.spawn_input.as_ref() { format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |") + } else if let Some(input) = self.commit_input.as_ref() { + format!(" commit>{input}_ | [Enter] commit [Esc] cancel |") } else if let Some(input) = self.search_input.as_ref() { format!( " /{input}_ | {} | {} | [Enter] apply [Esc] cancel |", @@ -1015,6 +1058,7 @@ impl Dashboard { }; let text = if self.spawn_input.is_some() + || self.commit_input.is_some() || self.search_input.is_some() || self.search_query.is_some() || self.pane_command_mode @@ -1075,8 +1119,11 @@ impl Dashboard { " 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(), + " 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(), + " C Commit staged changes for the selected worktree".to_string(), " c Show conflict-resolution protocol for selected conflicted worktree" .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), @@ -1539,6 +1586,14 @@ impl Dashboard { self.refresh_logs(); } Pane::Output => { + if self.output_mode == OutputMode::GitStatus { + self.output_follow = false; + if self.selected_git_status + 1 < self.selected_git_status_entries.len() { + self.selected_git_status += 1; + self.sync_output_scroll(self.last_output_height.max(1)); + } + return; + } let max_scroll = self.max_output_scroll(); if self.output_follow { return; @@ -1578,6 +1633,12 @@ impl Dashboard { self.refresh_logs(); } Pane::Output => { + if self.output_mode == OutputMode::GitStatus { + self.output_follow = false; + self.selected_git_status = self.selected_git_status.saturating_sub(1); + self.sync_output_scroll(self.last_output_height.max(1)); + return; + } if self.output_follow { self.output_follow = false; self.output_scroll_offset = self.max_output_scroll(); @@ -1789,9 +1850,136 @@ impl Dashboard { self.reset_output_view(); 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()); + } } } + pub fn toggle_git_status_mode(&mut self) { + match self.output_mode { + OutputMode::GitStatus => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + _ => { + let has_worktree = self + .sessions + .get(self.selected_session) + .and_then(|session| session.worktree.as_ref()) + .is_some(); + if !has_worktree { + self.set_operator_note("selected session has no worktree".to_string()); + return; + } + + self.sync_selected_git_status(); + self.output_mode = OutputMode::GitStatus; + self.selected_pane = Pane::Output; + 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 stage_selected_git_status(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note( + "git staging controls are only available in git status view".to_string(), + ); + return; + } + + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.set_operator_note("no git status entry selected".to_string()); + return; + }; + + if let Err(error) = worktree::stage_path(&worktree, &entry.path) { + tracing::warn!("Failed to stage {}: {error}", entry.path); + self.set_operator_note(format!("stage failed for {}: {error}", entry.display_path)); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("staged {}", entry.display_path)); + } + + pub fn unstage_selected_git_status(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note( + "git staging controls are only available in git status view".to_string(), + ); + return; + } + + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.set_operator_note("no git status entry selected".to_string()); + return; + }; + + if let Err(error) = worktree::unstage_path(&worktree, &entry.path) { + tracing::warn!("Failed to unstage {}: {error}", entry.path); + self.set_operator_note(format!("unstage failed for {}: {error}", entry.display_path)); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("unstaged {}", entry.display_path)); + } + + pub fn reset_selected_git_status(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note( + "git staging controls are only available in git status view".to_string(), + ); + return; + } + + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.set_operator_note("no git status entry selected".to_string()); + return; + }; + + if let Err(error) = worktree::reset_path(&worktree, &entry) { + tracing::warn!("Failed to reset {}: {error}", entry.path); + self.set_operator_note(format!("reset failed for {}: {error}", entry.display_path)); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("reset {}", entry.display_path)); + } + + pub fn begin_commit_prompt(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note("commit prompt is only available in git status view".to_string()); + return; + } + + if self + .sessions + .get(self.selected_session) + .and_then(|session| session.worktree.as_ref()) + .is_none() + { + self.set_operator_note("selected session has no worktree".to_string()); + return; + } + + if !self.selected_git_status_entries.iter().any(|entry| entry.staged) { + self.set_operator_note("no staged changes to commit".to_string()); + return; + } + + self.commit_input = Some(String::new()); + self.set_operator_note("commit mode | type a message and press Enter".to_string()); + } + pub fn toggle_diff_view_mode(&mut self) { if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { self.set_operator_note("no active worktree diff view to toggle".to_string()); @@ -2445,7 +2633,7 @@ impl Dashboard { } pub fn is_input_mode(&self) -> bool { - self.spawn_input.is_some() || self.search_input.is_some() + self.spawn_input.is_some() || self.search_input.is_some() || self.commit_input.is_some() } pub fn has_active_search(&self) -> bool { @@ -2553,6 +2741,8 @@ impl Dashboard { input.push(ch); } else if let Some(input) = self.search_input.as_mut() { input.push(ch); + } else if let Some(input) = self.commit_input.as_mut() { + input.push(ch); } } @@ -2561,6 +2751,8 @@ impl Dashboard { input.pop(); } else if let Some(input) = self.search_input.as_mut() { input.pop(); + } else if let Some(input) = self.commit_input.as_mut() { + input.pop(); } } @@ -2569,17 +2761,56 @@ impl Dashboard { self.set_operator_note("spawn input cancelled".to_string()); } else if self.search_input.take().is_some() { self.set_operator_note("search input cancelled".to_string()); + } else if self.commit_input.take().is_some() { + self.set_operator_note("commit input cancelled".to_string()); } } pub async fn submit_input(&mut self) { if self.spawn_input.is_some() { self.submit_spawn_prompt().await; + } else if self.commit_input.is_some() { + self.submit_commit_prompt(); } else { self.submit_search(); } } + fn submit_commit_prompt(&mut self) { + let Some(input) = self.commit_input.take() else { + return; + }; + + let message = input.trim().to_string(); + let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { + self.set_operator_note("no session selected".to_string()); + return; + }; + let Some(worktree) = self + .sessions + .get(self.selected_session) + .and_then(|session| session.worktree.clone()) + else { + self.set_operator_note("selected session has no worktree".to_string()); + return; + }; + + match worktree::commit_staged(&worktree, &message) { + Ok(hash) => { + self.refresh_after_git_status_action(None); + self.set_operator_note(format!( + "committed {} as {}", + format_session_id(&session_id), + hash + )); + } + Err(error) => { + self.commit_input = Some(input); + self.set_operator_note(format!("commit failed: {error}")); + } + } + } + fn submit_search(&mut self) { let Some(input) = self.search_input.take() else { return; @@ -3017,6 +3248,7 @@ impl Dashboard { self.ensure_selected_pane_visible(); self.sync_selected_output(); self.sync_selected_diff(); + self.sync_selected_git_status(); self.sync_selected_messages(); self.sync_selected_lineage(); self.refresh_logs(); @@ -3313,6 +3545,53 @@ impl Dashboard { { self.output_mode = OutputMode::SessionOutput; } + self.sync_selected_git_status(); + } + + fn sync_selected_git_status(&mut self) { + let session = self.sessions.get(self.selected_session); + let worktree = session.and_then(|session| session.worktree.as_ref()); + self.selected_git_status_entries = worktree + .and_then(|worktree| worktree::git_status_entries(worktree).ok()) + .unwrap_or_default(); + 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() { + self.output_mode = OutputMode::SessionOutput; + } + } + + fn selected_git_status_context( + &self, + ) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> { + let session = self.sessions.get(self.selected_session)?; + let worktree = session.worktree.clone()?; + let entry = self + .selected_git_status_entries + .get(self.selected_git_status) + .cloned()?; + Some((entry, worktree)) + } + + fn refresh_after_git_status_action(&mut self, preferred_path: Option<&str>) { + self.refresh(); + self.output_mode = OutputMode::GitStatus; + self.selected_pane = Pane::Output; + self.output_follow = false; + if let Some(path) = preferred_path { + if let Some(index) = self + .selected_git_status_entries + .iter() + .position(|entry| entry.path == path) + { + self.selected_git_status = index; + } + } + self.sync_output_scroll(self.last_output_height.max(1)); } fn current_diff_hunk_offsets(&self) -> &[usize] { @@ -3625,6 +3904,42 @@ impl Dashboard { .unwrap_or_default() } + fn visible_git_status_lines(&self) -> Vec> { + self.selected_git_status_entries + .iter() + .enumerate() + .map(|(index, entry)| { + let marker = if index == self.selected_git_status { ">>" } else { "-" }; + let mut flags = Vec::new(); + if entry.conflicted { + flags.push("conflict"); + } + if entry.staged { + flags.push("staged"); + } + if entry.unstaged { + flags.push("unstaged"); + } + if entry.untracked { + flags.push("untracked"); + } + let flag_text = if flags.is_empty() { + "clean".to_string() + } else { + flags.join(",") + }; + Line::from(format!( + "{} [{}{}] [{}] {}", + marker, + entry.index_status, + entry.worktree_status, + flag_text, + entry.display_path + )) + }) + .collect() + } + fn visible_timeline_lines(&self) -> Vec> { let show_session_label = self.timeline_scope == SearchScope::AllSessions; self.timeline_events() @@ -3928,6 +4243,14 @@ impl Dashboard { fn sync_output_scroll(&mut self, viewport_height: usize) { self.last_output_height = viewport_height.max(1); + if self.output_mode == OutputMode::GitStatus { + let max_scroll = self.max_output_scroll(); + let centered = self + .selected_git_status + .saturating_sub(self.last_output_height.max(1).saturating_sub(1) / 2); + self.output_scroll_offset = centered.min(max_scroll); + return; + } let max_scroll = self.max_output_scroll(); if self.output_follow { @@ -3938,9 +4261,14 @@ impl Dashboard { } fn max_output_scroll(&self) -> usize { - self.visible_output_lines() - .len() - .saturating_sub(self.last_output_height.max(1)) + let total_lines = if self.output_mode == OutputMode::GitStatus { + self.selected_git_status_entries.len() + } else if self.output_mode == OutputMode::Timeline { + self.visible_timeline_lines().len() + } else { + self.visible_output_lines().len() + }; + total_lines.saturating_sub(self.last_output_height.max(1)) } fn sync_metrics_scroll(&mut self, viewport_height: usize) { @@ -6709,6 +7037,80 @@ mod tests { assert!(rendered.contains("+new line")); } + #[test] + fn toggle_git_status_mode_renders_selected_worktree_status() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-status-{}", Uuid::new_v4())); + init_git_repo(&root)?; + fs::write(root.join("README.md"), "hello from git status\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); + + dashboard.toggle_git_status_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::GitStatus); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected worktree git status") + ); + assert_eq!(dashboard.output_title(), " Git status staged:0 unstaged:1 1/1 "); + let rendered = dashboard.rendered_output_text(180, 20); + assert!(rendered.contains("Git status")); + assert!(rendered.contains("README.md")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn begin_commit_prompt_opens_commit_input_for_staged_entries() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.output_mode = OutputMode::GitStatus; + dashboard.selected_git_status_entries = vec![worktree::GitStatusEntry { + path: "README.md".to_string(), + display_path: "README.md".to_string(), + index_status: 'M', + worktree_status: ' ', + staged: true, + unstaged: false, + untracked: false, + conflicted: false, + }]; + + dashboard.begin_commit_prompt(); + + assert_eq!(dashboard.commit_input.as_deref(), Some("")); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("commit mode | type a message and press Enter") + ); + let rendered = render_dashboard_text(dashboard, 180, 20); + assert!(rendered.contains("commit>_")); + } + #[test] fn toggle_diff_view_mode_switches_to_unified_rendering() { let mut dashboard = test_dashboard( @@ -10489,6 +10891,8 @@ diff --git a/src/lib.rs b/src/lib.rs diff_view_mode: DiffViewMode::Split, selected_conflict_protocol: None, selected_merge_readiness: None, + selected_git_status_entries: Vec::new(), + selected_git_status: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -10507,6 +10911,7 @@ diff --git a/src/lib.rs b/src/lib.rs collapsed_panes: HashSet::new(), search_input: None, spawn_input: None, + commit_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 2d9a2d58..92bbb068 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -44,6 +44,18 @@ pub struct BranchConflictPreview { pub right_patch_preview: Option, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct GitStatusEntry { + pub path: String, + pub display_path: String, + pub index_status: char, + pub worktree_status: char, + pub staged: bool, + pub unstaged: bool, + pub untracked: bool, + pub conflicted: bool, +} + /// 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")?; @@ -222,6 +234,124 @@ pub fn diff_summary(worktree: &WorktreeInfo) -> Result> { } } +pub fn git_status_entries(worktree: &WorktreeInfo) -> Result> { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .output() + .context("Failed to load git status entries")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git status failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_git_status_entry) + .collect()) +} + +pub fn stage_path(worktree: &WorktreeInfo, path: &str) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["add", "--"]) + .arg(path) + .output() + .with_context(|| format!("Failed to stage {}", path))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git add failed for {path}: {stderr}"); + } +} + +pub fn unstage_path(worktree: &WorktreeInfo, path: &str) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["reset", "HEAD", "--"]) + .arg(path) + .output() + .with_context(|| format!("Failed to unstage {}", path))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git reset failed for {path}: {stderr}"); + } +} + +pub fn reset_path(worktree: &WorktreeInfo, entry: &GitStatusEntry) -> Result<()> { + if entry.untracked { + let target = worktree.path.join(&entry.path); + if !target.exists() { + return Ok(()); + } + let metadata = fs::symlink_metadata(&target) + .with_context(|| format!("Failed to inspect untracked path {}", target.display()))?; + if metadata.is_dir() { + fs::remove_dir_all(&target) + .with_context(|| format!("Failed to remove {}", target.display()))?; + } else { + fs::remove_file(&target) + .with_context(|| format!("Failed to remove {}", target.display()))?; + } + return Ok(()); + } + + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["restore", "--source=HEAD", "--staged", "--worktree", "--"]) + .arg(&entry.path) + .output() + .with_context(|| format!("Failed to reset {}", entry.path))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git restore failed for {}: {stderr}", entry.path); + } +} + +pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result { + let message = message.trim(); + if message.is_empty() { + anyhow::bail!("commit message cannot be empty"); + } + if !has_staged_changes(worktree)? { + anyhow::bail!("no staged changes to commit"); + } + + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["commit", "-m", message]) + .output() + .context("Failed to create commit")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git commit failed: {stderr}"); + } + + let rev_parse = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["rev-parse", "--short", "HEAD"]) + .output() + .context("Failed to resolve commit hash")?; + if !rev_parse.status.success() { + let stderr = String::from_utf8_lossy(&rev_parse.stderr); + anyhow::bail!("git rev-parse failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string()) +} + pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result> { let mut preview = Vec::new(); let base_ref = format!("{}...HEAD", worktree.base_branch); @@ -409,6 +539,10 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result { Ok(!git_status_short(&worktree.path)?.is_empty()) } +pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result { + Ok(git_status_entries(worktree)?.iter().any(|entry| entry.staged)) +} + pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { let readiness = merge_readiness(worktree)?; if readiness.status == MergeReadinessStatus::Conflicted { @@ -854,6 +988,43 @@ fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> { } } +fn parse_git_status_entry(line: &str) -> Option { + if line.len() < 4 { + return None; + } + let bytes = line.as_bytes(); + let index_status = bytes[0] as char; + let worktree_status = bytes[1] as char; + let raw_path = line.get(3..)?.trim(); + if raw_path.is_empty() { + return None; + } + let display_path = raw_path.to_string(); + let normalized_path = raw_path + .split(" -> ") + .last() + .unwrap_or(raw_path) + .trim() + .to_string(); + let conflicted = matches!( + (index_status, worktree_status), + ('U', _) + | (_, 'U') + | ('A', 'A') + | ('D', 'D') + ); + Some(GitStatusEntry { + path: normalized_path, + display_path, + index_status, + worktree_status, + staged: index_status != ' ' && index_status != '?', + unstaged: worktree_status != ' ' && worktree_status != '?', + untracked: index_status == '?' && worktree_status == '?', + conflicted, + }) +} + fn parse_nonempty_lines(stdout: &[u8]) -> Vec { String::from_utf8_lossy(stdout) .lines() @@ -1331,6 +1502,68 @@ mod tests { Ok(()) } + #[test] + fn git_status_helpers_stage_unstage_reset_and_commit() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-status-helpers-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }; + + fs::write(repo.join("README.md"), "hello updated\n")?; + fs::write(repo.join("notes.txt"), "draft\n")?; + + let mut entries = git_status_entries(&worktree)?; + let readme = entries + .iter() + .find(|entry| entry.path == "README.md") + .expect("tracked README entry"); + assert!(readme.unstaged); + let notes = entries + .iter() + .find(|entry| entry.path == "notes.txt") + .expect("untracked notes entry"); + assert!(notes.untracked); + + stage_path(&worktree, "notes.txt")?; + entries = git_status_entries(&worktree)?; + let notes = entries + .iter() + .find(|entry| entry.path == "notes.txt") + .expect("staged notes entry"); + assert!(notes.staged); + assert!(!notes.untracked); + + unstage_path(&worktree, "notes.txt")?; + entries = git_status_entries(&worktree)?; + let notes = entries + .iter() + .find(|entry| entry.path == "notes.txt") + .expect("restored notes entry"); + assert!(notes.untracked); + + let notes_entry = notes.clone(); + reset_path(&worktree, ¬es_entry)?; + assert!(!repo.join("notes.txt").exists()); + + stage_path(&worktree, "README.md")?; + let hash = commit_staged(&worktree, "update readme")?; + assert!(!hash.is_empty()); + assert!(git_status_entries(&worktree)?.is_empty()); + + let output = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["log", "-1", "--pretty=%s"]) + .output()?; + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "update readme"); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn create_for_session_links_shared_node_modules_cache() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4()));