mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-11 02:20:29 +08:00
feat: add ecc2 hunk-level git patch actions
This commit is contained in:
parent
599a9d1e7b
commit
8936d09951
@ -110,6 +110,10 @@ pub struct Dashboard {
|
|||||||
selected_merge_readiness: Option<worktree::MergeReadiness>,
|
selected_merge_readiness: Option<worktree::MergeReadiness>,
|
||||||
selected_git_status_entries: Vec<worktree::GitStatusEntry>,
|
selected_git_status_entries: Vec<worktree::GitStatusEntry>,
|
||||||
selected_git_status: usize,
|
selected_git_status: usize,
|
||||||
|
selected_git_patch: Option<worktree::GitStatusPatchView>,
|
||||||
|
selected_git_patch_hunk_offsets_unified: Vec<usize>,
|
||||||
|
selected_git_patch_hunk_offsets_split: Vec<usize>,
|
||||||
|
selected_git_patch_hunk: usize,
|
||||||
output_mode: OutputMode,
|
output_mode: OutputMode,
|
||||||
output_filter: OutputFilter,
|
output_filter: OutputFilter,
|
||||||
output_time_filter: OutputTimeFilter,
|
output_time_filter: OutputTimeFilter,
|
||||||
@ -179,6 +183,7 @@ enum OutputMode {
|
|||||||
WorktreeDiff,
|
WorktreeDiff,
|
||||||
ConflictProtocol,
|
ConflictProtocol,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
GitPatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@ -498,6 +503,10 @@ impl Dashboard {
|
|||||||
selected_merge_readiness: None,
|
selected_merge_readiness: None,
|
||||||
selected_git_status_entries: Vec::new(),
|
selected_git_status_entries: Vec::new(),
|
||||||
selected_git_status: 0,
|
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_mode: OutputMode::SessionOutput,
|
||||||
output_filter: OutputFilter::All,
|
output_filter: OutputFilter::All,
|
||||||
output_time_filter: OutputTimeFilter::AllTime,
|
output_time_filter: OutputTimeFilter::AllTime,
|
||||||
@ -743,8 +752,11 @@ impl Dashboard {
|
|||||||
self.sync_output_scroll(area.height.saturating_sub(2) as usize);
|
self.sync_output_scroll(area.height.saturating_sub(2) as usize);
|
||||||
|
|
||||||
if self.sessions.get(self.selected_session).is_some()
|
if self.sessions.get(self.selected_session).is_some()
|
||||||
&& self.output_mode == OutputMode::WorktreeDiff
|
&& matches!(
|
||||||
&& self.selected_diff_patch.is_some()
|
self.output_mode,
|
||||||
|
OutputMode::WorktreeDiff | OutputMode::GitPatch
|
||||||
|
)
|
||||||
|
&& self.active_patch_text().is_some()
|
||||||
&& self.diff_view_mode == DiffViewMode::Split
|
&& self.diff_view_mode == DiffViewMode::Split
|
||||||
{
|
{
|
||||||
self.render_split_diff_output(frame, area);
|
self.render_split_diff_output(frame, area);
|
||||||
@ -798,6 +810,16 @@ impl Dashboard {
|
|||||||
};
|
};
|
||||||
(self.output_title(), content)
|
(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 => {
|
OutputMode::ConflictProtocol => {
|
||||||
let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| {
|
let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| {
|
||||||
"No conflicted worktree available for the selected session.".to_string()
|
"No conflicted worktree available for the selected session.".to_string()
|
||||||
@ -843,7 +865,7 @@ impl Dashboard {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(patch) = self.selected_diff_patch.as_ref() else {
|
let Some(patch) = self.active_patch_text() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let columns = build_worktree_diff_columns(patch, self.theme_palette());
|
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 {
|
if self.output_mode == OutputMode::GitStatus {
|
||||||
let staged = self
|
let staged = self
|
||||||
.selected_git_status_entries
|
.selected_git_status_entries
|
||||||
@ -1175,7 +1211,7 @@ impl Dashboard {
|
|||||||
|
|
||||||
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
||||||
let base_text = format!(
|
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_focus_shortcuts_label(),
|
||||||
self.pane_move_shortcuts_label(),
|
self.pane_move_shortcuts_label(),
|
||||||
self.layout_label(),
|
self.layout_label(),
|
||||||
@ -1303,11 +1339,12 @@ impl Dashboard {
|
|||||||
" H Restore all collapsed panes".to_string(),
|
" H Restore all collapsed panes".to_string(),
|
||||||
" y Toggle selected-session timeline view".to_string(),
|
" y Toggle selected-session timeline view".to_string(),
|
||||||
" E Cycle timeline event filter".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(),
|
" z Toggle selected worktree git status in output pane".to_string(),
|
||||||
" V Toggle diff view mode between split and unified".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(),
|
" {/} 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(),
|
" C Commit staged changes for the selected worktree".to_string(),
|
||||||
" P Create a draft PR from the selected worktree branch".to_string(),
|
" P Create a draft PR from the selected worktree branch".to_string(),
|
||||||
" c Show conflict-resolution protocol for selected conflicted worktree"
|
" c Show conflict-resolution protocol for selected conflicted worktree"
|
||||||
@ -2037,16 +2074,31 @@ impl Dashboard {
|
|||||||
self.set_operator_note("showing session output".to_string());
|
self.set_operator_note("showing session output".to_string());
|
||||||
}
|
}
|
||||||
OutputMode::GitStatus => {
|
OutputMode::GitStatus => {
|
||||||
self.output_mode = OutputMode::SessionOutput;
|
self.sync_selected_git_patch();
|
||||||
self.reset_output_view();
|
if self.selected_git_patch.is_some() {
|
||||||
self.set_operator_note("showing session output".to_string());
|
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) {
|
pub fn toggle_git_status_mode(&mut self) {
|
||||||
match self.output_mode {
|
match self.output_mode {
|
||||||
OutputMode::GitStatus => {
|
OutputMode::GitStatus | OutputMode::GitPatch => {
|
||||||
self.output_mode = OutputMode::SessionOutput;
|
self.output_mode = OutputMode::SessionOutput;
|
||||||
self.reset_output_view();
|
self.reset_output_view();
|
||||||
self.set_operator_note("showing session output".to_string());
|
self.set_operator_note("showing session output".to_string());
|
||||||
@ -2073,6 +2125,11 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stage_selected_git_status(&mut self) {
|
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 {
|
if self.output_mode != OutputMode::GitStatus {
|
||||||
self.set_operator_note(
|
self.set_operator_note(
|
||||||
"git staging controls are only available in git status view".to_string(),
|
"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) {
|
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 {
|
if self.output_mode != OutputMode::GitStatus {
|
||||||
self.set_operator_note(
|
self.set_operator_note(
|
||||||
"git staging controls are only available in git status view".to_string(),
|
"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) {
|
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 {
|
if self.output_mode != OutputMode::GitStatus {
|
||||||
self.set_operator_note(
|
self.set_operator_note(
|
||||||
"git staging controls are only available in git status view".to_string(),
|
"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) {
|
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(
|
self.set_operator_note(
|
||||||
"commit prompt is only available in git status view".to_string(),
|
"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());
|
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) {
|
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());
|
self.set_operator_note("no active worktree diff view to toggle".to_string());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2223,7 +2354,11 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn move_diff_hunk(&mut self, delta: isize) {
|
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());
|
self.set_operator_note("no active worktree diff to navigate".to_string());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2236,12 +2371,14 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let len = offsets.len();
|
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])
|
(len, offsets[next])
|
||||||
};
|
};
|
||||||
|
|
||||||
let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize;
|
let next =
|
||||||
self.selected_diff_hunk = 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_follow = false;
|
||||||
self.output_scroll_offset = next_offset;
|
self.output_scroll_offset = next_offset;
|
||||||
self.set_operator_note(format!("diff hunk {}/{}", next + 1, len));
|
self.set_operator_note(format!("diff hunk {}/{}", next + 1, len));
|
||||||
@ -4136,6 +4273,7 @@ impl Dashboard {
|
|||||||
self.output_mode = OutputMode::SessionOutput;
|
self.output_mode = OutputMode::SessionOutput;
|
||||||
}
|
}
|
||||||
self.sync_selected_git_status();
|
self.sync_selected_git_status();
|
||||||
|
self.sync_selected_git_patch();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_selected_git_status(&mut self) {
|
fn sync_selected_git_status(&mut self) {
|
||||||
@ -4147,11 +4285,50 @@ impl Dashboard {
|
|||||||
if self.selected_git_status >= self.selected_git_status_entries.len() {
|
if self.selected_git_status >= self.selected_git_status_entries.len() {
|
||||||
self.selected_git_status = self.selected_git_status_entries.len().saturating_sub(1);
|
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;
|
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(
|
fn selected_git_status_context(
|
||||||
&self,
|
&self,
|
||||||
) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> {
|
) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> {
|
||||||
@ -4164,9 +4341,24 @@ impl Dashboard {
|
|||||||
Some((entry, worktree))
|
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>) {
|
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.refresh();
|
||||||
self.output_mode = OutputMode::GitStatus;
|
|
||||||
self.selected_pane = Pane::Output;
|
self.selected_pane = Pane::Output;
|
||||||
self.output_follow = false;
|
self.output_follow = false;
|
||||||
if let Some(path) = preferred_path {
|
if let Some(path) = preferred_path {
|
||||||
@ -4178,19 +4370,56 @@ impl Dashboard {
|
|||||||
self.selected_git_status = index;
|
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));
|
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] {
|
fn current_diff_hunk_offsets(&self) -> &[usize] {
|
||||||
match self.diff_view_mode {
|
match self.output_mode {
|
||||||
DiffViewMode::Split => &self.selected_diff_hunk_offsets_split,
|
OutputMode::GitPatch => match self.diff_view_mode {
|
||||||
DiffViewMode::Unified => &self.selected_diff_hunk_offsets_unified,
|
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 {
|
fn current_diff_hunk_offset(&self) -> usize {
|
||||||
self.current_diff_hunk_offsets()
|
self.current_diff_hunk_offsets()
|
||||||
.get(self.selected_diff_hunk)
|
.get(self.current_diff_hunk_index())
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
@ -4200,7 +4429,7 @@ impl Dashboard {
|
|||||||
if total == 0 {
|
if total == 0 {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} 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 {
|
fn max_output_scroll(&self) -> usize {
|
||||||
let total_lines = if self.output_mode == OutputMode::GitStatus {
|
let total_lines = if self.output_mode == OutputMode::GitStatus {
|
||||||
self.selected_git_status_entries.len()
|
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 {
|
} else if self.output_mode == OutputMode::Timeline {
|
||||||
self.visible_timeline_lines().len()
|
self.visible_timeline_lines().len()
|
||||||
} else {
|
} else {
|
||||||
@ -8076,6 +8312,111 @@ mod tests {
|
|||||||
Ok(())
|
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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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]
|
#[test]
|
||||||
fn begin_commit_prompt_opens_commit_input_for_staged_entries() {
|
fn begin_commit_prompt_opens_commit_input_for_staged_entries() {
|
||||||
let mut dashboard = test_dashboard(
|
let mut dashboard = test_dashboard(
|
||||||
@ -12078,6 +12419,10 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
selected_merge_readiness: None,
|
selected_merge_readiness: None,
|
||||||
selected_git_status_entries: Vec::new(),
|
selected_git_status_entries: Vec::new(),
|
||||||
selected_git_status: 0,
|
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_mode: OutputMode::SessionOutput,
|
||||||
output_filter: OutputFilter::All,
|
output_filter: OutputFilter::All,
|
||||||
output_time_filter: OutputTimeFilter::AllTime,
|
output_time_filter: OutputTimeFilter::AllTime,
|
||||||
@ -12169,6 +12514,18 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_stdout(path: &Path, args: &[&str]) -> Result<String> {
|
||||||
|
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(
|
fn sample_session(
|
||||||
id: &str,
|
id: &str,
|
||||||
agent_type: &str,
|
agent_type: &str,
|
||||||
|
|||||||
@ -2,8 +2,9 @@ use anyhow::{Context, Result};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::session::WorktreeInfo;
|
use crate::session::WorktreeInfo;
|
||||||
@ -63,6 +64,27 @@ pub struct GitStatusEntry {
|
|||||||
pub conflicted: bool,
|
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<GitPatchHunk>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new git worktree for an agent session.
|
/// Create a new git worktree for an agent session.
|
||||||
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
|
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
|
||||||
let repo_root = std::env::current_dir().context("Failed to resolve repository root")?;
|
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<Option<GitStatusPatchView>> {
|
||||||
|
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<String> {
|
pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result<String> {
|
||||||
let message = message.trim();
|
let message = message.trim();
|
||||||
if message.is_empty() {
|
if message.is_empty() {
|
||||||
@ -887,6 +1007,39 @@ fn git_diff_patch_lines(worktree_path: &Path, extra_args: &[&str]) -> Result<Vec
|
|||||||
Ok(parse_nonempty_lines(&output.stdout))
|
Ok(parse_nonempty_lines(&output.stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_diff_patch_text_for_paths(
|
||||||
|
worktree_path: &Path,
|
||||||
|
extra_args: &[&str],
|
||||||
|
paths: &[String],
|
||||||
|
) -> Result<String> {
|
||||||
|
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(
|
fn git_diff_patch_lines_for_paths(
|
||||||
worktree_path: &Path,
|
worktree_path: &Path,
|
||||||
extra_args: &[&str],
|
extra_args: &[&str],
|
||||||
@ -924,6 +1077,86 @@ fn git_diff_patch_lines_for_paths(
|
|||||||
Ok(parse_nonempty_lines(&output.stdout))
|
Ok(parse_nonempty_lines(&output.stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_patch_hunks(section: GitPatchSectionKind, patch_text: &str) -> Vec<GitPatchHunk> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct SharedDependencyStrategy {
|
struct SharedDependencyStrategy {
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
@ -1364,6 +1597,18 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_stdout(repo: &Path, args: &[&str]) -> Result<String> {
|
||||||
|
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<PathBuf> {
|
fn init_repo(root: &Path) -> Result<PathBuf> {
|
||||||
let repo = root.join("repo");
|
let repo = root.join("repo");
|
||||||
fs::create_dir_all(&repo)?;
|
fs::create_dir_all(&repo)?;
|
||||||
@ -1917,6 +2162,163 @@ mod tests {
|
|||||||
Ok(())
|
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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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]
|
#[test]
|
||||||
fn latest_commit_subject_reads_head_subject() -> Result<()> {
|
fn latest_commit_subject_reads_head_subject() -> Result<()> {
|
||||||
let root = std::env::temp_dir().join(format!("ecc2-pr-subject-{}", Uuid::new_v4()));
|
let root = std::env::temp_dir().join(format!("ecc2-pr-subject-{}", Uuid::new_v4()));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user