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
117 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 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 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 return Err(CascadeError::config("Exiting edit mode not yet implemented. Use 'ca entry status' and handle any pending changes manually."));
164 }
165 }
166
167 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 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 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 manager.enter_edit_mode(stack_id, entry_id)?;
202
203 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(¤t_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 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
237async fn show_entry_picker(stack: &crate::stack::Stack) -> Result<usize> {
239 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 let chunks = Layout::default()
255 .direction(Direction::Vertical)
256 .margin(2)
257 .constraints(
258 [
259 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ]
263 .as_ref(),
264 )
265 .split(size);
266
267 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 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 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 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); }
359 KeyCode::Char('r') => {
360 continue;
362 }
363 _ => {}
364 }
365 }
366 }
367 };
368
369 disable_raw_mode()?;
371 execute!(
372 terminal.backend_mut(),
373 LeaveAlternateScreen,
374 DisableMouseCapture
375 )?;
376 terminal.show_cursor()?;
377
378 result
379}
380
381async 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(¤t_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 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 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 Output::section("Current state");
451
452 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(¤t_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 let current_head = repo.get_current_commit_hash()?;
461 if current_head != edit_info.original_commit_hash {
462 let current_short = ¤t_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 match repo.get_commit_count_between(&edit_info.original_commit_hash, ¤t_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)", ¤t_head[..8]));
475 }
476
477 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
511async 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(¤t_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 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 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 print!(
568 " {}. {} {} ({})",
569 entry_num,
570 status_icon,
571 entry.short_message(50),
572 entry.short_hash()
573 );
574
575 if let Some(pr_id) = &entry.pull_request_id {
577 print!(" PR: #{pr_id}");
578 }
579
580 print!("{edit_indicator}");
581 println!(); 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 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 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 }
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}