cascade_cli/cli/commands/
entry.rs1use 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 Checkout {
28 entry: Option<usize>,
30 #[arg(long)]
32 direct: bool,
33 #[arg(long, short)]
35 yes: bool,
36 },
37 Status {
39 #[arg(long)]
41 quiet: bool,
42 },
43 List {
45 #[arg(long, short)]
47 verbose: bool,
48 },
49 Clear {
51 #[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
69async 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(¤t_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 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 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_entry_picker(active_stack).await?
111 };
112
113 let target_entry = &active_stack.entries[target_entry_num - 1]; 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 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 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 _ = active_stack;
151
152 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 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 Output::info("Exiting current edit mode...");
181 manager.exit_edit_mode()?;
182 Output::success("✓ Exited previous edit mode");
183 }
184 }
185
186 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 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 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 manager.enter_edit_mode(stack_id, entry_id)?;
221
222 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(¤t_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 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
258async fn show_entry_picker(stack: &crate::stack::Stack) -> Result<usize> {
260 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 let chunks = Layout::default()
276 .direction(Direction::Vertical)
277 .margin(2)
278 .constraints(
279 [
280 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ]
284 .as_ref(),
285 )
286 .split(size);
287
288 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 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 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 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); }
380 KeyCode::Char('r') => {
381 continue;
383 }
384 _ => {}
385 }
386 }
387 }
388 };
389
390 disable_raw_mode()?;
392 execute!(
393 terminal.backend_mut(),
394 LeaveAlternateScreen,
395 DisableMouseCapture
396 )?;
397 terminal.show_cursor()?;
398
399 result
400}
401
402async 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(¤t_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 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 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 Output::section("Current state");
472
473 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(¤t_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 let current_head = repo.get_current_commit_hash()?;
482 if current_head != edit_info.original_commit_hash {
483 let current_short = ¤t_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 match repo.get_commit_count_between(&edit_info.original_commit_hash, ¤t_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)", ¤t_head[..8]));
496 }
497
498 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
532async 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(¤t_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 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 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 print!(
589 " {}. {} {} ({})",
590 entry_num,
591 status_icon,
592 entry.short_message(50),
593 entry.short_hash()
594 );
595
596 if let Some(pr_id) = &entry.pull_request_id {
598 print!(" PR: #{pr_id}");
599 }
600
601 print!("{edit_indicator}");
602 println!(); 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 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 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 }
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
679async 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(¤t_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 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 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 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 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}