cascade_cli/cli/commands/
entry.rs

1use crate::cli::output::Output;
2use crate::errors::{CascadeError, Result};
3use crate::git::find_repository_root;
4use crate::stack::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 tracing::{debug, warn};
23
24#[derive(Debug, Subcommand)]
25pub enum EntryAction {
26    /// Interactively checkout a stack entry for editing
27    Checkout {
28        /// Stack entry number (optional, shows picker if not provided)
29        entry: Option<usize>,
30        /// Skip interactive picker and use entry number directly
31        #[arg(long)]
32        direct: bool,
33        /// Skip confirmation prompts
34        #[arg(long, short)]
35        yes: bool,
36    },
37    /// Show current edit mode status
38    Status {
39        /// Show brief status only
40        #[arg(long)]
41        quiet: bool,
42    },
43    /// List all entries with their edit status
44    List {
45        /// Show detailed information
46        #[arg(long, short)]
47        verbose: bool,
48    },
49    /// Clear/exit edit mode (useful for recovering from corrupted state)
50    Clear {
51        /// Skip confirmation prompt
52        #[arg(long, short)]
53        yes: bool,
54    },
55}
56
57pub async fn run(action: EntryAction) -> Result<()> {
58    let _current_dir = env::current_dir()
59        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
60
61    match action {
62        EntryAction::Checkout { entry, direct, yes } => checkout_entry(entry, direct, yes).await,
63        EntryAction::Status { quiet } => show_edit_status(quiet).await,
64        EntryAction::List { verbose } => list_entries(verbose).await,
65        EntryAction::Clear { yes } => clear_edit_mode(yes).await,
66    }
67}
68
69/// Checkout a specific stack entry for editing
70async fn checkout_entry(
71    entry_num: Option<usize>,
72    direct: bool,
73    skip_confirmation: bool,
74) -> Result<()> {
75    let current_dir = env::current_dir()
76        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
77
78    let repo_root = find_repository_root(&current_dir)
79        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
80
81    let mut manager = StackManager::new(&repo_root)?;
82
83    // Get active stack
84    let active_stack = manager.get_active_stack().ok_or_else(|| {
85        CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
86    })?;
87
88    if active_stack.entries.is_empty() {
89        return Err(CascadeError::config(
90            "Stack is empty. Push some commits first with 'ca stack push'",
91        ));
92    }
93
94    // Determine which entry to checkout
95    let target_entry_num = if let Some(num) = entry_num {
96        if num == 0 || num > active_stack.entries.len() {
97            return Err(CascadeError::config(format!(
98                "Invalid entry number: {}. Stack has {} entries",
99                num,
100                active_stack.entries.len()
101            )));
102        }
103        num
104    } else if direct {
105        return Err(CascadeError::config(
106            "Entry number required when using --direct flag",
107        ));
108    } else {
109        // Show interactive picker
110        show_entry_picker(active_stack).await?
111    };
112
113    let target_entry = &active_stack.entries[target_entry_num - 1]; // Convert to 0-based index
114
115    // Clone the values we need before borrowing manager mutably
116    let stack_id = active_stack.id;
117    let entry_id = target_entry.id;
118    let entry_branch = target_entry.branch.clone();
119    let entry_short_hash = target_entry.short_hash();
120    let entry_short_message = target_entry.short_message(50);
121    let entry_pr_id = target_entry.pull_request_id.clone();
122    let entry_message = target_entry.message.clone();
123
124    // Check if already in edit mode and get info before confirmation
125    let already_in_edit_mode = manager.is_in_edit_mode();
126    let edit_mode_display = if already_in_edit_mode {
127        let edit_info = manager.get_edit_mode_info().unwrap();
128
129        // Get the commit message for the current edit target
130        let commit_message = if let Some(target_entry_id) = &edit_info.target_entry_id {
131            if let Some(entry) = active_stack
132                .entries
133                .iter()
134                .find(|e| e.id == *target_entry_id)
135            {
136                entry.short_message(50)
137            } else {
138                "Unknown entry".to_string()
139            }
140        } else {
141            "Unknown target".to_string()
142        };
143
144        Some((edit_info.original_commit_hash.clone(), commit_message))
145    } else {
146        None
147    };
148
149    // Let the active_stack reference go out of scope before we potentially mutably borrow manager
150    let _ = active_stack;
151
152    // Handle edit mode exit if needed
153    if let Some((commit_hash, commit_message)) = edit_mode_display {
154        warn!("Already in edit mode for entry in stack");
155
156        if !skip_confirmation {
157            Output::warning("Already in edit mode!");
158            Output::sub_item(format!(
159                "Current target: {} ({})",
160                &commit_hash[..8],
161                commit_message
162            ));
163
164            // Interactive confirmation to exit current edit mode
165            let should_exit_edit_mode = Confirm::with_theme(&ColorfulTheme::default())
166                .with_prompt("Exit current edit mode and start a new one?")
167                .default(false)
168                .interact()
169                .map_err(|e| {
170                    CascadeError::config(format!("Failed to get user confirmation: {e}"))
171                })?;
172
173            if !should_exit_edit_mode {
174                return Err(CascadeError::config(
175                    "Operation cancelled. Use 'ca entry status' to see current edit mode details.",
176                ));
177            }
178
179            // Exit current edit mode before starting a new one
180            Output::info("Exiting current edit mode...");
181            manager.exit_edit_mode()?;
182            Output::success("✓ Exited previous edit mode");
183        }
184    }
185
186    // Confirmation prompt
187    if !skip_confirmation {
188        Output::section("Checking out entry for editing");
189        Output::sub_item(format!(
190            "Entry #{target_entry_num}: {entry_short_hash} ({entry_short_message})"
191        ));
192        Output::sub_item(format!("Branch: {entry_branch}"));
193        if let Some(pr_id) = &entry_pr_id {
194            Output::sub_item(format!("PR: #{pr_id}"));
195        }
196
197        // Display full commit message
198        Output::sub_item("Commit Message:");
199        let lines: Vec<&str> = entry_message.lines().collect();
200        for line in lines {
201            Output::sub_item(format!("  {line}"));
202        }
203
204        Output::warning("This will checkout the commit and enter edit mode.");
205        Output::info("Any changes you make can be amended to this commit or create new entries.");
206
207        // Interactive confirmation to proceed with checkout
208        let should_continue = Confirm::with_theme(&ColorfulTheme::default())
209            .with_prompt("Continue with checkout?")
210            .default(false)
211            .interact()
212            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
213
214        if !should_continue {
215            return Err(CascadeError::config("Entry checkout cancelled"));
216        }
217    }
218
219    // Enter edit mode
220    manager.enter_edit_mode(stack_id, entry_id)?;
221
222    // Checkout the branch (not the commit - we want to stay on the branch)
223    let current_dir = env::current_dir()
224        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
225
226    let repo_root = find_repository_root(&current_dir)
227        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
228    let repo = crate::git::GitRepository::open(&repo_root)?;
229
230    debug!("Checking out branch: {}", entry_branch);
231    repo.checkout_branch(&entry_branch)?;
232
233    Output::success(format!("Entered edit mode for entry #{target_entry_num}"));
234    Output::sub_item(format!(
235        "You are now on commit: {} ({})",
236        entry_short_hash, entry_short_message
237    ));
238    Output::sub_item(format!("Branch: {entry_branch}"));
239
240    Output::section("Make your changes and commit normally");
241    Output::bullet("Use 'ca entry status' to see edit mode info");
242    Output::bullet("When you commit, the pre-commit hook will guide you:");
243    Output::sub_item("  → Press Enter (or 'A') to amend this entry");
244    Output::sub_item("  → Type 'n' to create new entry on top");
245    Output::bullet("Run 'ca sync' after committing to update PRs");
246
247    // Check if prepare-commit-msg hook is installed
248    let hooks_dir = repo_root.join(".git/hooks");
249    let hook_path = hooks_dir.join("prepare-commit-msg");
250    if !hook_path.exists() {
251        Output::tip("Install the prepare-commit-msg hook for better guidance:");
252        Output::sub_item("ca hooks add prepare-commit-msg");
253    }
254
255    Ok(())
256}
257
258/// Interactive entry picker using TUI
259async fn show_entry_picker(stack: &crate::stack::Stack) -> Result<usize> {
260    // Setup terminal
261    enable_raw_mode()?;
262    let mut stdout = io::stdout();
263    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
264    let backend = CrosstermBackend::new(stdout);
265    let mut terminal = Terminal::new(backend)?;
266
267    let mut list_state = ListState::default();
268    list_state.select(Some(0));
269
270    let result = loop {
271        terminal.draw(|f| {
272            let size = f.area();
273
274            // Create layout
275            let chunks = Layout::default()
276                .direction(Direction::Vertical)
277                .margin(2)
278                .constraints(
279                    [
280                        Constraint::Length(3), // Title
281                        Constraint::Min(5),    // List
282                        Constraint::Length(3), // Help
283                    ]
284                    .as_ref(),
285                )
286                .split(size);
287
288            // Title
289            let title = Paragraph::new(format!("📚 Select Entry from Stack: {}", stack.name))
290                .style(
291                    Style::default()
292                        .fg(Color::Cyan)
293                        .add_modifier(Modifier::BOLD),
294                )
295                .alignment(Alignment::Center)
296                .block(Block::default().borders(Borders::ALL));
297            f.render_widget(title, chunks[0]);
298
299            // Entry list
300            let items: Vec<ListItem> = stack
301                .entries
302                .iter()
303                .enumerate()
304                .map(|(i, entry)| {
305                    let status_icon = if entry.is_submitted {
306                        if entry.pull_request_id.is_some() {
307                            "📤"
308                        } else {
309                            "📝"
310                        }
311                    } else {
312                        "🔄"
313                    };
314
315                    let pr_text = if let Some(pr_id) = &entry.pull_request_id {
316                        format!(" PR: #{pr_id}")
317                    } else {
318                        "".to_string()
319                    };
320
321                    let line = Line::from(vec![
322                        Span::raw(format!("  {}. ", i + 1)),
323                        Span::raw(status_icon),
324                        Span::raw(" "),
325                        Span::styled(entry.short_message(40), Style::default().fg(Color::White)),
326                        Span::raw(" "),
327                        Span::styled(
328                            format!("({})", entry.short_hash()),
329                            Style::default().fg(Color::Yellow),
330                        ),
331                        Span::styled(pr_text, Style::default().fg(Color::Green)),
332                    ]);
333
334                    ListItem::new(line)
335                })
336                .collect();
337
338            let list = List::new(items)
339                .block(Block::default().borders(Borders::ALL).title("Entries"))
340                .highlight_style(Style::default().fg(Color::Black).bg(Color::Cyan))
341                .highlight_symbol("→ ");
342
343            f.render_stateful_widget(list, chunks[1], &mut list_state);
344
345            // Help text
346            let help = Paragraph::new("↑/↓: Navigate • Enter: Select • q: Quit • r: Refresh")
347                .style(Style::default().fg(Color::DarkGray))
348                .alignment(Alignment::Center)
349                .block(Block::default().borders(Borders::ALL));
350            f.render_widget(help, chunks[2]);
351        })?;
352
353        // Handle input
354        if let Event::Key(key) = event::read()? {
355            if key.kind == KeyEventKind::Press {
356                match key.code {
357                    KeyCode::Char('q') => {
358                        break Err(CascadeError::config("Entry selection cancelled"));
359                    }
360                    KeyCode::Up => {
361                        let selected = list_state.selected().unwrap_or(0);
362                        if selected > 0 {
363                            list_state.select(Some(selected - 1));
364                        } else {
365                            list_state.select(Some(stack.entries.len() - 1));
366                        }
367                    }
368                    KeyCode::Down => {
369                        let selected = list_state.selected().unwrap_or(0);
370                        if selected < stack.entries.len() - 1 {
371                            list_state.select(Some(selected + 1));
372                        } else {
373                            list_state.select(Some(0));
374                        }
375                    }
376                    KeyCode::Enter => {
377                        let selected = list_state.selected().unwrap_or(0);
378                        break Ok(selected + 1); // Convert to 1-based index
379                    }
380                    KeyCode::Char('r') => {
381                        // Refresh - for now just continue the loop
382                        continue;
383                    }
384                    _ => {}
385                }
386            }
387        }
388    };
389
390    // Restore terminal
391    disable_raw_mode()?;
392    execute!(
393        terminal.backend_mut(),
394        LeaveAlternateScreen,
395        DisableMouseCapture
396    )?;
397    terminal.show_cursor()?;
398
399    result
400}
401
402/// Show current edit mode status
403async fn show_edit_status(quiet: bool) -> Result<()> {
404    let current_dir = env::current_dir()
405        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
406
407    let repo_root = find_repository_root(&current_dir)
408        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
409    let manager = StackManager::new(&repo_root)?;
410
411    if !manager.is_in_edit_mode() {
412        if quiet {
413            println!("inactive");
414        } else {
415            Output::info("Not in edit mode");
416            Output::sub_item("Use 'ca entry checkout' to start editing a stack entry");
417        }
418        return Ok(());
419    }
420
421    let edit_info = manager.get_edit_mode_info().unwrap();
422
423    if quiet {
424        println!("active:{:?}", edit_info.target_entry_id);
425        return Ok(());
426    }
427
428    Output::section("Currently in edit mode");
429
430    // Try to get the entry information
431    if let Some(active_stack) = manager.get_active_stack() {
432        if let Some(target_entry_id) = edit_info.target_entry_id {
433            if let Some(entry) = active_stack
434                .entries
435                .iter()
436                .find(|e| e.id == target_entry_id)
437            {
438                Output::sub_item(format!(
439                    "Target entry: {} ({})",
440                    entry.short_hash(),
441                    entry.short_message(50)
442                ));
443                Output::sub_item(format!("Branch: {}", entry.branch));
444
445                // Display full commit message
446                Output::sub_item("Commit Message:");
447                let lines: Vec<&str> = entry.message.lines().collect();
448                for line in lines {
449                    Output::sub_item(format!("  {line}"));
450                }
451            } else {
452                Output::sub_item(format!("Target entry: {target_entry_id:?} (not found)"));
453            }
454        } else {
455            Output::sub_item("Target entry: Unknown");
456        }
457    } else {
458        Output::sub_item(format!("Target entry: {:?}", edit_info.target_entry_id));
459    }
460
461    Output::sub_item(format!(
462        "Original commit: {}",
463        &edit_info.original_commit_hash[..8]
464    ));
465    Output::sub_item(format!(
466        "Started: {}",
467        edit_info.started_at.format("%Y-%m-%d %H:%M:%S")
468    ));
469
470    // Show current Git status
471    Output::section("Current state");
472
473    // Get current repository state
474    let current_dir = env::current_dir()
475        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
476    let repo_root = find_repository_root(&current_dir)
477        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
478    let repo = crate::git::GitRepository::open(&repo_root)?;
479
480    // Current HEAD vs original commit
481    let current_head = repo.get_current_commit_hash()?;
482    if current_head != edit_info.original_commit_hash {
483        let current_short = &current_head[..8];
484        let original_short = &edit_info.original_commit_hash[..8];
485        Output::sub_item(format!("HEAD moved: {original_short} → {current_short}"));
486
487        // Show if there are new commits
488        match repo.get_commit_count_between(&edit_info.original_commit_hash, &current_head) {
489            Ok(count) if count > 0 => {
490                Output::sub_item(format!("  {count} new commit(s) created"));
491            }
492            _ => {}
493        }
494    } else {
495        Output::sub_item(format!("HEAD: {} (unchanged)", &current_head[..8]));
496    }
497
498    // Working directory and staging status
499    match repo.get_status_summary() {
500        Ok(status) => {
501            if status.is_clean() {
502                Output::sub_item("Working directory: clean");
503            } else {
504                if status.has_staged_changes() {
505                    Output::sub_item(format!("Staged changes: {} files", status.staged_count()));
506                }
507                if status.has_unstaged_changes() {
508                    Output::sub_item(format!(
509                        "Unstaged changes: {} files",
510                        status.unstaged_count()
511                    ));
512                }
513                if status.has_untracked_files() {
514                    Output::sub_item(format!(
515                        "Untracked files: {} files",
516                        status.untracked_count()
517                    ));
518                }
519            }
520        }
521        Err(_) => {
522            Output::sub_item("Working directory: status unavailable");
523        }
524    }
525
526    Output::tip("Use 'git status' for detailed file-level status");
527    Output::sub_item("Use 'ca entry list' to see all entries");
528
529    Ok(())
530}
531
532/// List all entries in the stack with edit status
533async fn list_entries(verbose: bool) -> Result<()> {
534    let current_dir = env::current_dir()
535        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
536
537    let repo_root = find_repository_root(&current_dir)
538        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
539    let manager = StackManager::new(&repo_root)?;
540
541    let active_stack = manager.get_active_stack().ok_or_else(|| {
542        CascadeError::config(
543            "No active stack. Create a stack first with 'ca stack create'".to_string(),
544        )
545    })?;
546
547    if active_stack.entries.is_empty() {
548        Output::info(format!(
549            "Active stack '{}' has no entries yet",
550            active_stack.name
551        ));
552        Output::sub_item("Add some commits to the stack with 'ca stack push'");
553        return Ok(());
554    }
555
556    Output::section(format!(
557        "Stack: {} ({} entries)",
558        active_stack.name,
559        active_stack.entries.len()
560    ));
561
562    let edit_mode_info = manager.get_edit_mode_info();
563
564    for (i, entry) in active_stack.entries.iter().enumerate() {
565        let entry_num = i + 1;
566
567        // Status icon
568        let status_icon = if entry.is_submitted {
569            if entry.pull_request_id.is_some() {
570                "📤"
571            } else {
572                "📝"
573            }
574        } else {
575            "🔄"
576        };
577
578        // Edit mode indicator
579        let edit_indicator = if edit_mode_info.is_some()
580            && edit_mode_info.unwrap().target_entry_id == Some(entry.id)
581        {
582            " 🎯"
583        } else {
584            ""
585        };
586
587        // Basic entry line
588        print!(
589            "   {}. {} {} ({})",
590            entry_num,
591            status_icon,
592            entry.short_message(50),
593            entry.short_hash()
594        );
595
596        // PR information
597        if let Some(pr_id) = &entry.pull_request_id {
598            print!(" PR: #{pr_id}");
599        }
600
601        print!("{edit_indicator}");
602        println!(); // Line break for entry
603
604        // Verbose information
605        if verbose {
606            Output::sub_item(format!("Branch: {}", entry.branch));
607            Output::sub_item(format!("Commit: {}", entry.commit_hash));
608            Output::sub_item(format!(
609                "Created: {}",
610                entry.created_at.format("%Y-%m-%d %H:%M:%S")
611            ));
612            if entry.is_submitted {
613                Output::sub_item("Status: Submitted");
614            } else {
615                Output::sub_item("Status: Draft");
616            }
617
618            // Display full commit message
619            Output::sub_item("Message:");
620            let lines: Vec<&str> = entry.message.lines().collect();
621            for line in lines {
622                Output::sub_item(format!("  {line}"));
623            }
624
625            // Add Git status info for entry in edit mode
626            if edit_mode_info.is_some() && edit_mode_info.unwrap().target_entry_id == Some(entry.id)
627            {
628                if let Ok(repo_root) = find_repository_root(&env::current_dir().unwrap_or_default())
629                {
630                    if let Ok(repo) = crate::git::GitRepository::open(&repo_root) {
631                        match repo.get_status_summary() {
632                            Ok(status) => {
633                                if !status.is_clean() {
634                                    Output::sub_item("Git Status:");
635                                    if status.has_staged_changes() {
636                                        Output::sub_item(format!(
637                                            "  Staged: {} files",
638                                            status.staged_count()
639                                        ));
640                                    }
641                                    if status.has_unstaged_changes() {
642                                        Output::sub_item(format!(
643                                            "  Unstaged: {} files",
644                                            status.unstaged_count()
645                                        ));
646                                    }
647                                    if status.has_untracked_files() {
648                                        Output::sub_item(format!(
649                                            "  Untracked: {} files",
650                                            status.untracked_count()
651                                        ));
652                                    }
653                                } else {
654                                    Output::sub_item("Git Status: clean");
655                                }
656                            }
657                            Err(_) => {
658                                Output::sub_item("Git Status: unavailable");
659                            }
660                        }
661                    }
662                }
663            }
664            // Add spacing between entries
665        }
666    }
667
668    if let Some(_edit_info) = edit_mode_info {
669        Output::spacing();
670        Output::info("Edit mode active - use 'ca entry status' for details");
671    } else {
672        Output::spacing();
673        Output::tip("Use 'ca entry checkout' to start editing an entry");
674    }
675
676    Ok(())
677}
678
679/// Clear/exit edit mode (useful for recovering from corrupted state)
680async fn clear_edit_mode(skip_confirmation: bool) -> Result<()> {
681    let current_dir = env::current_dir()
682        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
683
684    let repo_root = find_repository_root(&current_dir)
685        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
686
687    let mut manager = StackManager::new(&repo_root)?;
688
689    if !manager.is_in_edit_mode() {
690        Output::info("Not currently in edit mode");
691        return Ok(());
692    }
693
694    // Show current edit mode info
695    if let Some(edit_info) = manager.get_edit_mode_info() {
696        Output::section("Current edit mode state");
697
698        if let Some(target_entry_id) = &edit_info.target_entry_id {
699            Output::sub_item(format!("Target entry: {}", target_entry_id));
700
701            // Try to find the entry
702            if let Some(active_stack) = manager.get_active_stack() {
703                if let Some(entry) = active_stack
704                    .entries
705                    .iter()
706                    .find(|e| e.id == *target_entry_id)
707                {
708                    Output::sub_item(format!("Entry: {}", entry.short_message(50)));
709                } else {
710                    Output::warning("Target entry not found in stack (corrupted state)");
711                }
712            }
713        }
714
715        Output::sub_item(format!(
716            "Original commit: {}",
717            &edit_info.original_commit_hash[..8]
718        ));
719        Output::sub_item(format!(
720            "Started: {}",
721            edit_info.started_at.format("%Y-%m-%d %H:%M:%S")
722        ));
723    }
724
725    // Confirm before clearing
726    if !skip_confirmation {
727        println!();
728        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
729            .with_prompt("Clear edit mode state?")
730            .default(true)
731            .interact()
732            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
733
734        if !confirmed {
735            return Err(CascadeError::config("Operation cancelled."));
736        }
737    }
738
739    // Clear edit mode
740    manager.exit_edit_mode()?;
741
742    Output::success("Edit mode cleared");
743    Output::tip("Use 'ca entry checkout' to start a new edit session");
744
745    Ok(())
746}