From 35ae606be39ecfb570d339231bcc44b96f273991 Mon Sep 17 00:00:00 2001 From: mark-pro <20671988+mark-pro@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:55:42 -0400 Subject: [PATCH] feat(switch): add interactive branch switch command Closes #32 --- README.md | 4 + src/cli/mod.rs | 5 + src/cli/switch/interactive.rs | 452 ++++++++++++++++++++++++++++++++++ src/cli/switch/mod.rs | 87 +++++++ src/cli/tree/mod.rs | 2 + src/cli/tree/render.rs | 36 +-- src/cli/tree/rows.rs | 184 ++++++++++++++ src/core/mod.rs | 1 + src/core/switch.rs | 71 ++++++ src/core/tree.rs | 22 +- tests/pause_guard.rs | 14 ++ tests/switch.rs | 124 ++++++++++ 12 files changed, 963 insertions(+), 39 deletions(-) create mode 100644 src/cli/switch/interactive.rs create mode 100644 src/cli/switch/mod.rs create mode 100644 src/cli/tree/rows.rs create mode 100644 src/core/switch.rs create mode 100644 tests/switch.rs diff --git a/README.md b/README.md index 073b2d3..e01e731 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ dgr pr list --view dgr init # initialize dagger in the current directory dgr branch # create a tracked branch from the current branch dgr branch -p # create a tracked branch under a specific parent +dgr switch # switch directly to a local branch +dgr switch # choose a tracked branch from the interactive tree dgr tree # show the full tracked branch tree dgr tree --branch # show one branch and its descendants dgr commit -m "message" # commit and restack tracked descendants if needed @@ -117,6 +119,8 @@ dgr reparent -p # reparent a named tracked branch onto a new p dgr orphan # stop tracking a branch but keep the local branch ``` +When you run `dgr switch` without a branch name, dagger opens an inline tree picker for the tracked stack. Use the arrow keys or `j`/`k` to move, `Enter` to switch, and `Esc` or `q` to cancel. + ### Sync stacks Run `dgr sync` to reconcile your local branches, dagger's tracked stack metadata, and GitHub pull requests: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e5af369..07f4c54 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -9,6 +9,7 @@ mod operation; mod orphan; mod pr; mod reparent; +mod switch; mod sync; mod tree; @@ -58,6 +59,9 @@ enum Commands { /// Continue a paused restack sequence Sync(sync::SyncArgs), + /// Switch to a branch directly or choose one from the tracked tree + Switch(switch::SwitchArgs), + /// Print the tracked branch stacks as a shared tree from trunk Tree(tree::TreeArgs), } @@ -81,6 +85,7 @@ pub fn run() -> ExitCode { Commands::Pr(args) => pr::execute(args), Commands::Reparent(args) => reparent::execute(args), Commands::Sync(args) => sync::execute(args), + Commands::Switch(args) => switch::execute(args), Commands::Tree(args) => tree::execute(args), }; diff --git a/src/cli/switch/interactive.rs b/src/cli/switch/interactive.rs new file mode 100644 index 0000000..147c678 --- /dev/null +++ b/src/cli/switch/interactive.rs @@ -0,0 +1,452 @@ +use std::cmp; +use std::env; +use std::io; +use std::io::Write; + +use ratatui::crossterm::QueueableCommand; +use ratatui::crossterm::cursor::{Hide, MoveToColumn, MoveUp, Show}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::crossterm::terminal::{self, Clear, ClearType}; + +use crate::core::tree::TreeView; +use crate::ui::markers; +use crate::ui::palette::Accent; + +use super::super::tree::{StackTreeRow, stack_tree_rows}; + +const MAX_VISIBLE_ROWS: usize = 12; +const SCRIPTED_EVENTS_ENV: &str = "DGR_SWITCH_TEST_EVENTS"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum InteractiveOutcome { + Selected(String), + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InputEvent { + Up, + Down, + Confirm, + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InteractiveState { + rows: Vec, + selected_index: usize, + untracked_current_branch: Option, +} + +impl InteractiveState { + fn new(view: &TreeView) -> Self { + let rows = stack_tree_rows(view); + let selected_index = view + .current_branch_name + .as_deref() + .and_then(|branch_name| rows.iter().position(|row| row.branch_name == branch_name)) + .unwrap_or_default(); + let untracked_current_branch = view + .current_branch_name + .as_ref() + .filter(|branch_name| rows.iter().all(|row| row.branch_name != **branch_name)) + .cloned(); + + Self { + rows, + selected_index, + untracked_current_branch, + } + } + + fn apply_event(&mut self, event: InputEvent) -> Option { + match event { + InputEvent::Up => { + self.selected_index = self.selected_index.saturating_sub(1); + None + } + InputEvent::Down => { + let last_index = self.rows.len().saturating_sub(1); + self.selected_index = cmp::min(self.selected_index + 1, last_index); + None + } + InputEvent::Confirm => Some(InteractiveOutcome::Selected( + self.selected_branch_name().to_string(), + )), + InputEvent::Cancel => Some(InteractiveOutcome::Cancelled), + } + } + + fn render(&self) -> String { + let range = visible_range(self.rows.len(), self.selected_index, MAX_VISIBLE_ROWS); + let range_start = range.start; + let mut lines = self.rows[range] + .iter() + .enumerate() + .map(|(offset, row)| { + let row_index = range_start + offset; + let is_selected = row_index == self.selected_index; + let selector = if is_selected { + Accent::HeadMarker.paint_ansi(markers::HEAD) + } else { + " ".to_string() + }; + let line = if is_selected { + Accent::HeadMarker.paint_ansi(&row.line) + } else { + row.line.clone() + }; + + format!("{selector} {line}") + }) + .collect::>(); + + lines.push(String::new()); + lines.push("↑/↓ or j/k move • Enter switch • Esc/q cancel".into()); + + if let Some(branch_name) = &self.untracked_current_branch { + lines.push(format!( + "Current branch '{}' is untracked; starting on '{}'.", + branch_name, self.rows[0].branch_name + )); + } + + lines.join("\n") + } + + fn selected_branch_name(&self) -> &str { + &self.rows[self.selected_index].branch_name + } +} + +pub(super) fn run(view: &TreeView) -> io::Result { + let mut state = InteractiveState::new(view); + let mut terminal = InteractiveTerminal::start()?; + + loop { + terminal.render(&state.render())?; + + let Some(event) = read_input_event()? else { + continue; + }; + + if let Some(outcome) = state.apply_event(event) { + terminal.finish()?; + return Ok(outcome); + } + } +} + +pub(super) fn run_scripted( + view: &TreeView, + events: &[InputEvent], +) -> io::Result { + let mut state = InteractiveState::new(view); + + for event in events { + if let Some(outcome) = state.apply_event(*event) { + return Ok(outcome); + } + } + + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "scripted switch session ended without Enter, Esc, or q", + )) +} + +pub(super) fn scripted_events_from_env() -> io::Result>> { + let Some(value) = env::var_os(SCRIPTED_EVENTS_ENV) else { + return Ok(None); + }; + + let mut events = Vec::new(); + for token in value.to_string_lossy().split(',').map(str::trim) { + if token.is_empty() { + continue; + } + + events.push(parse_scripted_event(token)?); + } + + Ok(Some(events)) +} + +fn parse_scripted_event(token: &str) -> io::Result { + match token { + "up" | "k" => Ok(InputEvent::Up), + "down" | "j" => Ok(InputEvent::Down), + "enter" => Ok(InputEvent::Confirm), + "esc" | "q" | "cancel" => Ok(InputEvent::Cancel), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsupported scripted switch event '{token}' in {SCRIPTED_EVENTS_ENV}"), + )), + } +} + +fn read_input_event() -> io::Result> { + loop { + match event::read()? { + Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { + return Ok(match key_event.code { + KeyCode::Up | KeyCode::Char('k') => Some(InputEvent::Up), + KeyCode::Down | KeyCode::Char('j') => Some(InputEvent::Down), + KeyCode::Enter => Some(InputEvent::Confirm), + KeyCode::Esc | KeyCode::Char('q') => Some(InputEvent::Cancel), + _ => None, + }); + } + Event::Key(_) => continue, + _ => return Ok(None), + } + } +} + +fn visible_range(row_count: usize, selected_index: usize, limit: usize) -> std::ops::Range { + let visible_count = cmp::min(row_count, limit); + let half = visible_count / 2; + let mut start = selected_index.saturating_sub(half); + + if start + visible_count > row_count { + start = row_count.saturating_sub(visible_count); + } + + start..start + visible_count +} + +struct InteractiveTerminal { + stdout: io::Stdout, + rendered_line_count: usize, + active: bool, +} + +impl InteractiveTerminal { + fn start() -> io::Result { + terminal::enable_raw_mode()?; + + let mut stdout = io::stdout(); + stdout.queue(Hide)?; + stdout.flush()?; + + Ok(Self { + stdout, + rendered_line_count: 0, + active: true, + }) + } + + fn render(&mut self, frame: &str) -> io::Result<()> { + self.move_to_frame_top()?; + self.stdout.queue(Clear(ClearType::FromCursorDown))?; + write!(self.stdout, "{}", terminal_frame_text(frame))?; + self.stdout.flush()?; + self.rendered_line_count = frame_line_count(frame); + Ok(()) + } + + fn finish(&mut self) -> io::Result<()> { + self.clear_rendered()?; + self.restore_terminal() + } + + fn clear_rendered(&mut self) -> io::Result<()> { + self.move_to_frame_top()?; + self.stdout.queue(Clear(ClearType::FromCursorDown))?; + self.stdout.flush()?; + self.rendered_line_count = 0; + Ok(()) + } + + fn move_to_frame_top(&mut self) -> io::Result<()> { + self.stdout.queue(MoveToColumn(0))?; + + if self.rendered_line_count > 1 { + self.stdout + .queue(MoveUp((self.rendered_line_count - 1) as u16))?; + } + + Ok(()) + } + + fn restore_terminal(&mut self) -> io::Result<()> { + self.stdout.queue(Show)?; + self.stdout.flush()?; + terminal::disable_raw_mode()?; + self.active = false; + Ok(()) + } +} + +impl Drop for InteractiveTerminal { + fn drop(&mut self) { + if !self.active { + return; + } + + let _ = self.clear_rendered(); + let _ = self.stdout.queue(Show); + let _ = self.stdout.flush(); + let _ = terminal::disable_raw_mode(); + } +} + +fn frame_line_count(frame: &str) -> usize { + cmp::max(frame.lines().count(), 1) +} + +fn terminal_frame_text(frame: &str) -> String { + frame.replace('\n', "\r\n") +} + +#[cfg(test)] +mod tests { + use super::{ + InputEvent, InteractiveOutcome, InteractiveState, terminal_frame_text, visible_range, + }; + use crate::core::tree::{TreeLabel, TreeNode, TreeView}; + use crate::ui::markers; + use crate::ui::palette::Accent; + + #[test] + fn starts_on_current_tracked_branch() { + let state = InteractiveState::new(&sample_view(Some("feat/auth-ui"))); + + assert_eq!(state.selected_branch_name(), "feat/auth-ui"); + assert_eq!(state.untracked_current_branch, None); + let rendered = state.render(); + assert!(rendered.contains(&Accent::HeadMarker.paint_ansi(markers::HEAD))); + assert!(rendered.contains("↑/↓ or j/k move • Enter switch • Esc/q cancel")); + assert!(rendered.contains(Accent::HeadMarker.ansi())); + } + + #[test] + fn starts_on_trunk_when_current_branch_is_untracked() { + let state = InteractiveState::new(&sample_view(Some("scratch"))); + + assert_eq!(state.selected_branch_name(), "main"); + assert_eq!(state.untracked_current_branch.as_deref(), Some("scratch")); + assert!( + state + .render() + .contains("Current branch 'scratch' is untracked") + ); + } + + #[test] + fn clamps_navigation_to_top_and_bottom() { + let mut state = InteractiveState::new(&sample_view(Some("main"))); + + state.apply_event(InputEvent::Up); + assert_eq!(state.selected_branch_name(), "main"); + + for _ in 0..10 { + state.apply_event(InputEvent::Down); + } + assert_eq!(state.selected_branch_name(), "feat/billing"); + } + + #[test] + fn supports_arrow_and_vim_style_vertical_navigation() { + let mut state = InteractiveState::new(&sample_view(Some("main"))); + + state.apply_event(InputEvent::Down); + assert_eq!(state.selected_branch_name(), "feat/auth"); + + state.apply_event(InputEvent::Up); + assert_eq!(state.selected_branch_name(), "main"); + } + + #[test] + fn confirms_or_cancels_selection() { + let mut state = InteractiveState::new(&sample_view(Some("main"))); + state.apply_event(InputEvent::Down); + + assert_eq!( + state.apply_event(InputEvent::Confirm), + Some(InteractiveOutcome::Selected("feat/auth".into())) + ); + assert_eq!( + InteractiveState::new(&sample_view(Some("main"))).apply_event(InputEvent::Cancel), + Some(InteractiveOutcome::Cancelled) + ); + } + + #[test] + fn limits_rendered_rows_to_viewport() { + let mut rows = Vec::new(); + for index in 0..20 { + rows.push(TreeNode { + branch_name: format!("feat/{index}"), + is_current: index == 10, + pull_request_number: None, + children: vec![], + }); + } + + let state = InteractiveState::new(&TreeView { + root_label: Some(TreeLabel { + branch_name: "main".into(), + is_current: false, + pull_request_number: None, + }), + roots: rows, + current_branch_name: Some("feat/10".into()), + is_current_visible: true, + current_branch_suffix: None, + }); + + let rendered = state.render(); + let branch_lines = rendered + .lines() + .take_while(|line| !line.is_empty()) + .collect::>(); + + assert_eq!(branch_lines.len(), 12); + assert!(branch_lines.iter().any(|line| line.contains("feat/10"))); + } + + #[test] + fn centers_selected_row_within_visible_range_when_possible() { + assert_eq!(visible_range(20, 0, 12), 0..12); + assert_eq!(visible_range(20, 10, 12), 4..16); + assert_eq!(visible_range(20, 19, 12), 8..20); + } + + #[test] + fn terminal_output_uses_carriage_return_line_breaks() { + assert_eq!(terminal_frame_text("one\ntwo"), "one\r\ntwo"); + } + + fn sample_view(current_branch_name: Option<&str>) -> TreeView { + TreeView { + root_label: Some(TreeLabel { + branch_name: "main".into(), + is_current: current_branch_name == Some("main"), + pull_request_number: None, + }), + roots: vec![ + TreeNode { + branch_name: "feat/auth".into(), + is_current: current_branch_name == Some("feat/auth"), + pull_request_number: Some(101), + children: vec![TreeNode { + branch_name: "feat/auth-ui".into(), + is_current: current_branch_name == Some("feat/auth-ui"), + pull_request_number: Some(102), + children: vec![], + }], + }, + TreeNode { + branch_name: "feat/billing".into(), + is_current: current_branch_name == Some("feat/billing"), + pull_request_number: None, + children: vec![], + }, + ], + current_branch_name: current_branch_name.map(str::to_string), + is_current_visible: current_branch_name != Some("scratch"), + current_branch_suffix: None, + } + } +} diff --git a/src/cli/switch/mod.rs b/src/cli/switch/mod.rs new file mode 100644 index 0000000..0363b8f --- /dev/null +++ b/src/cli/switch/mod.rs @@ -0,0 +1,87 @@ +mod interactive; + +use std::io; +use std::io::IsTerminal; + +use clap::Args; + +use crate::core::git; +use crate::core::switch::{self, SwitchDisposition, SwitchOptions}; + +use super::CommandOutcome; + +#[derive(Args, Debug, Clone, Default)] +pub struct SwitchArgs { + /// Switch directly to the named local branch + pub branch_name: Option, +} + +pub fn execute(args: SwitchArgs) -> io::Result { + match args + .branch_name + .as_deref() + .map(str::trim) + .filter(|branch_name| !branch_name.is_empty()) + { + Some(branch_name) => execute_direct(branch_name), + None => execute_interactive(), + } +} + +fn execute_direct(branch_name: &str) -> io::Result { + let outcome = switch::run(&SwitchOptions { + branch_name: branch_name.to_string(), + })?; + + if outcome.status.success() { + match outcome.disposition { + SwitchDisposition::Switched => println!("Switched to '{}'.", outcome.branch_name), + SwitchDisposition::AlreadyCurrent => println!("Already on '{}'.", outcome.branch_name), + } + } + + Ok(CommandOutcome { + status: outcome.status, + }) +} + +fn execute_interactive() -> io::Result { + let view = switch::load_interactive_tree_view()?; + + let outcome = match interactive::scripted_events_from_env()? { + Some(events) => interactive::run_scripted(&view, &events)?, + None => { + if !(io::stdin().is_terminal() && io::stdout().is_terminal()) { + return Err(io::Error::other( + "dgr switch interactive mode requires an interactive terminal; pass a branch name to switch directly", + )); + } + + interactive::run(&view)? + } + }; + + match outcome { + interactive::InteractiveOutcome::Selected(branch_name) => execute_direct(&branch_name), + interactive::InteractiveOutcome::Cancelled => Ok(CommandOutcome { + status: git::success_status()?, + }), + } +} + +#[cfg(test)] +mod tests { + use super::SwitchArgs; + + #[test] + fn preserves_optional_branch_name() { + assert_eq!( + SwitchArgs { + branch_name: Some("feat/auth".into()), + } + .branch_name + .as_deref(), + Some("feat/auth") + ); + } +} diff --git a/src/cli/tree/mod.rs b/src/cli/tree/mod.rs index d29f47c..e8945d9 100644 --- a/src/cli/tree/mod.rs +++ b/src/cli/tree/mod.rs @@ -1,4 +1,5 @@ mod render; +mod rows; use std::io; @@ -9,6 +10,7 @@ use crate::core::tree::{self, TreeOptions}; use super::CommandOutcome; pub(super) use render::{render_branch_lineage, render_stack_tree}; +pub(super) use rows::{StackTreeRow, stack_tree_rows}; #[derive(Args, Debug, Clone, Default)] pub struct TreeArgs { diff --git a/src/cli/tree/render.rs b/src/cli/tree/render.rs index 5e5b45d..b04d2fc 100644 --- a/src/cli/tree/render.rs +++ b/src/cli/tree/render.rs @@ -1,9 +1,10 @@ -use crate::cli::common; use crate::core::graph::BranchLineageNode; -use crate::core::tree::{TreeLabel, TreeView}; +use crate::core::tree::TreeView; use crate::ui::markers; use crate::ui::palette::Accent; +use super::rows::stack_tree_rows; + pub fn render_branch_lineage(lineage: &[BranchLineageNode]) -> String { let mut lines = Vec::new(); @@ -19,12 +20,11 @@ pub fn render_branch_lineage(lineage: &[BranchLineageNode]) -> String { } pub fn render_stack_tree(view: &TreeView) -> String { - let mut rendered = common::render_tree( - view.root_label.as_ref().map(format_tree_label), - &view.roots, - &|node| format_branch_label(&node.branch_name, node.is_current, node.pull_request_number), - &|node| node.children.as_slice(), - ); + let mut rendered = stack_tree_rows(view) + .into_iter() + .map(|row| row.line) + .collect::>() + .join("\n"); if !view.is_current_visible { if let Some(current_branch) = &view.current_branch_name { @@ -45,14 +45,6 @@ pub fn render_stack_tree(view: &TreeView) -> String { rendered } -fn format_tree_label(root_label: &TreeLabel) -> String { - format_branch_label( - &root_label.branch_name, - root_label.is_current, - root_label.pull_request_number, - ) -} - fn format_branch_text(branch_name: &str, pull_request_number: Option) -> String { match pull_request_number { Some(number) => format!("{branch_name} (#{number})"), @@ -79,17 +71,7 @@ fn format_branch_label( } fn format_lineage_branch(branch: &BranchLineageNode, is_current: bool) -> String { - let label = format_branch_text(&branch.branch_name, branch.pull_request_number); - - if is_current { - format!( - "{} {}", - Accent::BranchRef.paint_ansi(markers::CURRENT_BRANCH), - Accent::BranchRef.paint_ansi(&label) - ) - } else { - format!("{} {}", markers::NON_CURRENT_BRANCH, label) - } + format_branch_label(&branch.branch_name, is_current, branch.pull_request_number) } #[cfg(test)] diff --git a/src/cli/tree/rows.rs b/src/cli/tree/rows.rs new file mode 100644 index 0000000..295ecc3 --- /dev/null +++ b/src/cli/tree/rows.rs @@ -0,0 +1,184 @@ +use crate::core::tree::{TreeLabel, TreeNode, TreeView}; +use crate::ui::markers; +use crate::ui::palette::Accent; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StackTreeRow { + pub branch_name: String, + pub is_current: bool, + pub pull_request_number: Option, + pub line: String, +} + +pub(crate) fn stack_tree_rows(view: &TreeView) -> Vec { + let mut rows = Vec::new(); + + if let Some(root_label) = &view.root_label { + rows.push(row_from_label(root_label)); + } + + append_tree_rows(&mut rows, &view.roots, ""); + + rows +} + +fn append_tree_rows(rows: &mut Vec, nodes: &[TreeNode], prefix: &str) { + for (index, node) in nodes.iter().enumerate() { + let is_last = index + 1 == nodes.len(); + let connector = if is_last { "└──" } else { "├──" }; + rows.push(row_from_node(node, format!("{prefix}{connector} "))); + + let child_prefix = if is_last { + format!("{prefix} ") + } else { + format!("{prefix}│ ") + }; + + append_tree_rows(rows, &node.children, &child_prefix); + } +} + +fn row_from_label(label: &TreeLabel) -> StackTreeRow { + StackTreeRow { + branch_name: label.branch_name.clone(), + is_current: label.is_current, + pull_request_number: label.pull_request_number, + line: format_branch_label( + "", + &label.branch_name, + label.is_current, + label.pull_request_number, + ), + } +} + +fn row_from_node(node: &TreeNode, prefix: String) -> StackTreeRow { + StackTreeRow { + branch_name: node.branch_name.clone(), + is_current: node.is_current, + pull_request_number: node.pull_request_number, + line: format_branch_label( + &prefix, + &node.branch_name, + node.is_current, + node.pull_request_number, + ), + } +} + +fn format_branch_text(branch_name: &str, pull_request_number: Option) -> String { + match pull_request_number { + Some(number) => format!("{branch_name} (#{number})"), + None => branch_name.to_string(), + } +} + +fn format_branch_label( + prefix: &str, + branch_name: &str, + is_current: bool, + pull_request_number: Option, +) -> String { + let label = format_branch_text(branch_name, pull_request_number); + + if is_current { + format!( + "{prefix}{} {}", + Accent::BranchRef.paint_ansi(markers::CURRENT_BRANCH), + Accent::BranchRef.paint_ansi(&label) + ) + } else { + format!("{prefix}{} {label}", markers::NON_CURRENT_BRANCH) + } +} + +#[cfg(test)] +mod tests { + use super::{StackTreeRow, stack_tree_rows}; + use crate::core::tree::{TreeLabel, TreeNode, TreeView}; + + #[test] + fn builds_rows_in_tree_order_with_connectors_and_metadata() { + assert_eq!( + stack_tree_rows(&TreeView { + root_label: Some(TreeLabel { + branch_name: "main".into(), + is_current: false, + pull_request_number: None, + }), + roots: vec![ + TreeNode { + branch_name: "feat/auth".into(), + is_current: false, + pull_request_number: Some(101), + children: vec![TreeNode { + branch_name: "feat/auth-ui".into(), + is_current: true, + pull_request_number: None, + children: vec![], + }], + }, + TreeNode { + branch_name: "feat/billing".into(), + is_current: false, + pull_request_number: None, + children: vec![], + }, + ], + current_branch_name: Some("feat/auth-ui".into()), + is_current_visible: true, + current_branch_suffix: None, + }), + vec![ + StackTreeRow { + branch_name: "main".into(), + is_current: false, + pull_request_number: None, + line: "* main".into(), + }, + StackTreeRow { + branch_name: "feat/auth".into(), + is_current: false, + pull_request_number: Some(101), + line: "├── * feat/auth (#101)".into(), + }, + StackTreeRow { + branch_name: "feat/auth-ui".into(), + is_current: true, + pull_request_number: None, + line: "│ └── \u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeat/auth-ui\u{1b}[0m".into(), + }, + StackTreeRow { + branch_name: "feat/billing".into(), + is_current: false, + pull_request_number: None, + line: "└── * feat/billing".into(), + }, + ] + ); + } + + #[test] + fn omits_root_row_when_view_is_filtered_to_main_branch() { + assert_eq!( + stack_tree_rows(&TreeView { + root_label: None, + roots: vec![TreeNode { + branch_name: "feat/auth".into(), + is_current: false, + pull_request_number: None, + children: vec![], + }], + current_branch_name: Some("main".into()), + is_current_visible: false, + current_branch_suffix: None, + }), + vec![StackTreeRow { + branch_name: "feat/auth".into(), + is_current: false, + pull_request_number: None, + line: "└── * feat/auth".into(), + }] + ); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index cc78ce3..dcc33cc 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod pr; pub(crate) mod reparent; pub(crate) mod restack; pub(crate) mod store; +pub(crate) mod switch; pub(crate) mod sync; #[cfg(test)] pub(crate) mod test_support; diff --git a/src/core/switch.rs b/src/core/switch.rs new file mode 100644 index 0000000..b10b2a0 --- /dev/null +++ b/src/core/switch.rs @@ -0,0 +1,71 @@ +use std::io; +use std::process::ExitStatus; + +use crate::core::git; +use crate::core::tree; +use crate::core::tree::TreeView; +use crate::core::workflow; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SwitchOptions { + pub branch_name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SwitchDisposition { + Switched, + AlreadyCurrent, +} + +#[derive(Debug)] +pub struct SwitchOutcome { + pub status: ExitStatus, + pub branch_name: String, + pub disposition: SwitchDisposition, +} + +pub fn run(options: &SwitchOptions) -> io::Result { + ensure_switch_allowed()?; + + let branch_name = options.branch_name.trim(); + if branch_name.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "branch name cannot be empty", + )); + } + + if !git::branch_exists(branch_name)? { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("branch '{branch_name}' was not found"), + )); + } + + if git::current_branch_name_if_any()?.as_deref() == Some(branch_name) { + return Ok(SwitchOutcome { + status: git::success_status()?, + branch_name: branch_name.to_string(), + disposition: SwitchDisposition::AlreadyCurrent, + }); + } + + Ok(SwitchOutcome { + status: git::switch_branch(branch_name)?, + branch_name: branch_name.to_string(), + disposition: SwitchDisposition::Switched, + }) +} + +pub fn load_interactive_tree_view() -> io::Result { + ensure_switch_allowed()?; + tree::full_view( + "dagger is not initialized; run 'dgr init' first or pass a branch name to switch directly", + ) +} + +fn ensure_switch_allowed() -> io::Result<()> { + workflow::ensure_no_pending_operation_for_command("switch")?; + let repo = git::resolve_repo_context()?; + git::ensure_no_in_progress_operations(&repo, "switch") +} diff --git a/src/core/tree.rs b/src/core/tree.rs index 8adc8d9..9d91f22 100644 --- a/src/core/tree.rs +++ b/src/core/tree.rs @@ -45,28 +45,26 @@ pub struct TreeOutcome { pub fn run(options: &TreeOptions) -> io::Result { let status = git::probe_repo_status()?; - let session = open_initialized("dagger is not initialized")?; - let current_branch = git::current_branch_name_if_any()?; - let view = build_tree_view( - &session.state, - &session.config.trunk_branch, - current_branch.as_deref(), - ); + let view = full_view("dagger is not initialized")?; let view = filter_tree_view(view, options.branch_name.as_deref())?; Ok(TreeOutcome { status, view }) } pub(crate) fn focused_context_view(branch_name: &str) -> io::Result { - let session = open_initialized("dagger is not initialized")?; + let view = full_view("dagger is not initialized")?; + + focus_tree_view(view, branch_name) +} + +pub(crate) fn full_view(missing_message: &str) -> io::Result { + let session = open_initialized(missing_message)?; let current_branch = git::current_branch_name_if_any()?; - let view = build_tree_view( + Ok(build_tree_view( &session.state, &session.config.trunk_branch, current_branch.as_deref(), - ); - - focus_tree_view(view, branch_name) + )) } fn build_tree_view( diff --git a/tests/pause_guard.rs b/tests/pause_guard.rs index f2c56f6..8720292 100644 --- a/tests/pause_guard.rs +++ b/tests/pause_guard.rs @@ -127,3 +127,17 @@ fn reparent_rejects_immediately_while_commit_restack_is_paused() { ); }); } + +#[test] +fn switch_rejects_immediately_while_commit_restack_is_paused() { + with_temp_repo("dgr-pause-guard", |repo| { + let operation = pause_commit_restack(repo); + + assert_command_rejected_while_commit_is_paused( + repo, + &["switch", "main"], + "switch", + &operation, + ); + }); +} diff --git a/tests/switch.rs b/tests/switch.rs new file mode 100644 index 0000000..e05f4d8 --- /dev/null +++ b/tests/switch.rs @@ -0,0 +1,124 @@ +mod support; + +use support::{ + commit_file, dgr, dgr_ok, dgr_ok_with_env, git_ok, git_stdout, initialize_main_repo, + load_state_json, with_temp_repo, +}; + +#[test] +fn switch_directly_to_tracked_branch_preserves_dagger_state() { + with_temp_repo("dgr-switch-cli", |repo| { + initialize_main_repo(repo); + dgr_ok(repo, &["init"]); + dgr_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["checkout", "main"]); + + let state_before = load_state_json(repo); + let output = dgr_ok(repo, &["switch", "feat/auth"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert_eq!(stdout.trim_end(), "Switched to 'feat/auth'."); + assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "feat/auth"); + assert_eq!(load_state_json(repo), state_before); + }); +} + +#[test] +fn switch_directly_to_untracked_branch_without_dagger_init() { + with_temp_repo("dgr-switch-cli", |repo| { + initialize_main_repo(repo); + git_ok(repo, &["checkout", "-b", "scratch"]); + commit_file(repo, "scratch.txt", "scratch\n", "feat: scratch"); + git_ok(repo, &["checkout", "main"]); + + let output = dgr_ok(repo, &["switch", "scratch"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert_eq!(stdout.trim_end(), "Switched to 'scratch'."); + assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "scratch"); + }); +} + +#[test] +fn switch_reports_when_branch_is_already_checked_out() { + with_temp_repo("dgr-switch-cli", |repo| { + initialize_main_repo(repo); + + let output = dgr_ok(repo, &["switch", "main"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert_eq!(stdout.trim_end(), "Already on 'main'."); + assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "main"); + }); +} + +#[test] +fn switch_direct_mode_reports_missing_branch() { + with_temp_repo("dgr-switch-cli", |repo| { + initialize_main_repo(repo); + + let output = dgr(repo, &["switch", "missing"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "unexpected stdout:\n{stdout}"); + assert!(stderr.contains("branch 'missing' was not found")); + }); +} + +#[test] +fn switch_interactive_script_can_confirm_selection() { + with_temp_repo("dgr-switch-cli", |repo| { + initialize_main_repo(repo); + dgr_ok(repo, &["init"]); + dgr_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["checkout", "main"]); + + let output = dgr_ok_with_env( + repo, + &["switch"], + &[("DGR_SWITCH_TEST_EVENTS", "down,enter")], + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert_eq!(stdout.trim_end(), "Switched to 'feat/auth'."); + assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "feat/auth"); + }); +} + +#[test] +fn switch_interactive_script_can_cancel_without_switching() { + with_temp_repo("dgr-switch-cli", |repo| { + initialize_main_repo(repo); + dgr_ok(repo, &["init"]); + dgr_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["checkout", "main"]); + + let output = dgr_ok_with_env(repo, &["switch"], &[("DGR_SWITCH_TEST_EVENTS", "q")]); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.is_empty(), "unexpected stdout:\n{stdout}"); + assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "main"); + }); +} + +#[test] +fn switch_interactive_requires_a_tty_without_scripted_input() { + with_temp_repo("dgr-switch-cli", |repo| { + initialize_main_repo(repo); + dgr_ok(repo, &["init"]); + + let output = dgr(repo, &["switch"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "unexpected stdout:\n{stdout}"); + assert!(stderr.contains("dgr switch interactive mode requires an interactive terminal")); + assert!(stderr.contains("pass a branch name to switch directly")); + }); +}