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::{info, 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}
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
62async 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(¤t_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 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 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_entry_picker(active_stack).await?
104 };
105
106 let target_entry = &active_stack.entries[target_entry_num - 1]; 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 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 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 _ = active_stack;
145
146 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 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 Output::info("Exiting current edit mode...");
175 manager.exit_edit_mode()?;
176 Output::success("✓ Exited previous edit mode");
177 }
178 }
179
180 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 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 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 manager.enter_edit_mode(stack_id, entry_id)?;
215
216 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(¤t_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 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
250async fn show_entry_picker(stack: &crate::stack::Stack) -> Result<usize> {
252 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 let chunks = Layout::default()
268 .direction(Direction::Vertical)
269 .margin(2)
270 .constraints(
271 [
272 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ]
276 .as_ref(),
277 )
278 .split(size);
279
280 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 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 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 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); }
372 KeyCode::Char('r') => {
373 continue;
375 }
376 _ => {}
377 }
378 }
379 }
380 };
381
382 disable_raw_mode()?;
384 execute!(
385 terminal.backend_mut(),
386 LeaveAlternateScreen,
387 DisableMouseCapture
388 )?;
389 terminal.show_cursor()?;
390
391 result
392}
393
394async 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(¤t_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 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 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 Output::section("Current state");
464
465 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(¤t_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 let current_head = repo.get_current_commit_hash()?;
474 if current_head != edit_info.original_commit_hash {
475 let current_short = ¤t_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 match repo.get_commit_count_between(&edit_info.original_commit_hash, ¤t_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)", ¤t_head[..8]));
488 }
489
490 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
524async 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(¤t_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 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 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 print!(
581 " {}. {} {} ({})",
582 entry_num,
583 status_icon,
584 entry.short_message(50),
585 entry.short_hash()
586 );
587
588 if let Some(pr_id) = &entry.pull_request_id {
590 print!(" PR: #{pr_id}");
591 }
592
593 print!("{edit_indicator}");
594 println!(); 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 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 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 }
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}