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