1use crate::cli::output::Output;
2use crate::errors::{CascadeError, Result};
3use crate::git::{find_repository_root, GitRepository};
4use crate::stack::{StackEntry, StackManager};
5use clap::Subcommand;
6use crossterm::{
7 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
8 execute,
9 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use dialoguer::{theme::ColorfulTheme, Confirm};
12use ratatui::{
13 backend::CrosstermBackend,
14 layout::{Alignment, Constraint, Direction, Layout},
15 style::{Color, Modifier, Style},
16 text::{Line, Span},
17 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
18 Terminal,
19};
20use std::env;
21use std::io;
22use std::path::Path;
23use tracing::debug;
24
25#[derive(Debug, Subcommand)]
26pub enum EntryAction {
27 Checkout {
29 entry: Option<usize>,
31 #[arg(long)]
33 direct: bool,
34 #[arg(long, short)]
36 yes: bool,
37 },
38 Status {
40 #[arg(long)]
42 quiet: bool,
43 },
44 List {
46 #[arg(long, short)]
48 verbose: bool,
49 },
50 Clear {
52 #[arg(long, short)]
54 yes: bool,
55 },
56 Amend {
61 #[arg(long, short)]
63 message: Option<String>,
64 #[arg(long, short)]
66 all: bool,
67 #[arg(long)]
69 push: bool,
70 },
71 Continue,
75 Abort,
79}
80
81pub async fn run(action: EntryAction) -> Result<()> {
82 let _current_dir = env::current_dir()
83 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
84
85 match action {
86 EntryAction::Checkout { entry, direct, yes } => checkout_entry(entry, direct, yes).await,
87 EntryAction::Status { quiet } => show_edit_status(quiet).await,
88 EntryAction::List { verbose } => list_entries(verbose).await,
89 EntryAction::Clear { yes } => clear_edit_mode(yes).await,
90 EntryAction::Amend { message, all, push } => amend_entry(message, all, push).await,
91 EntryAction::Continue => continue_restack().await,
92 EntryAction::Abort => abort_restack().await,
93 }
94}
95
96async fn checkout_entry(
98 entry_num: Option<usize>,
99 direct: bool,
100 skip_confirmation: bool,
101) -> Result<()> {
102 let current_dir = env::current_dir()
103 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
104
105 let repo_root = find_repository_root(¤t_dir)
106 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
107
108 let mut manager = StackManager::new(&repo_root)?;
109
110 let active_stack = manager.get_active_stack().ok_or_else(|| {
112 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
113 })?;
114
115 if active_stack.entries.is_empty() {
116 return Err(CascadeError::config(
117 "Stack is empty. Push some commits first with 'ca stack push'",
118 ));
119 }
120
121 let target_entry_num = if let Some(num) = entry_num {
123 if num == 0 || num > active_stack.entries.len() {
124 return Err(CascadeError::config(format!(
125 "Invalid entry number: {}. Stack has {} entries",
126 num,
127 active_stack.entries.len()
128 )));
129 }
130 num
131 } else if direct {
132 return Err(CascadeError::config(
133 "Entry number required when using --direct flag",
134 ));
135 } else {
136 show_entry_picker(active_stack).await?
138 };
139
140 let target_entry = &active_stack.entries[target_entry_num - 1]; let stack_id = active_stack.id;
144 let entry_id = target_entry.id;
145 let entry_branch = target_entry.branch.clone();
146 let entry_short_hash = target_entry.short_hash();
147 let entry_short_message = target_entry.short_message(50);
148 let entry_pr_id = target_entry.pull_request_id.clone();
149 let entry_message = target_entry.message.clone();
150
151 let already_in_edit_mode = manager.is_in_edit_mode();
153 let edit_mode_display = if already_in_edit_mode {
154 let edit_info = manager.get_edit_mode_info().unwrap();
155
156 let commit_message = if let Some(target_entry_id) = &edit_info.target_entry_id {
158 if let Some(entry) = active_stack
159 .entries
160 .iter()
161 .find(|e| e.id == *target_entry_id)
162 {
163 entry.short_message(50)
164 } else {
165 "Unknown entry".to_string()
166 }
167 } else {
168 "Unknown target".to_string()
169 };
170
171 Some((edit_info.original_commit_hash.clone(), commit_message))
172 } else {
173 None
174 };
175
176 let _ = active_stack;
178
179 if let Some((commit_hash, commit_message)) = edit_mode_display {
181 tracing::debug!("Already in edit mode for entry in stack");
182
183 if !skip_confirmation {
184 Output::warning("Already in edit mode!");
185 Output::sub_item(format!(
186 "Current target: {} ({})",
187 &commit_hash[..8],
188 commit_message
189 ));
190
191 let should_exit_edit_mode = Confirm::with_theme(&ColorfulTheme::default())
193 .with_prompt("Exit current edit mode and start a new one?")
194 .default(false)
195 .interact()
196 .map_err(|e| {
197 CascadeError::config(format!("Failed to get user confirmation: {e}"))
198 })?;
199
200 if !should_exit_edit_mode {
201 return Err(CascadeError::config(
202 "Operation cancelled. Use 'ca entry status' to see current edit mode details.",
203 ));
204 }
205
206 Output::info("Exiting current edit mode...");
208 manager.exit_edit_mode()?;
209 Output::success("✓ Exited previous edit mode");
210 }
211 }
212
213 if !skip_confirmation {
215 Output::section("Checking out entry for editing");
216 Output::sub_item(format!(
217 "Entry #{target_entry_num}: {entry_short_hash} ({entry_short_message})"
218 ));
219 Output::sub_item(format!("Branch: {entry_branch}"));
220 if let Some(pr_id) = &entry_pr_id {
221 Output::sub_item(format!("PR: #{pr_id}"));
222 }
223
224 Output::sub_item("Commit Message:");
226 let lines: Vec<&str> = entry_message.lines().collect();
227 for line in lines {
228 Output::sub_item(format!(" {line}"));
229 }
230
231 Output::warning("This will checkout the commit and enter edit mode.");
232 Output::info("Any changes you make can be amended to this commit or create new entries.");
233
234 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
236 .with_prompt("Continue with checkout?")
237 .default(false)
238 .interact()
239 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
240
241 if !should_continue {
242 return Err(CascadeError::config("Entry checkout cancelled"));
243 }
244 }
245
246 manager.enter_edit_mode(stack_id, entry_id)?;
248
249 let current_dir = env::current_dir()
251 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
252
253 let repo_root = find_repository_root(¤t_dir)
254 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
255 let repo = crate::git::GitRepository::open(&repo_root)?;
256
257 debug!("Checking out branch: {}", entry_branch);
258 repo.checkout_branch(&entry_branch)?;
259
260 Output::success(format!("Entered edit mode for entry #{target_entry_num}"));
261 Output::sub_item(format!(
262 "You are now on commit: {} ({})",
263 entry_short_hash, entry_short_message
264 ));
265 Output::sub_item(format!("Branch: {entry_branch}"));
266
267 Output::section("Make your changes and commit normally");
268 Output::bullet("Use 'ca entry status' to see edit mode info");
269 Output::bullet("When you commit, the pre-commit hook will guide you");
270
271 let hooks_dir = repo_root.join(".git/hooks");
273 let hook_path = hooks_dir.join("prepare-commit-msg");
274 if !hook_path.exists() {
275 Output::tip("Install the prepare-commit-msg hook for better guidance:");
276 Output::sub_item("ca hooks add prepare-commit-msg");
277 }
278
279 Ok(())
280}
281
282async fn show_entry_picker(stack: &crate::stack::Stack) -> Result<usize> {
284 enable_raw_mode()?;
286 let mut stdout = io::stdout();
287 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
288 let backend = CrosstermBackend::new(stdout);
289 let mut terminal = Terminal::new(backend)?;
290
291 let mut list_state = ListState::default();
292 list_state.select(Some(0));
293
294 let result = loop {
295 terminal.draw(|f| {
296 let size = f.area();
297
298 let chunks = Layout::default()
300 .direction(Direction::Vertical)
301 .margin(2)
302 .constraints(
303 [
304 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ]
308 .as_ref(),
309 )
310 .split(size);
311
312 let title = Paragraph::new(format!("📚 Select Entry from Stack: {}", stack.name))
314 .style(
315 Style::default()
316 .fg(Color::Cyan)
317 .add_modifier(Modifier::BOLD),
318 )
319 .alignment(Alignment::Center)
320 .block(Block::default().borders(Borders::ALL));
321 f.render_widget(title, chunks[0]);
322
323 let items: Vec<ListItem> = stack
325 .entries
326 .iter()
327 .enumerate()
328 .map(|(i, entry)| {
329 let status_icon = if entry.is_submitted {
330 if entry.pull_request_id.is_some() {
331 "📤"
332 } else {
333 "📝"
334 }
335 } else {
336 "🔄"
337 };
338
339 let pr_text = if let Some(pr_id) = &entry.pull_request_id {
340 format!(" PR: #{pr_id}")
341 } else {
342 "".to_string()
343 };
344
345 let line = Line::from(vec![
346 Span::raw(format!(" {}. ", i + 1)),
347 Span::raw(status_icon),
348 Span::raw(" "),
349 Span::styled(entry.short_message(40), Style::default().fg(Color::White)),
350 Span::raw(" "),
351 Span::styled(
352 format!("({})", entry.short_hash()),
353 Style::default().fg(Color::Yellow),
354 ),
355 Span::styled(pr_text, Style::default().fg(Color::Green)),
356 ]);
357
358 ListItem::new(line)
359 })
360 .collect();
361
362 let list = List::new(items)
363 .block(Block::default().borders(Borders::ALL).title("Entries"))
364 .highlight_style(Style::default().fg(Color::Black).bg(Color::Cyan))
365 .highlight_symbol("→ ");
366
367 f.render_stateful_widget(list, chunks[1], &mut list_state);
368
369 let help = Paragraph::new("↑/↓: Navigate • Enter: Select • q: Quit • r: Refresh")
371 .style(Style::default().fg(Color::DarkGray))
372 .alignment(Alignment::Center)
373 .block(Block::default().borders(Borders::ALL));
374 f.render_widget(help, chunks[2]);
375 })?;
376
377 if let Event::Key(key) = event::read()? {
379 if key.kind == KeyEventKind::Press {
380 match key.code {
381 KeyCode::Char('q') => {
382 break Err(CascadeError::config("Entry selection cancelled"));
383 }
384 KeyCode::Up => {
385 let selected = list_state.selected().unwrap_or(0);
386 if selected > 0 {
387 list_state.select(Some(selected - 1));
388 } else {
389 list_state.select(Some(stack.entries.len() - 1));
390 }
391 }
392 KeyCode::Down => {
393 let selected = list_state.selected().unwrap_or(0);
394 if selected < stack.entries.len() - 1 {
395 list_state.select(Some(selected + 1));
396 } else {
397 list_state.select(Some(0));
398 }
399 }
400 KeyCode::Enter => {
401 let selected = list_state.selected().unwrap_or(0);
402 break Ok(selected + 1); }
404 KeyCode::Char('r') => {
405 continue;
407 }
408 _ => {}
409 }
410 }
411 }
412 };
413
414 disable_raw_mode()?;
416 execute!(
417 terminal.backend_mut(),
418 LeaveAlternateScreen,
419 DisableMouseCapture
420 )?;
421 terminal.show_cursor()?;
422
423 result
424}
425
426async fn show_edit_status(quiet: bool) -> Result<()> {
428 let current_dir = env::current_dir()
429 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
430
431 let repo_root = find_repository_root(¤t_dir)
432 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
433 let manager = StackManager::new(&repo_root)?;
434
435 if !manager.is_in_edit_mode() {
436 if quiet {
437 println!("inactive");
438 } else {
439 Output::info("Not in edit mode");
440 Output::sub_item("Use 'ca entry checkout' to start editing a stack entry");
441 }
442 return Ok(());
443 }
444
445 let edit_info = manager.get_edit_mode_info().unwrap();
446
447 if quiet {
448 println!("active:{:?}", edit_info.target_entry_id);
449 return Ok(());
450 }
451
452 Output::section("Currently in edit mode");
453
454 if let Some(active_stack) = manager.get_active_stack() {
456 if let Some(target_entry_id) = edit_info.target_entry_id {
457 if let Some(entry) = active_stack
458 .entries
459 .iter()
460 .find(|e| e.id == target_entry_id)
461 {
462 Output::sub_item(format!(
463 "Target entry: {} ({})",
464 entry.short_hash(),
465 entry.short_message(50)
466 ));
467 Output::sub_item(format!("Branch: {}", entry.branch));
468
469 Output::sub_item("Commit Message:");
471 let lines: Vec<&str> = entry.message.lines().collect();
472 for line in lines {
473 Output::sub_item(format!(" {line}"));
474 }
475 } else {
476 Output::sub_item(format!("Target entry: {target_entry_id:?} (not found)"));
477 }
478 } else {
479 Output::sub_item("Target entry: Unknown");
480 }
481 } else {
482 Output::sub_item(format!("Target entry: {:?}", edit_info.target_entry_id));
483 }
484
485 Output::sub_item(format!(
486 "Original commit: {}",
487 &edit_info.original_commit_hash[..8]
488 ));
489 Output::sub_item(format!(
490 "Started: {}",
491 edit_info.started_at.format("%Y-%m-%d %H:%M:%S")
492 ));
493
494 Output::section("Current state");
496
497 let current_dir = env::current_dir()
499 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
500 let repo_root = find_repository_root(¤t_dir)
501 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
502 let repo = crate::git::GitRepository::open(&repo_root)?;
503
504 let current_head = repo.get_current_commit_hash()?;
506 if current_head != edit_info.original_commit_hash {
507 let current_short = ¤t_head[..8];
508 let original_short = &edit_info.original_commit_hash[..8];
509 Output::sub_item(format!("HEAD moved: {original_short} → {current_short}"));
510
511 match repo.get_commit_count_between(&edit_info.original_commit_hash, ¤t_head) {
513 Ok(count) if count > 0 => {
514 Output::sub_item(format!(" {count} new commit(s) created"));
515 }
516 _ => {}
517 }
518 } else {
519 Output::sub_item(format!("HEAD: {} (unchanged)", ¤t_head[..8]));
520 }
521
522 match repo.get_status_summary() {
524 Ok(status) => {
525 if status.is_clean() {
526 Output::sub_item("Working directory: clean");
527 } else {
528 if status.has_staged_changes() {
529 Output::sub_item(format!("Staged changes: {} files", status.staged_count()));
530 }
531 if status.has_unstaged_changes() {
532 Output::sub_item(format!(
533 "Unstaged changes: {} files",
534 status.unstaged_count()
535 ));
536 }
537 if status.has_untracked_files() {
538 Output::sub_item(format!(
539 "Untracked files: {} files",
540 status.untracked_count()
541 ));
542 }
543 }
544 }
545 Err(_) => {
546 Output::sub_item("Working directory: status unavailable");
547 }
548 }
549
550 Output::tip("Use 'git status' for detailed file-level status");
551 Output::sub_item("Use 'ca entry list' to see all entries");
552
553 Ok(())
554}
555
556async fn list_entries(verbose: bool) -> Result<()> {
558 let current_dir = env::current_dir()
559 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
560
561 let repo_root = find_repository_root(¤t_dir)
562 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
563 let manager = StackManager::new(&repo_root)?;
564
565 let active_stack = manager.get_active_stack().ok_or_else(|| {
566 CascadeError::config(
567 "No active stack. Create a stack first with 'ca stack create'".to_string(),
568 )
569 })?;
570
571 if active_stack.entries.is_empty() {
572 Output::info(format!(
573 "Active stack '{}' has no entries yet",
574 active_stack.name
575 ));
576 Output::sub_item("Add some commits to the stack with 'ca stack push'");
577 return Ok(());
578 }
579
580 Output::section(format!(
581 "Stack: {} ({} entries)",
582 active_stack.name,
583 active_stack.entries.len()
584 ));
585
586 let edit_mode_info = manager.get_edit_mode_info();
587 let edit_target_entry_id = edit_mode_info
588 .as_ref()
589 .and_then(|info| info.target_entry_id);
590
591 for (i, entry) in active_stack.entries.iter().enumerate() {
592 let entry_num = i + 1;
593 let status_label = Output::entry_status(entry.is_submitted, entry.is_merged);
594 let mut entry_line = format!(
595 "{} {} ({})",
596 status_label,
597 entry.short_message(50),
598 entry.short_hash()
599 );
600
601 if let Some(pr_id) = &entry.pull_request_id {
602 entry_line.push_str(&format!(" PR: #{pr_id}"));
603 }
604
605 if Some(entry.id) == edit_target_entry_id {
606 entry_line.push_str(" [edit target]");
607 }
608
609 Output::numbered_item(entry_num, entry_line);
610
611 if verbose {
612 Output::sub_item(format!("Branch: {}", entry.branch));
613 Output::sub_item(format!("Commit: {}", entry.commit_hash));
614 Output::sub_item(format!(
615 "Created: {}",
616 entry.created_at.format("%Y-%m-%d %H:%M:%S")
617 ));
618
619 if entry.is_merged {
620 Output::sub_item("Status: Merged");
621 } else if entry.is_submitted {
622 Output::sub_item("Status: Submitted");
623 } else {
624 Output::sub_item("Status: Draft");
625 }
626
627 Output::sub_item("Message:");
628 for line in entry.message.lines() {
629 Output::sub_item(format!(" {line}"));
630 }
631
632 if Some(entry.id) == edit_target_entry_id {
633 Output::sub_item("Edit mode target");
634
635 match crate::git::GitRepository::open(&repo_root) {
636 Ok(repo) => match repo.get_status_summary() {
637 Ok(status) => {
638 if !status.is_clean() {
639 Output::sub_item("Git Status:");
640 if status.has_staged_changes() {
641 Output::sub_item(format!(
642 " Staged: {} files",
643 status.staged_count()
644 ));
645 }
646 if status.has_unstaged_changes() {
647 Output::sub_item(format!(
648 " Unstaged: {} files",
649 status.unstaged_count()
650 ));
651 }
652 if status.has_untracked_files() {
653 Output::sub_item(format!(
654 " Untracked: {} files",
655 status.untracked_count()
656 ));
657 }
658 } else {
659 Output::sub_item("Git Status: clean");
660 }
661 }
662 Err(_) => {
663 Output::sub_item("Git Status: unavailable");
664 }
665 },
666 Err(_) => {
667 Output::sub_item("Git Status: unavailable");
668 }
669 }
670 }
671 }
672 }
673
674 if edit_mode_info.is_some() {
675 Output::spacing();
676 Output::info("Edit mode active - use 'ca entry status' for details");
677 } else {
678 Output::spacing();
679 Output::tip("Use 'ca entry checkout' to start editing an entry");
680 }
681
682 Ok(())
683}
684
685async fn clear_edit_mode(skip_confirmation: bool) -> Result<()> {
687 let current_dir = env::current_dir()
688 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
689
690 let repo_root = find_repository_root(¤t_dir)
691 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
692
693 let mut manager = StackManager::new(&repo_root)?;
694
695 if !manager.is_in_edit_mode() {
696 Output::info("Not currently in edit mode");
697 return Ok(());
698 }
699
700 if let Some(edit_info) = manager.get_edit_mode_info() {
702 Output::section("Current edit mode state");
703
704 if let Some(target_entry_id) = &edit_info.target_entry_id {
705 Output::sub_item(format!("Target entry: {}", target_entry_id));
706
707 if let Some(active_stack) = manager.get_active_stack() {
709 if let Some(entry) = active_stack
710 .entries
711 .iter()
712 .find(|e| e.id == *target_entry_id)
713 {
714 Output::sub_item(format!("Entry: {}", entry.short_message(50)));
715 } else {
716 Output::warning("Target entry not found in stack (corrupted state)");
717 }
718 }
719 }
720
721 Output::sub_item(format!(
722 "Original commit: {}",
723 &edit_info.original_commit_hash[..8]
724 ));
725 Output::sub_item(format!(
726 "Started: {}",
727 edit_info.started_at.format("%Y-%m-%d %H:%M:%S")
728 ));
729 }
730
731 if !skip_confirmation {
733 println!();
734 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
735 .with_prompt("Clear edit mode state?")
736 .default(true)
737 .interact()
738 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
739
740 if !confirmed {
741 return Err(CascadeError::config("Operation cancelled."));
742 }
743 }
744
745 manager.exit_edit_mode()?;
747
748 Output::success("Edit mode cleared");
749 Output::tip("Use 'ca entry checkout' to start a new edit session");
750
751 Ok(())
752}
753
754async fn amend_entry(message: Option<String>, _all: bool, push: bool) -> Result<()> {
756 let current_dir = env::current_dir()
757 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
758
759 let repo_root = find_repository_root(¤t_dir)
760 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
761
762 let mut manager = StackManager::new(&repo_root)?;
763 let repo = crate::git::GitRepository::open(&repo_root)?;
764
765 let current_branch = repo.get_current_branch()?;
766
767 let (stack_id, entry_index, entry_id, entry_branch, working_branch, has_dependents, has_pr) = {
769 let active_stack = manager.get_active_stack().ok_or_else(|| {
770 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
771 })?;
772
773 let mut found_entry = None;
775
776 for (idx, entry) in active_stack.entries.iter().enumerate() {
777 if entry.branch == current_branch {
778 found_entry = Some((
779 idx,
780 entry.id,
781 entry.branch.clone(),
782 entry.pull_request_id.clone(),
783 ));
784 break;
785 }
786 }
787
788 match found_entry {
789 Some((idx, id, branch, pr_id)) => {
790 let has_dependents = active_stack
791 .entries
792 .iter()
793 .skip(idx + 1)
794 .any(|entry| !entry.is_merged);
795 (
796 active_stack.id,
797 idx,
798 id,
799 branch,
800 active_stack.working_branch.clone(),
801 has_dependents,
802 pr_id.is_some(),
803 )
804 }
805 None => {
806 return Err(CascadeError::config(format!(
807 "Current branch '{}' is not a stack entry branch.\n\
808 Use 'ca entry checkout <N>' to checkout a stack entry first.",
809 current_branch
810 )));
811 }
812 }
813 };
814
815 Output::section(format!("Amending stack entry #{}", entry_index + 1));
816
817 let mut amend_args = vec!["commit", "-a", "--amend"];
821
822 if let Some(ref msg) = message {
823 amend_args.push("-m");
824 amend_args.push(msg);
825 } else {
826 amend_args.push("--no-edit");
828 }
829
830 debug!("Running git {}", amend_args.join(" "));
831
832 let output = std::process::Command::new("git")
834 .args(&amend_args)
835 .env("CASCADE_SKIP_HOOKS", "1")
836 .current_dir(&repo_root)
837 .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) .output()
840 .map_err(CascadeError::Io)?;
841
842 if !output.status.success() {
843 let stderr = String::from_utf8_lossy(&output.stderr);
844 return Err(CascadeError::branch(format!(
845 "Failed to amend commit: {}",
846 stderr.trim()
847 )));
848 }
849
850 Output::success("Commit amended");
851
852 let new_commit_hash = repo.get_head_commit()?.id().to_string();
854 debug!("New commit hash after amend: {}", new_commit_hash);
855
856 {
858 let stack = manager
859 .get_stack_mut(&stack_id)
860 .ok_or_else(|| CascadeError::config("Stack not found"))?;
861
862 let old_hash = stack
863 .entries
864 .iter()
865 .find(|e| e.id == entry_id)
866 .map(|e| e.commit_hash.clone())
867 .ok_or_else(|| CascadeError::config("Entry not found"))?;
868
869 stack
870 .update_entry_commit_hash(&entry_id, new_commit_hash.clone())
871 .map_err(CascadeError::config)?;
872
873 debug!(
874 "Updated entry commit hash: {} -> {}",
875 &old_hash[..8],
876 &new_commit_hash[..8]
877 );
878 Output::sub_item(format!(
879 "Updated metadata: {} → {}",
880 &old_hash[..8],
881 &new_commit_hash[..8]
882 ));
883 }
884
885 manager.save_to_disk()?;
886
887 if let Some(ref working_branch_name) = working_branch {
889 Output::sub_item(format!("Updating working branch: {}", working_branch_name));
890
891 repo.update_branch_to_commit(working_branch_name, &new_commit_hash)?;
893
894 Output::success(format!("Working branch '{}' updated", working_branch_name));
895 } else {
896 Output::warning("No working branch found - create one with 'ca stack create' for safety");
897 }
898
899 if push {
901 println!();
902
903 if has_pr {
904 Output::section("Force-pushing to remote");
905
906 std::env::set_var("FORCE_PUSH_NO_CONFIRM", "1");
908
909 repo.force_push_branch(¤t_branch, ¤t_branch)?;
910 Output::success(format!("Force-pushed '{}' to remote", current_branch));
911 Output::sub_item("PR will be automatically updated");
912 } else {
913 Output::warning("No PR found for this entry - skipping push");
914 Output::tip("Use 'ca submit' to create a PR");
915 }
916 }
917
918 println!();
920 Output::section("Summary");
921 Output::bullet(format!(
922 "Amended entry #{} on branch '{}'",
923 entry_index + 1,
924 entry_branch
925 ));
926 if working_branch.is_some() {
927 Output::bullet("Working branch updated");
928 }
929 if push {
930 Output::bullet("Changes force-pushed to remote");
931 }
932
933 if has_dependents {
935 println!();
936 let dependent_count = {
937 let stack = manager
938 .get_stack(&stack_id)
939 .ok_or_else(|| CascadeError::config("Stack not found"))?;
940 stack
941 .entries
942 .iter()
943 .skip(entry_index + 1)
944 .filter(|entry| !entry.is_merged)
945 .count()
946 };
947
948 let plural = if dependent_count == 1 {
949 "entry"
950 } else {
951 "entries"
952 };
953
954 Output::section(format!(
955 "Restacking {} dependent {}",
956 dependent_count, plural
957 ));
958
959 match restack_dependent_entries(&repo_root, &stack_id, entry_index).await {
962 Ok(_) => {
963 Output::success(format!(
964 "Restacked {} dependent {}",
965 dependent_count, plural
966 ));
967 }
968 Err(e) => {
969 println!();
970 Output::error(format!("Failed to restack dependent entries: {}", e));
971 println!();
972 Output::section("Recovery Steps");
973 Output::bullet("Resolve any conflicts in your editor");
974 Output::bullet("Stage resolved files: git add <files>");
975 Output::bullet("Continue: ca entry continue");
976 Output::bullet("Or abort: ca entry abort");
977 println!();
978 return Err(CascadeError::validation(
979 "Restack failed - resolve conflicts and run 'ca entry continue'",
980 ));
981 }
982 }
983 }
984
985 if !push && !has_dependents {
987 println!();
988 Output::tip("Use --push to automatically force-push after amending");
989 }
990
991 Ok(())
992}
993
994async fn restack_dependent_entries(
1004 repo_root: &Path,
1005 stack_id: &uuid::Uuid,
1006 amended_entry_index: usize,
1007) -> Result<()> {
1008 use tracing::debug;
1009
1010 debug!(
1011 "Restacking dependent entries after amending entry #{}",
1012 amended_entry_index + 1
1013 );
1014
1015 let mut stack_manager = StackManager::new(repo_root)?;
1017 let git_repo = GitRepository::open(repo_root)?;
1018
1019 let stack = stack_manager
1021 .get_stack(stack_id)
1022 .ok_or_else(|| CascadeError::config("Stack not found"))?
1023 .clone();
1024
1025 let amended_entry = &stack.entries[amended_entry_index];
1027 let amended_branch = &amended_entry.branch;
1028 let amended_commit = &amended_entry.commit_hash;
1029
1030 debug!(
1031 "Amended entry: branch='{}', commit={}",
1032 amended_branch,
1033 &amended_commit[..8]
1034 );
1035
1036 let dependent_entries: Vec<(usize, StackEntry)> = stack
1039 .entries
1040 .iter()
1041 .enumerate()
1042 .skip(amended_entry_index + 1)
1043 .map(|(idx, entry)| (idx, entry.clone()))
1044 .collect();
1045
1046 if dependent_entries.is_empty() {
1047 debug!("No dependent entries after amended entry");
1048 return Ok(());
1049 }
1050
1051 let unmerged_count = dependent_entries
1052 .iter()
1053 .filter(|(_, e)| !e.is_merged)
1054 .count();
1055 debug!(
1056 "Will process {} dependent entries ({} unmerged, {} merged)",
1057 dependent_entries.len(),
1058 unmerged_count,
1059 dependent_entries.len() - unmerged_count
1060 );
1061
1062 let original_branch = git_repo.get_current_branch()?;
1064 debug!("Currently on branch: {}", original_branch);
1065
1066 let mut current_base_commit = amended_commit.clone();
1069
1070 for &(original_index, ref entry) in dependent_entries.iter() {
1071 let entry_num = original_index + 1; if entry.is_merged {
1076 debug!(
1077 "Entry #{} ({}) is merged, advancing base to {}",
1078 entry_num,
1079 entry.branch,
1080 &entry.commit_hash[..8]
1081 );
1082 current_base_commit = entry.commit_hash.clone();
1083 continue;
1084 }
1085
1086 debug!(
1087 "Rebasing entry #{} ({}): {} onto {}",
1088 entry_num,
1089 entry.branch,
1090 &entry.commit_hash[..8],
1091 ¤t_base_commit[..8]
1092 );
1093
1094 let temp_branch = format!("{}-restack-temp", entry.branch);
1097
1098 git_repo.create_branch(&temp_branch, Some(¤t_base_commit))?;
1100 git_repo.checkout_branch_silent(&temp_branch)?;
1101
1102 match git_repo.cherry_pick(&entry.commit_hash) {
1104 Ok(new_commit_hash) => {
1105 git_repo.update_branch_to_commit(&entry.branch, &new_commit_hash)?;
1107
1108 {
1110 let stack_mut = stack_manager
1111 .get_stack_mut(stack_id)
1112 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1113
1114 stack_mut
1115 .update_entry_commit_hash(&entry.id, new_commit_hash.clone())
1116 .map_err(CascadeError::config)?;
1117 }
1118 stack_manager.save_to_disk()?;
1119
1120 debug!(" → New commit: {}", &new_commit_hash[..8]);
1121
1122 current_base_commit = new_commit_hash;
1124 }
1125 Err(e) => {
1126 println!();
1131 Output::error(format!(
1132 "Failed to restack entry #{} ({}): {}",
1133 entry_num, entry.branch, e
1134 ));
1135 println!();
1136 Output::section("Recovery Options");
1137 println!();
1138 Output::sub_item("To continue after resolving conflicts:");
1139 Output::bullet("1. Check for conflicts: git status");
1140 Output::bullet("2. Resolve conflicts in your editor");
1141 Output::bullet("3. Stage resolved files: git add <files>");
1142 Output::bullet("4. Continue restack: ca entry continue");
1143 println!();
1144 Output::sub_item("To abort and undo the restack:");
1145 Output::bullet("→ Run: ca entry abort");
1146 Output::bullet("→ Then check: ca validate");
1147 println!();
1148 Output::tip("Both commands bypass hooks to avoid edit-mode detection");
1149
1150 return Err(CascadeError::validation(format!(
1151 "Restack paused at entry #{} - resolve conflicts or abort",
1152 entry_num
1153 )));
1154 }
1155 }
1156
1157 git_repo.checkout_branch_unsafe(&original_branch)?;
1160 git_repo.delete_branch_unsafe(&temp_branch)?;
1162 }
1163
1164 if let Some(ref working_branch_name) = stack.working_branch {
1168 debug!(
1169 "Updating working branch '{}' to {}",
1170 working_branch_name,
1171 ¤t_base_commit[..8]
1172 );
1173 git_repo.update_branch_to_commit(working_branch_name, ¤t_base_commit)?;
1174 }
1175
1176 debug!("Successfully restacked {} entries", dependent_entries.len());
1177 Ok(())
1178}
1179
1180async fn continue_restack() -> Result<()> {
1183 use tracing::debug;
1184
1185 let current_dir = env::current_dir()
1186 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1187
1188 let repo_root = find_repository_root(¤t_dir)?;
1189 let git_repo = GitRepository::open(&repo_root)?;
1190
1191 let cherry_pick_head = repo_root.join(".git").join("CHERRY_PICK_HEAD");
1193 if !cherry_pick_head.exists() {
1194 return Err(CascadeError::validation(
1195 "No cherry-pick in progress. Nothing to continue.".to_string(),
1196 ));
1197 }
1198
1199 Output::section("Continuing restack");
1200
1201 let current_branch = git_repo.get_current_branch()?;
1203 if !current_branch.ends_with("-restack-temp") {
1204 return Err(CascadeError::validation(format!(
1205 "Expected to be on a *-restack-temp branch, but on '{}'. Cannot continue safely.",
1206 current_branch
1207 )));
1208 }
1209
1210 let entry_branch = current_branch.trim_end_matches("-restack-temp");
1212
1213 match git_repo.stage_conflict_resolved_files() {
1216 Ok(_) => {
1217 Output::sub_item("Auto-staged resolved conflict files");
1218 }
1219 Err(e) => {
1220 debug!("Could not auto-stage conflict files: {}", e);
1221 Output::warning("Could not auto-stage files. Make sure you've run 'git add <files>'");
1222 }
1223 }
1224
1225 let output = std::process::Command::new("git")
1227 .args(["cherry-pick", "--continue"])
1228 .env("CASCADE_SKIP_HOOKS", "1")
1229 .current_dir(&repo_root)
1230 .stdout(std::process::Stdio::null())
1231 .stderr(std::process::Stdio::piped())
1232 .output()
1233 .map_err(CascadeError::Io)?;
1234
1235 if !output.status.success() {
1236 let stderr = String::from_utf8_lossy(&output.stderr);
1237 return Err(CascadeError::validation(format!(
1238 "Failed to continue cherry-pick: {}\n\n\
1239 Make sure all conflicts are resolved and staged:\n\
1240 1. Check status: git status\n\
1241 2. Stage resolved files: git add <files>\n\
1242 3. Try again: ca entry continue",
1243 stderr.trim()
1244 )));
1245 }
1246
1247 Output::success("Cherry-pick completed");
1248
1249 let new_commit_hash = git_repo.get_head_commit()?.id().to_string();
1251 debug!("New commit hash: {}", &new_commit_hash[..8]);
1252
1253 Output::sub_item(format!("Updating branch '{}' to new commit", entry_branch));
1256 git_repo.update_branch_to_commit(entry_branch, &new_commit_hash)?;
1257
1258 let mut stack_manager = StackManager::new(&repo_root)?;
1260 let active_stack = stack_manager
1261 .get_active_stack()
1262 .ok_or_else(|| CascadeError::config("No active stack"))?;
1263
1264 let entry_id = active_stack
1266 .entries
1267 .iter()
1268 .find(|e| e.branch == entry_branch)
1269 .map(|e| e.id)
1270 .ok_or_else(|| {
1271 CascadeError::config(format!(
1272 "Could not find entry for branch '{}'",
1273 entry_branch
1274 ))
1275 })?;
1276
1277 let stack_id = active_stack.id;
1278
1279 {
1280 let stack_mut = stack_manager
1281 .get_stack_mut(&stack_id)
1282 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1283
1284 stack_mut
1285 .update_entry_commit_hash(&entry_id, new_commit_hash.clone())
1286 .map_err(CascadeError::config)?;
1287 }
1288 stack_manager.save_to_disk()?;
1289
1290 Output::sub_item(format!("Updated metadata: {}", &new_commit_hash[..8]));
1291
1292 Output::sub_item(format!("Cleaning up temp branch '{}'", current_branch));
1294
1295 git_repo.checkout_branch_unsafe(entry_branch)?;
1297
1298 git_repo.delete_branch_unsafe(¤t_branch)?;
1300
1301 println!();
1302 Output::warning("Restack is incomplete!");
1303 Output::sub_item("The current entry has been resolved, but:");
1304 Output::sub_item("• Remaining dependent entries still need restacking");
1305 Output::sub_item("• Working branch needs updating");
1306 println!();
1307 Output::section("Next Steps");
1308 Output::bullet("Complete restack: ca sync");
1309 Output::bullet("This will rebase remaining entries and update working branch");
1310 println!();
1311
1312 Ok(())
1313}
1314
1315async fn abort_restack() -> Result<()> {
1318 let current_dir = env::current_dir()
1319 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1320
1321 let repo_root = find_repository_root(¤t_dir)?;
1322
1323 let cherry_pick_head = repo_root.join(".git").join("CHERRY_PICK_HEAD");
1325 if !cherry_pick_head.exists() {
1326 return Err(CascadeError::validation(
1327 "No cherry-pick in progress. Nothing to abort.".to_string(),
1328 ));
1329 }
1330
1331 Output::section("Aborting restack");
1332
1333 let output = std::process::Command::new("git")
1335 .args(["cherry-pick", "--abort"])
1336 .env("CASCADE_SKIP_HOOKS", "1")
1337 .current_dir(&repo_root)
1338 .stdout(std::process::Stdio::null())
1339 .stderr(std::process::Stdio::piped())
1340 .output()
1341 .map_err(CascadeError::Io)?;
1342
1343 if !output.status.success() {
1344 let stderr = String::from_utf8_lossy(&output.stderr);
1345 return Err(CascadeError::validation(format!(
1346 "Failed to abort cherry-pick: {}\n\n\
1347 You may need to manually clean up the Git state:\n\
1348 1. Check status: git status\n\
1349 2. Reset if needed: git reset --hard HEAD",
1350 stderr.trim()
1351 )));
1352 }
1353
1354 Output::success("Cherry-pick aborted");
1355
1356 let git_repo = GitRepository::open(&repo_root)?;
1358 let current_branch = git_repo.get_current_branch().ok();
1359
1360 if let Some(ref branch) = current_branch {
1362 if branch.ends_with("-restack-temp") {
1363 let original_branch = branch.trim_end_matches("-restack-temp");
1365
1366 Output::sub_item(format!("Cleaning up temp branch '{}'", branch));
1367
1368 if let Err(e) = git_repo.checkout_branch_unsafe(original_branch) {
1370 Output::warning(format!(
1371 "Could not checkout to '{}': {}. You may need to checkout manually.",
1372 original_branch, e
1373 ));
1374 } else {
1375 if let Err(e) = git_repo.delete_branch_unsafe(branch) {
1377 Output::warning(format!(
1378 "Could not delete temp branch '{}': {}. You may need to delete it manually.",
1379 branch, e
1380 ));
1381 }
1382 }
1383 }
1384 }
1385
1386 println!();
1387 Output::warning("Restack was aborted - stack may be in inconsistent state");
1388 println!();
1389 Output::section("Next Steps");
1390 Output::bullet("Check stack state: ca validate");
1391 Output::bullet("If needed, fix issues with: ca validate (choose 'Incorporate' or 'Reset')");
1392 Output::bullet("Or try restack again: ca sync");
1393 println!();
1394
1395 Ok(())
1396}