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