diff --git a/src/cli/adopt/mod.rs b/src/cli/adopt/mod.rs index 935e52d..def2399 100644 --- a/src/cli/adopt/mod.rs +++ b/src/cli/adopt/mod.rs @@ -3,7 +3,6 @@ use std::io; use clap::Args; use crate::core::adopt::{self, AdoptOptions, AdoptOutcome}; -use crate::core::tree; use super::CommandOutcome; use super::common; @@ -23,8 +22,7 @@ pub fn execute(args: AdoptArgs) -> io::Result { let outcome = adopt::apply(&plan)?; if outcome.status.success() { - let view = tree::focused_context_view(&outcome.branch_name)?; - let rendered_tree = super::tree::render_stack_tree(&view); + let rendered_tree = super::tree::render_focused_context_tree(&outcome.branch_name, None)?; let output = format_adopt_success_output(&outcome, &rendered_tree); if !output.is_empty() { println!("{output}"); diff --git a/src/cli/orphan/mod.rs b/src/cli/orphan/mod.rs index 4877b52..aa9fa3f 100644 --- a/src/cli/orphan/mod.rs +++ b/src/cli/orphan/mod.rs @@ -3,7 +3,6 @@ use std::io; use clap::Args; use crate::core::orphan::{self, OrphanOptions, OrphanOutcome}; -use crate::core::tree; use super::CommandOutcome; use super::common; @@ -19,8 +18,10 @@ pub fn execute(args: OrphanArgs) -> io::Result { let outcome = orphan::apply(&plan)?; if outcome.status.success() { - let view = tree::focused_context_view(&outcome.parent_branch_name)?; - let rendered_tree = super::tree::render_stack_tree(&view); + let rendered_tree = super::tree::render_focused_context_tree( + &outcome.parent_branch_name, + Some((&outcome.branch_name, "(orphaned)")), + )?; let output = format_orphan_success_output(&outcome, &rendered_tree); if !output.is_empty() { println!("{output}"); diff --git a/src/cli/reparent/mod.rs b/src/cli/reparent/mod.rs index b7bd6c4..a332fdf 100644 --- a/src/cli/reparent/mod.rs +++ b/src/cli/reparent/mod.rs @@ -3,7 +3,6 @@ use std::io; use clap::Args; use crate::core::reparent::{self, ReparentOptions, ReparentOutcome, ReparentPlan}; -use crate::core::tree; use super::CommandOutcome; use super::common; @@ -23,8 +22,7 @@ pub fn execute(args: ReparentArgs) -> io::Result { let outcome = reparent::apply(&plan)?; if outcome.status.success() { - let view = tree::focused_context_view(&outcome.branch_name)?; - let rendered_tree = super::tree::render_stack_tree(&view); + let rendered_tree = super::tree::render_focused_context_tree(&outcome.branch_name, None)?; let output = format_reparent_success_output(&outcome, &rendered_tree); if !output.is_empty() { println!("{output}"); diff --git a/src/cli/sync/mod.rs b/src/cli/sync/mod.rs index 56da08a..1f2b8e7 100644 --- a/src/cli/sync/mod.rs +++ b/src/cli/sync/mod.rs @@ -51,8 +51,8 @@ pub fn execute(args: SyncArgs) -> io::Result { } } SyncCompletion::Adopt(adopt_outcome) if adopt_outcome.status.success() => { - let view = tree::focused_context_view(&adopt_outcome.branch_name)?; - let rendered_tree = super::tree::render_stack_tree(&view); + let rendered_tree = + super::tree::render_focused_context_tree(&adopt_outcome.branch_name, None)?; let output = super::adopt::format_adopt_success_output(adopt_outcome, &rendered_tree); if !output.is_empty() { @@ -102,8 +102,10 @@ pub fn execute(args: SyncArgs) -> io::Result { } } SyncCompletion::Orphan(orphan_outcome) if orphan_outcome.status.success() => { - let view = tree::focused_context_view(&orphan_outcome.parent_branch_name)?; - let rendered_tree = super::tree::render_stack_tree(&view); + let rendered_tree = super::tree::render_focused_context_tree( + &orphan_outcome.parent_branch_name, + Some((&orphan_outcome.branch_name, "(orphaned)")), + )?; let output = super::orphan::format_orphan_success_output(orphan_outcome, &rendered_tree); if !output.is_empty() { @@ -111,8 +113,8 @@ pub fn execute(args: SyncArgs) -> io::Result { } } SyncCompletion::Reparent(reparent_outcome) if reparent_outcome.status.success() => { - let view = tree::focused_context_view(&reparent_outcome.branch_name)?; - let rendered_tree = super::tree::render_stack_tree(&view); + let rendered_tree = + super::tree::render_focused_context_tree(&reparent_outcome.branch_name, None)?; let output = super::reparent::format_reparent_success_output( reparent_outcome, &rendered_tree, diff --git a/src/cli/sync/render.rs b/src/cli/sync/render.rs index 4c28ab4..79e6802 100644 --- a/src/cli/sync/render.rs +++ b/src/cli/sync/render.rs @@ -340,6 +340,9 @@ mod tests { }, ], }], + current_branch_name: Some("main".into()), + is_current_visible: true, + current_branch_suffix: None, } } diff --git a/src/cli/tree/mod.rs b/src/cli/tree/mod.rs index 8e79d6d..d29f47c 100644 --- a/src/cli/tree/mod.rs +++ b/src/cli/tree/mod.rs @@ -35,6 +35,21 @@ impl From for TreeOptions { } } +pub(super) fn render_focused_context_tree( + branch_name: &str, + suffix_for_current_branch: Option<(&str, &str)>, +) -> io::Result { + let mut view = tree::focused_context_view(branch_name)?; + + if let Some((current_branch_name, suffix)) = suffix_for_current_branch { + if view.current_branch_name.as_deref() == Some(current_branch_name) { + view.current_branch_suffix = Some(suffix.to_string()); + } + } + + Ok(render::render_stack_tree(&view)) +} + #[cfg(test)] mod tests { use super::TreeArgs; diff --git a/src/cli/tree/render.rs b/src/cli/tree/render.rs index 9b058d3..5e5b45d 100644 --- a/src/cli/tree/render.rs +++ b/src/cli/tree/render.rs @@ -19,12 +19,30 @@ pub fn render_branch_lineage(lineage: &[BranchLineageNode]) -> String { } pub fn render_stack_tree(view: &TreeView) -> String { - common::render_tree( + 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(), - ) + ); + + if !view.is_current_visible { + if let Some(current_branch) = &view.current_branch_name { + let label = match &view.current_branch_suffix { + Some(suffix) => format!("{current_branch} {suffix}"), + None => current_branch.clone(), + }; + + rendered.push_str("\n\n"); + rendered.push_str(&format!( + "{} {}", + Accent::BranchRef.paint_ansi(markers::CURRENT_BRANCH), + Accent::BranchRef.paint_ansi(&label) + )); + } + } + + rendered } fn format_tree_label(root_label: &TreeLabel) -> String { @@ -56,7 +74,7 @@ fn format_branch_label( Accent::BranchRef.paint_ansi(&label) ) } else { - label + format!("{} {}", markers::NON_CURRENT_BRANCH, label) } } @@ -70,7 +88,7 @@ fn format_lineage_branch(branch: &BranchLineageNode, is_current: bool) -> String Accent::BranchRef.paint_ansi(&label) ) } else { - format!("{} {}", markers::NON_CURRENT_BRANCH, label) + format!("{} {}", markers::NON_CURRENT_BRANCH, label) } } @@ -102,9 +120,9 @@ mod tests { concat!( "\u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeature/api-followup\u{1b}[0m\n", "│ \n", - "* feature/api\n", + "* feature/api\n", "│ \n", - "* main" + "* main" ) ); } @@ -141,9 +159,9 @@ mod tests { concat!( "\u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeature/api-followup (#43)\u{1b}[0m\n", "│ \n", - "* feature/api (#42)\n", + "* feature/api (#42)\n", "│ \n", - "* main" + "* main" ) ); } @@ -199,19 +217,22 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-ui".into()), + is_current_visible: true, + current_branch_suffix: None, }); assert_eq!( rendered, concat!( - "main\n", - "├── feat/auth\n", - "│ ├── feat/auth-api\n", - "│ │ └── feat/auth-api-tests\n", + "* main\n", + "├── * feat/auth\n", + "│ ├── * feat/auth-api\n", + "│ │ └── * feat/auth-api-tests\n", "│ └── \u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeat/auth-ui\u{1b}[0m\n", - "├── feat/billing\n", - "│ └── feat/billing-retry\n", - "└── docs/readme" + "├── * feat/billing\n", + "│ └── * feat/billing-retry\n", + "└── * docs/readme" ) ); } @@ -243,14 +264,17 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-ui".into()), + is_current_visible: true, + current_branch_suffix: None, }); assert_eq!( rendered, concat!( - "feat/auth\n", - "├── feat/auth-api\n", - "│ └── feat/auth-api-tests\n", + "* feat/auth\n", + "├── * feat/auth-api\n", + "│ └── * feat/auth-api-tests\n", "└── \u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeat/auth-ui\u{1b}[0m" ) ); @@ -278,15 +302,70 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-ui".into()), + is_current_visible: true, + current_branch_suffix: None, }); assert_eq!( rendered, concat!( - "feat/auth (#42)\n", - "├── feat/auth-api (#43)\n", + "* feat/auth (#42)\n", + "├── * feat/auth-api (#43)\n", "└── \u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeat/auth-ui (#44)\u{1b}[0m" ) ); } + + #[test] + fn renders_hidden_tracked_current_branch_at_bottom() { + let rendered = render_stack_tree(&TreeView { + root_label: Some(TreeLabel { + branch_name: "feat/billing".into(), + is_current: false, + pull_request_number: None, + }), + roots: vec![], + current_branch_name: Some("feat/auth-ui".into()), + is_current_visible: false, + current_branch_suffix: None, + }); + + assert_eq!( + rendered, + concat!( + "* feat/billing\n\n", + "\u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeat/auth-ui\u{1b}[0m" + ) + ); + } + + #[test] + fn renders_hidden_orphaned_current_branch_at_bottom() { + let rendered = render_stack_tree(&TreeView { + root_label: Some(TreeLabel { + branch_name: "main".into(), + is_current: false, + pull_request_number: None, + }), + roots: vec![TreeNode { + branch_name: "feat/tracked".into(), + is_current: false, + pull_request_number: None, + children: vec![], + }], + current_branch_name: Some("feat/untracked".into()), + is_current_visible: false, + current_branch_suffix: Some("(orphaned)".into()), + }); + + assert_eq!( + rendered, + concat!( + "* main\n", + "└── * feat/tracked\n\n", + "\u{1b}[32m✓\u{1b}[0m \u{1b}[32mfeat/untracked (orphaned)\u{1b}[0m" + ) + ); + } } diff --git a/src/core/tree.rs b/src/core/tree.rs index d43eda3..8adc8d9 100644 --- a/src/core/tree.rs +++ b/src/core/tree.rs @@ -32,6 +32,9 @@ pub struct TreeNode { pub struct TreeView { pub root_label: Option, pub roots: Vec, + pub current_branch_name: Option, + pub is_current_visible: bool, + pub current_branch_suffix: Option, } #[derive(Debug)] @@ -44,21 +47,26 @@ 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 full_view = build_tree_view( + let view = build_tree_view( &session.state, &session.config.trunk_branch, current_branch.as_deref(), ); - let view = filter_tree_view(full_view, options.branch_name.as_deref())?; + 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 full_view = build_tree_view(&session.state, &session.config.trunk_branch, None); + let current_branch = git::current_branch_name_if_any()?; + let view = build_tree_view( + &session.state, + &session.config.trunk_branch, + current_branch.as_deref(), + ); - focus_tree_view(full_view, branch_name) + focus_tree_view(view, branch_name) } fn build_tree_view( @@ -99,20 +107,42 @@ fn build_tree_view( sort_branch_nodes(children, &order_lookup); } - TreeView { + let roots: Vec = root_nodes + .into_iter() + .map(|node| build_tree_node(node, current_branch, &child_lookup)) + .collect(); + + with_current_visibility(TreeView { root_label: Some(TreeLabel { branch_name: trunk_branch.to_string(), is_current: current_branch == Some(trunk_branch), pull_request_number: None, }), - roots: root_nodes - .into_iter() - .map(|node| build_tree_node(node, current_branch, &child_lookup)) - .collect(), - } + roots, + current_branch_name: current_branch.map(String::from), + is_current_visible: false, + current_branch_suffix: None, + }) +} + +fn is_current_visible_in_node(node: &TreeNode) -> bool { + node.is_current || node.children.iter().any(is_current_visible_in_node) +} + +fn is_current_visible(view: &TreeView) -> bool { + view.root_label + .as_ref() + .map(|label| label.is_current) + .unwrap_or(false) + || view.roots.iter().any(is_current_visible_in_node) +} + +fn with_current_visibility(mut view: TreeView) -> TreeView { + view.is_current_visible = is_current_visible(&view); + view } -fn filter_tree_view(view: TreeView, requested_branch: Option<&str>) -> io::Result { +fn filter_tree_view(mut view: TreeView, requested_branch: Option<&str>) -> io::Result { let Some(requested_branch) = requested_branch .map(str::trim) .filter(|branch| !branch.is_empty()) @@ -125,10 +155,8 @@ fn filter_tree_view(view: TreeView, requested_branch: Option<&str>) -> io::Resul }; if requested_branch == root_label.branch_name { - return Ok(TreeView { - root_label: None, - roots: view.roots, - }); + view.root_label = None; + return Ok(with_current_visibility(view)); } let selected_node = view @@ -145,14 +173,14 @@ fn filter_tree_view(view: TreeView, requested_branch: Option<&str>) -> io::Resul ) })?; - Ok(TreeView { - root_label: Some(TreeLabel { - branch_name: selected_node.branch_name.clone(), - is_current: selected_node.is_current, - pull_request_number: selected_node.pull_request_number, - }), - roots: selected_node.children.clone(), - }) + view.root_label = Some(TreeLabel { + branch_name: selected_node.branch_name.clone(), + is_current: selected_node.is_current, + pull_request_number: selected_node.pull_request_number, + }); + view.roots = selected_node.children.clone(); + + Ok(with_current_visibility(view)) } fn focus_tree_view(view: TreeView, requested_branch: &str) -> io::Result { @@ -172,7 +200,7 @@ fn focus_tree_view(view: TreeView, requested_branch: &str) -> io::Result io::Result Option }) } -fn clear_current_flags(node: &mut TreeNode) { - node.is_current = false; - for child in &mut node.children { - clear_current_flags(child); - } -} - -fn mark_current_branch(node: &mut TreeNode, branch_name: &str) -> bool { - if node.branch_name == branch_name { - node.is_current = true; - return true; - } - - for child in &mut node.children { - if mark_current_branch(child, branch_name) { - return true; - } - } - - false -} - fn sort_branch_nodes(nodes: &mut Vec<&BranchNode>, order_lookup: &HashMap) { nodes.sort_by(|left, right| { left.created_at_unix_secs @@ -358,6 +364,9 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-api".into()), + is_current_visible: true, + current_branch_suffix: None, } ); } @@ -394,6 +403,9 @@ mod tests { }, ], }], + current_branch_name: Some("feat/auth-ui".into()), + is_current_visible: true, + current_branch_suffix: None, }; assert_eq!( @@ -423,6 +435,88 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-ui".into()), + is_current_visible: true, + current_branch_suffix: None, + } + ); + } + + #[test] + fn filtering_tree_marks_current_branch_hidden_when_outside_selected_subtree() { + let view = 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/billing".into(), + is_current: true, + pull_request_number: Some(102), + children: vec![], + }, + ], + current_branch_name: Some("feat/billing".into()), + is_current_visible: true, + current_branch_suffix: None, + }; + + assert_eq!( + filter_tree_view(view, Some("feat/auth")).unwrap(), + TreeView { + root_label: Some(TreeLabel { + branch_name: "feat/auth".into(), + is_current: false, + pull_request_number: Some(101), + }), + roots: vec![], + current_branch_name: Some("feat/billing".into()), + is_current_visible: false, + current_branch_suffix: None, + } + ); + } + + #[test] + fn filtering_trunk_marks_current_branch_hidden_when_trunk_header_is_removed() { + let view = TreeView { + root_label: Some(TreeLabel { + branch_name: "main".into(), + is_current: true, + pull_request_number: None, + }), + roots: vec![TreeNode { + branch_name: "feat/auth".into(), + is_current: false, + pull_request_number: Some(101), + children: vec![], + }], + current_branch_name: Some("main".into()), + is_current_visible: true, + current_branch_suffix: None, + }; + + assert_eq!( + filter_tree_view(view, Some("main")).unwrap(), + TreeView { + root_label: None, + roots: vec![TreeNode { + branch_name: "feat/auth".into(), + is_current: false, + pull_request_number: Some(101), + children: vec![], + }], + current_branch_name: Some("main".into()), + is_current_visible: false, + current_branch_suffix: None, } ); } @@ -443,7 +537,7 @@ mod tests { children: vec![ TreeNode { branch_name: "feat/auth-api".into(), - is_current: false, + is_current: true, pull_request_number: Some(102), children: vec![TreeNode { branch_name: "feat/auth-api-tests".into(), @@ -467,6 +561,9 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-api".into()), + is_current_visible: true, + current_branch_suffix: None, }; assert_eq!( @@ -493,6 +590,67 @@ mod tests { }], }], }], + current_branch_name: Some("feat/auth-api".into()), + is_current_visible: true, + current_branch_suffix: None, + } + ); + } + + #[test] + fn focusing_tree_marks_current_branch_hidden_when_outside_selected_path() { + let view = 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-api".into(), + is_current: false, + pull_request_number: Some(102), + children: vec![], + }], + }, + TreeNode { + branch_name: "feat/billing".into(), + is_current: true, + pull_request_number: Some(103), + children: vec![], + }, + ], + current_branch_name: Some("feat/billing".into()), + is_current_visible: true, + current_branch_suffix: None, + }; + + assert_eq!( + focus_tree_view(view, "feat/auth-api").unwrap(), + 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-api".into(), + is_current: false, + pull_request_number: Some(102), + children: vec![], + }], + }], + current_branch_name: Some("feat/billing".into()), + is_current_visible: false, + current_branch_suffix: None, } ); } diff --git a/tests/adopt.rs b/tests/adopt.rs index 885a4c8..a7f7c14 100644 --- a/tests/adopt.rs +++ b/tests/adopt.rs @@ -18,7 +18,7 @@ fn adopts_current_branch_onto_trunk_without_rebase() { let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); assert!(stdout.contains("Adopted 'feat/adopted' under 'main'.")); - assert!(stdout.contains("main\n└── ✓ feat/adopted")); + assert!(stdout.contains("* main\n└── ✓ feat/adopted")); let state = load_state_json(repo); let adopted = find_node(&state, "feat/adopted").unwrap(); @@ -45,7 +45,7 @@ fn adopts_named_branch_with_rebase_and_restores_original_branch() { assert!(stdout.contains("Adopted 'feat/auth-ui' under 'feat/auth'.")); assert!(stdout.contains("Restacked 'feat/auth-ui' onto 'feat/auth'.")); assert!(stdout.contains("Returned to 'feat/auth' after adopt.")); - assert!(stdout.contains("main\n└── feat/auth\n └── ✓ feat/auth-ui")); + assert!(stdout.contains("* main\n└── ✓ feat/auth\n └── * feat/auth-ui")); let merge_base = git_stdout(repo, &["merge-base", "feat/auth", "feat/auth-ui"]); let parent_head = git_stdout(repo, &["rev-parse", "feat/auth"]); @@ -59,6 +59,42 @@ fn adopts_named_branch_with_rebase_and_restores_original_branch() { }); } +#[test] +fn adopts_named_branch_and_shows_unrelated_checked_out_branch_below_tree() { + with_temp_repo("dgr-adopt-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"]); + dgr_ok(repo, &["branch", "feat/billing"]); + commit_file(repo, "billing.txt", "billing\n", "feat: billing"); + git_ok(repo, &["checkout", "main"]); + git_ok(repo, &["checkout", "-b", "feat/auth-ui"]); + commit_file(repo, "ui.txt", "ui\n", "feat: auth ui"); + git_ok(repo, &["checkout", "feat/billing"]); + + let output = dgr_ok(repo, &["adopt", "feat/auth-ui", "-p", "feat/auth"]); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(stdout.contains("Adopted 'feat/auth-ui' under 'feat/auth'.")); + assert!(stdout.contains("Restacked 'feat/auth-ui' onto 'feat/auth'.")); + assert!(stdout.contains("Returned to 'feat/billing' after adopt.")); + assert!( + stdout.contains("* main\n└── * feat/auth\n └── * feat/auth-ui\n\n✓ feat/billing") + ); + assert_eq!( + git_stdout(repo, &["branch", "--show-current"]), + "feat/billing" + ); + + let state = load_state_json(repo); + let adopted = find_node(&state, "feat/auth-ui").unwrap(); + assert_eq!(adopted["base_ref"], "feat/auth"); + assert_eq!(adopted["parent"]["kind"], "branch"); + }); +} + #[test] fn leaves_rebase_open_when_adopt_rebase_conflicts() { with_temp_repo("dgr-adopt-cli", |repo| { diff --git a/tests/branch.rs b/tests/branch.rs index 7edbf4f..0d7fb8b 100644 --- a/tests/branch.rs +++ b/tests/branch.rs @@ -26,7 +26,7 @@ fn branch_command_renders_marked_lineage_and_tracks_parent() { let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); assert!(stdout.contains("Created and switched to 'feat/auth'.")); - assert!(stdout.contains("✓ feat/auth\n│ \n* main")); + assert!(stdout.contains("✓ feat/auth\n│ \n* main")); let state = load_state_json(repo); let node = find_node(&state, "feat/auth").unwrap(); @@ -47,7 +47,7 @@ fn init_reuses_marked_lineage_output_for_current_branch() { assert!(stdout.contains("Using existing Git repository.")); assert!(stdout.contains("Dagger is already initialized.")); - assert!(stdout.contains("✓ feat/auth\n│ \n* main")); + assert!(stdout.contains("✓ feat/auth\n│ \n* main")); }); } @@ -80,6 +80,6 @@ exit 1 let output = dgr_ok(repo, &["init"]); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); - assert!(stdout.contains("✓ feat/auth (#123)\n│ \n* main")); + assert!(stdout.contains("✓ feat/auth (#123)\n│ \n* main")); }); } diff --git a/tests/orphan.rs b/tests/orphan.rs index 85d4a76..5a91a28 100644 --- a/tests/orphan.rs +++ b/tests/orphan.rs @@ -1,9 +1,9 @@ mod support; use support::{ - active_rebase_head_name, commit_file, dgr, dgr_ok, find_archived_node, find_node, git_ok, - git_stdout, initialize_main_repo, load_events_json, load_operation_json, load_state_json, - overwrite_file, strip_ansi, with_temp_repo, + commit_file, dgr, dgr_ok, find_archived_node, find_node, git_ok, git_stdout, + initialize_main_repo, load_operation_json, load_state_json, overwrite_file, strip_ansi, + with_temp_repo, write_file, }; #[test] @@ -20,7 +20,7 @@ fn orphans_current_branch_without_descendants_and_keeps_local_branch() { assert!(stdout.contains("Orphaned 'feat/auth'. It is no longer tracked by dagger.")); assert_eq!( stdout.trim_end(), - "Orphaned 'feat/auth'. It is no longer tracked by dagger.\n\nmain" + "Orphaned 'feat/auth'. It is no longer tracked by dagger.\n\n* main\n\n✓ feat/auth (orphaned)" ); assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "feat/auth"); assert!(git_stdout(repo, &["branch", "--list", "feat/auth"]).contains("feat/auth")); @@ -28,13 +28,7 @@ fn orphans_current_branch_without_descendants_and_keeps_local_branch() { let state = load_state_json(repo); assert!(find_node(&state, "feat/auth").is_none()); assert!(find_archived_node(&state, "feat/auth").is_some()); - - let events = load_events_json(repo); - assert!(events.iter().any(|event| { - event["type"].as_str() == Some("branch_archived") - && event["branch_name"].as_str() == Some("feat/auth") - && event["reason"]["kind"].as_str() == Some("orphaned") - })); + assert!(load_operation_json(repo).is_none()); }); } @@ -56,7 +50,7 @@ fn orphans_named_branch_restacks_descendants_to_trunk_and_restores_original_bran assert!(stdout.contains("Returned to 'main' after orphaning.")); assert!(stdout.contains("Restacked:")); assert!(stdout.contains("- feat/auth-ui onto main")); - assert!(stdout.contains("main\n└── feat/auth-ui")); + assert!(stdout.contains("✓ main\n└── * feat/auth-ui")); assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "main"); assert!(git_stdout(repo, &["branch", "--list", "feat/auth"]).contains("feat/auth")); assert_eq!( @@ -65,10 +59,12 @@ fn orphans_named_branch_restacks_descendants_to_trunk_and_restores_original_bran ); let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth").is_none()); let child = find_node(&state, "feat/auth-ui").unwrap(); assert_eq!(child["base_ref"], "main"); assert_eq!(child["parent"]["kind"], "trunk"); assert!(find_archived_node(&state, "feat/auth").is_some()); + assert!(load_operation_json(repo).is_none()); }); } @@ -80,7 +76,7 @@ fn orphans_named_branch_restacks_descendants_to_tracked_parent() { dgr_ok(repo, &["branch", "feat/auth"]); commit_file(repo, "auth.txt", "auth\n", "feat: auth"); dgr_ok(repo, &["branch", "feat/auth-api"]); - commit_file(repo, "auth-api.txt", "api\n", "feat: auth api"); + commit_file(repo, "api.txt", "api\n", "feat: auth api"); dgr_ok(repo, &["branch", "feat/auth-api-tests"]); commit_file(repo, "tests.txt", "tests\n", "feat: auth api tests"); git_ok(repo, &["checkout", "feat/auth"]); @@ -91,7 +87,7 @@ fn orphans_named_branch_restacks_descendants_to_tracked_parent() { assert!(stdout.contains("Orphaned 'feat/auth-api'. It is no longer tracked by dagger.")); assert!(stdout.contains("Returned to 'feat/auth' after orphaning.")); assert!(stdout.contains("- feat/auth-api-tests onto feat/auth")); - assert!(stdout.contains("main\n└── ✓ feat/auth\n └── feat/auth-api-tests")); + assert!(stdout.contains("* main\n└── ✓ feat/auth\n └── * feat/auth-api-tests")); assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "feat/auth"); assert_eq!( git_stdout(repo, &["merge-base", "feat/auth", "feat/auth-api-tests"]), @@ -99,12 +95,15 @@ fn orphans_named_branch_restacks_descendants_to_tracked_parent() { ); let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth-api").is_none()); let child = find_node(&state, "feat/auth-api-tests").unwrap(); - let parent = find_node(&state, "feat/auth").unwrap(); assert_eq!(child["base_ref"], "feat/auth"); - assert_eq!(child["parent"]["kind"], "branch"); - assert_eq!(child["parent"]["node_id"], parent["id"]); + assert_eq!( + child["parent"]["node_id"], + find_node(&state, "feat/auth").unwrap()["id"] + ); assert!(find_archived_node(&state, "feat/auth-api").is_some()); + assert!(load_operation_json(repo).is_none()); }); } @@ -114,45 +113,69 @@ fn sync_continues_paused_orphan_operation() { initialize_main_repo(repo); dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - overwrite_file(repo, "shared.txt", "parent\n", "feat: parent"); + overwrite_file(repo, "shared.txt", "parent\n", "feat: auth"); + dgr_ok(repo, &["branch", "feat/auth-ui"]); + overwrite_file(repo, "shared.txt", "child\n", "feat: auth ui"); + git_ok(repo, &["checkout", "main"]); + + let output = dgr(repo, &["orphan", "feat/auth"]); + assert!(!output.status.success()); + + let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth").is_some()); + assert!(load_operation_json(repo).is_some()); + + overwrite_file(repo, "shared.txt", "resolved\n", "fix: resolve conflict"); + git_ok(repo, &["add", "shared.txt"]); + + let output = dgr_ok(repo, &["sync", "--continue"]); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(stdout.contains("Orphaned 'feat/auth'. It is no longer tracked by dagger.")); + assert!(stdout.contains("Returned to 'main' after orphaning.")); + assert!(stdout.contains("- feat/auth-ui onto main")); + + let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth").is_none()); + let child = find_node(&state, "feat/auth-ui").unwrap(); + assert_eq!(child["base_ref"], "main"); + assert_eq!(child["parent"]["kind"], "trunk"); + assert!(find_archived_node(&state, "feat/auth").is_some()); + assert!(load_operation_json(repo).is_none()); + }); +} + +#[test] +fn sync_continues_paused_orphan_and_shows_orphaned_current_branch_below_tree() { + with_temp_repo("dgr-orphan-cli", |repo| { + initialize_main_repo(repo); + dgr_ok(repo, &["init"]); + dgr_ok(repo, &["branch", "feat/auth"]); + overwrite_file(repo, "shared.txt", "parent\n", "feat: auth"); dgr_ok(repo, &["branch", "feat/auth-ui"]); - overwrite_file(repo, "shared.txt", "child\n", "feat: child"); + overwrite_file(repo, "shared.txt", "child\n", "feat: auth ui"); git_ok(repo, &["checkout", "main"]); overwrite_file(repo, "shared.txt", "main\n", "feat: trunk"); git_ok(repo, &["checkout", "feat/auth"]); - let paused = dgr(repo, &["orphan"]); - let stderr = String::from_utf8(paused.stderr).unwrap(); + let output = dgr(repo, &["orphan"]); + assert!(!output.status.success()); + assert!(load_operation_json(repo).is_some()); - assert!(!paused.status.success()); - assert!(stderr.contains("dgr sync --continue")); - assert_eq!( - load_operation_json(repo).unwrap()["origin"]["type"].as_str(), - Some("orphan") - ); - assert!( - active_rebase_head_name(repo).contains("feat/auth-ui"), - "expected rebase head-name to reference feat/auth-ui" - ); - - std::fs::write(repo.join("shared.txt"), "resolved\n").unwrap(); + write_file(repo, "shared.txt", "resolved\n"); git_ok(repo, &["add", "shared.txt"]); - let resumed = dgr_ok(repo, &["sync", "--continue"]); - let stdout = strip_ansi(&String::from_utf8(resumed.stdout).unwrap()); + let output = dgr_ok(repo, &["sync", "--continue"]); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); assert!(stdout.contains("Orphaned 'feat/auth'. It is no longer tracked by dagger.")); assert!(stdout.contains("Returned to 'feat/auth' after orphaning.")); - assert!(stdout.contains("Restacked:")); assert!(stdout.contains("- feat/auth-ui onto main")); + assert!(stdout.contains("* main\n└── * feat/auth-ui\n\n✓ feat/auth (orphaned)")); assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "feat/auth"); - assert!(git_stdout(repo, &["branch", "--list", "feat/auth"]).contains("feat/auth")); - assert_eq!( - git_stdout(repo, &["merge-base", "main", "feat/auth-ui"]), - git_stdout(repo, &["rev-parse", "main"]) - ); let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth").is_none()); let child = find_node(&state, "feat/auth-ui").unwrap(); assert_eq!(child["base_ref"], "main"); assert_eq!(child["parent"]["kind"], "trunk"); @@ -160,3 +183,34 @@ fn sync_continues_paused_orphan_operation() { assert!(load_operation_json(repo).is_none()); }); } + +#[test] +fn reproduces_issue_7_orphan_tree_shows_parent_as_checked_out_after_orphan() { + with_temp_repo("dgr-orphan-issue-7", |repo| { + initialize_main_repo(repo); + dgr_ok(repo, &["init"]); + dgr_ok(repo, &["branch", "feat/sync-progress"]); + commit_file(repo, "progress.txt", "progress\n", "feat: sync progress"); + dgr_ok(repo, &["branch", "fix/sync-premature-deletion"]); + commit_file(repo, "fix.txt", "fix\n", "fix: sync premature deletion"); + + // We are on 'fix/sync-premature-deletion'. Now orphan it. + let output = dgr_ok(repo, &["orphan"]); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + // The bug: parent 'feat/sync-progress' is marked as current (✓) but it should be '*' + // and 'fix/sync-premature-deletion' should be at the bottom with '✓' and '(orphaned)' suffix. + assert!(stdout.contains( + "Orphaned 'fix/sync-premature-deletion'. It is no longer tracked by dagger." + )); + + // This assertion is EXPECTED TO FAIL until the fix is applied. + // Current (buggy) output likely looks like: + // main + // └── ✓ feat/sync-progress + + assert!(stdout.contains( + "main\n└── * feat/sync-progress\n\n✓ fix/sync-premature-deletion (orphaned)" + )); + }); +} diff --git a/tests/reparent.rs b/tests/reparent.rs index 82e388b..17780fb 100644 --- a/tests/reparent.rs +++ b/tests/reparent.rs @@ -22,7 +22,7 @@ fn reparents_current_branch_to_trunk_and_records_event() { assert!(stdout.contains("Reparented 'feat/auth-ui' onto 'main'.")); assert!(stdout.contains("Restacked:")); assert!(stdout.contains("- feat/auth-ui onto main")); - assert!(stdout.contains("main\n└── ✓ feat/auth-ui")); + assert!(stdout.contains("* main\n└── ✓ feat/auth-ui")); assert_eq!( git_stdout(repo, &["branch", "--show-current"]), "feat/auth-ui" @@ -73,7 +73,7 @@ fn reparents_named_branch_to_tracked_parent_and_restores_original_branch() { assert!(stdout.contains("Restacked:")); assert!(stdout.contains("- feat/auth-api onto feat/platform")); assert!(stdout.contains("- feat/auth-api-tests onto feat/auth-api")); - assert!(stdout.contains("feat/platform\n └── ✓ feat/auth-api")); + assert!(stdout.contains("✓ main\n└── * feat/platform\n └── * feat/auth-api\n └── * feat/auth-api-tests")); assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "main"); assert_eq!( git_stdout(repo, &["merge-base", "feat/platform", "feat/auth-api"]), @@ -100,6 +100,42 @@ fn reparents_named_branch_to_tracked_parent_and_restores_original_branch() { }); } +#[test] +fn reparents_named_branch_and_shows_unrelated_checked_out_branch_below_tree() { + with_temp_repo("dgr-reparent-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"); + dgr_ok(repo, &["branch", "feat/auth-api"]); + commit_file(repo, "api.txt", "api\n", "feat: api"); + dgr_ok(repo, &["branch", "feat/auth-api-tests"]); + commit_file(repo, "tests.txt", "tests\n", "feat: tests"); + git_ok(repo, &["checkout", "main"]); + dgr_ok(repo, &["branch", "feat/platform"]); + commit_file(repo, "platform.txt", "platform\n", "feat: platform"); + git_ok(repo, &["checkout", "main"]); + dgr_ok(repo, &["branch", "feat/billing"]); + commit_file(repo, "billing.txt", "billing\n", "feat: billing"); + git_ok(repo, &["checkout", "feat/billing"]); + + let output = dgr_ok(repo, &["reparent", "feat/auth-api", "-p", "feat/platform"]); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(stdout.contains("Reparented 'feat/auth-api' onto 'feat/platform'.")); + assert!(stdout.contains("Returned to 'feat/billing' after reparenting.")); + assert!(stdout.contains("- feat/auth-api onto feat/platform")); + assert!(stdout.contains("- feat/auth-api-tests onto feat/auth-api")); + assert!(stdout.contains( + "* main\n└── * feat/platform\n └── * feat/auth-api\n └── * feat/auth-api-tests\n\n✓ feat/billing" + )); + assert_eq!( + git_stdout(repo, &["branch", "--show-current"]), + "feat/billing" + ); + }); +} + #[test] fn rejects_reparenting_onto_descendant() { with_temp_repo("dgr-reparent-cli", |repo| { diff --git a/tests/sync.rs b/tests/sync.rs index 53c4402..47ec3f4 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -659,6 +659,52 @@ fn sync_continues_paused_adopt_rebase() { }); } +#[test] +fn sync_continues_paused_adopt_and_shows_unrelated_checked_out_branch_below_tree() { + with_temp_repo("dgr-sync-cli", |repo| { + initialize_main_repo(repo); + dgr_ok(repo, &["init"]); + dgr_ok(repo, &["branch", "feat/auth"]); + overwrite_file(repo, "shared.txt", "parent\n", "feat: parent"); + git_ok(repo, &["checkout", "main"]); + dgr_ok(repo, &["branch", "feat/billing"]); + commit_file(repo, "billing.txt", "billing\n", "feat: billing"); + git_ok(repo, &["checkout", "main"]); + git_ok(repo, &["checkout", "-b", "feat/auth-ui"]); + overwrite_file(repo, "shared.txt", "child\n", "feat: child"); + git_ok(repo, &["checkout", "feat/billing"]); + + let paused = dgr(repo, &["adopt", "feat/auth-ui", "-p", "feat/auth"]); + assert!(!paused.status.success()); + assert!(load_operation_json(repo).is_some()); + + write_file(repo, "shared.txt", "resolved\n"); + git_ok(repo, &["add", "shared.txt"]); + + let resumed = dgr_ok(repo, &["sync", "--continue"]); + let stdout = strip_ansi(&String::from_utf8(resumed.stdout).unwrap()); + + assert!(stdout.contains("Adopted 'feat/auth-ui' under 'feat/auth'.")); + assert!(stdout.contains("Restacked 'feat/auth-ui' onto 'feat/auth'.")); + assert!(stdout.contains("Returned to 'feat/billing' after adopt.")); + assert!( + stdout.contains("* main\n└── * feat/auth\n └── * feat/auth-ui\n\n✓ feat/billing") + ); + assert_eq!( + git_stdout(repo, &["branch", "--show-current"]), + "feat/billing" + ); + assert_eq!( + git_stdout(repo, &["merge-base", "feat/auth", "feat/auth-ui"]), + git_stdout(repo, &["rev-parse", "feat/auth"]) + ); + + let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth-ui").is_some()); + assert!(load_operation_json(repo).is_none()); + }); +} + #[test] fn sync_continues_paused_merge_and_preserves_delete_prompt() { with_temp_repo("dgr-sync-cli", |repo| { diff --git a/tests/tree.rs b/tests/tree.rs new file mode 100644 index 0000000..20d7cc8 --- /dev/null +++ b/tests/tree.rs @@ -0,0 +1,47 @@ +mod support; + +use support::{ + commit_file, dgr_ok, git_ok, git_stdout, initialize_main_repo, strip_ansi, with_temp_repo, +}; + +#[test] +fn tree_branch_main_shows_checked_out_trunk_below_filtered_tree() { + with_temp_repo("dgr-tree-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(repo, &["tree", "--branch", "main"]); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert_eq!(stdout.trim_end(), "└── * feat/auth\n\n✓ main"); + assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "main"); + }); +} + +#[test] +fn tree_branch_shows_hidden_checked_out_branch_below_selected_stack() { + with_temp_repo("dgr-tree-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"); + dgr_ok(repo, &["branch", "feat/auth-ui"]); + commit_file(repo, "ui.txt", "ui\n", "feat: auth ui"); + git_ok(repo, &["checkout", "main"]); + dgr_ok(repo, &["branch", "feat/billing"]); + commit_file(repo, "billing.txt", "billing\n", "feat: billing"); + git_ok(repo, &["checkout", "feat/auth-ui"]); + + let output = dgr_ok(repo, &["tree", "--branch", "feat/billing"]); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert_eq!(stdout.trim_end(), "* feat/billing\n\n✓ feat/auth-ui"); + assert_eq!( + git_stdout(repo, &["branch", "--show-current"]), + "feat/auth-ui" + ); + }); +}