From c7a836552fa113e4d92eac3aa2b5fc9bdb89d934 Mon Sep 17 00:00:00 2001 From: mark-pro <20671988+mark-pro@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:42:42 -0400 Subject: [PATCH 1/3] fix(orphan): output now displays orphaned branch) --- src/cli/orphan/mod.rs | 5 +- src/cli/sync/render.rs | 3 ++ src/cli/tree/render.rs | 96 ++++++++++++++++++++++++++++-------- src/core/tree.rs | 109 +++++++++++++++++++++++------------------ tests/adopt.rs | 4 +- tests/branch.rs | 6 +-- tests/orphan.rs | 37 ++++++++++++-- tests/reparent.rs | 4 +- 8 files changed, 184 insertions(+), 80 deletions(-) diff --git a/src/cli/orphan/mod.rs b/src/cli/orphan/mod.rs index 4877b52..cc6584b 100644 --- a/src/cli/orphan/mod.rs +++ b/src/cli/orphan/mod.rs @@ -19,7 +19,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 mut view = tree::focused_context_view(&outcome.parent_branch_name)?; + if view.current_branch_name.as_deref() == Some(&outcome.branch_name) { + view.current_branch_suffix = Some("(orphaned)".into()); + } let rendered_tree = super::tree::render_stack_tree(&view); let output = format_orphan_success_output(&outcome, &rendered_tree); if !output.is_empty() { diff --git a/src/cli/sync/render.rs b/src/cli/sync/render.rs index 4c28ab4..832f03b 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_in_tree: true, + current_branch_suffix: None, } } diff --git a/src/cli/tree/render.rs b/src/cli/tree/render.rs index 9b058d3..5e6b8c6 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_in_tree { + 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_in_tree: 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_in_tree: 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,47 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-ui".into()), + is_current_in_tree: 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_untracked_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_in_tree: 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..7408da1 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_in_tree: 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,32 @@ fn build_tree_view( sort_branch_nodes(children, &order_lookup); } + let roots: Vec = root_nodes + .into_iter() + .map(|node| build_tree_node(node, current_branch, &child_lookup)) + .collect(); + + let is_current_trunk = current_branch == Some(trunk_branch); + let is_current_in_tree = is_current_trunk || roots.iter().any(|root| is_current_in_node(root)); + TreeView { root_label: Some(TreeLabel { branch_name: trunk_branch.to_string(), - is_current: current_branch == Some(trunk_branch), + is_current: is_current_trunk, 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_in_tree, + current_branch_suffix: None, } } -fn filter_tree_view(view: TreeView, requested_branch: Option<&str>) -> io::Result { +fn is_current_in_node(node: &TreeNode) -> bool { + node.is_current || node.children.iter().any(is_current_in_node) +} + +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 +145,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(view); } let selected_node = view @@ -145,14 +163,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(view) } fn focus_tree_view(view: TreeView, requested_branch: &str) -> io::Result { @@ -172,7 +190,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 +354,9 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-api".into()), + is_current_in_tree: true, + current_branch_suffix: None, } ); } @@ -394,6 +393,9 @@ mod tests { }, ], }], + current_branch_name: Some("feat/auth-ui".into()), + is_current_in_tree: true, + current_branch_suffix: None, }; assert_eq!( @@ -423,6 +425,9 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-ui".into()), + is_current_in_tree: true, + current_branch_suffix: None, } ); } @@ -443,7 +448,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 +472,9 @@ mod tests { children: vec![], }, ], + current_branch_name: Some("feat/auth-api".into()), + is_current_in_tree: true, + current_branch_suffix: None, }; assert_eq!( @@ -493,6 +501,9 @@ mod tests { }], }], }], + current_branch_name: Some("feat/auth-api".into()), + is_current_in_tree: true, + current_branch_suffix: None, } ); } diff --git a/tests/adopt.rs b/tests/adopt.rs index 885a4c8..66efe17 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"]); 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..4b0357e 100644 --- a/tests/orphan.rs +++ b/tests/orphan.rs @@ -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")); @@ -56,7 +56,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!( @@ -91,7 +91,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"]), @@ -160,3 +160,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..4325941 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"]), From 17976bf211342d7c06513dcbf6617028b43ee036 Mon Sep 17 00:00:00 2001 From: mark-pro <20671988+mark-pro@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:06:46 -0400 Subject: [PATCH 2/3] chore: update to orphan tests --- tests/orphan.rs | 61 +++++++++++++++++-------------------------------- 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/tests/orphan.rs b/tests/orphan.rs index 4b0357e..cc33fbd 100644 --- a/tests/orphan.rs +++ b/tests/orphan.rs @@ -1,8 +1,8 @@ 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, + 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, }; @@ -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()); }); } @@ -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"]); @@ -99,12 +95,12 @@ 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 +110,30 @@ 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: 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", "feat/auth"]); + assert!(!output.status.success()); - 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" - ); + let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth").is_some()); + assert!(load_operation_json(repo).is_some()); - std::fs::write(repo.join("shared.txt"), "resolved\n").unwrap(); + overwrite_file(repo, "shared.txt", "resolved\n", "fix: resolve conflict"); 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("Returned to 'main' after orphaning.")); assert!(stdout.contains("- feat/auth-ui onto main")); - 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"); From bd25f7d0ac02d0e7a798b3b412c7bfba2bc71e19 Mon Sep 17 00:00:00 2001 From: mark-pro <20671988+mark-pro@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:22:58 -0400 Subject: [PATCH 3/3] fix(tree): render checked out branch outside visible slice --- src/cli/adopt/mod.rs | 4 +- src/cli/orphan/mod.rs | 10 +-- src/cli/reparent/mod.rs | 4 +- src/cli/sync/mod.rs | 14 +-- src/cli/sync/render.rs | 2 +- src/cli/tree/mod.rs | 15 ++++ src/cli/tree/render.rs | 35 ++++++-- src/core/tree.rs | 187 +++++++++++++++++++++++++++++++++++----- tests/adopt.rs | 36 ++++++++ tests/orphan.rs | 50 ++++++++++- tests/reparent.rs | 36 ++++++++ tests/sync.rs | 46 ++++++++++ tests/tree.rs | 47 ++++++++++ 13 files changed, 437 insertions(+), 49 deletions(-) create mode 100644 tests/tree.rs 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 cc6584b..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,11 +18,10 @@ pub fn execute(args: OrphanArgs) -> io::Result { let outcome = orphan::apply(&plan)?; if outcome.status.success() { - let mut view = tree::focused_context_view(&outcome.parent_branch_name)?; - if view.current_branch_name.as_deref() == Some(&outcome.branch_name) { - view.current_branch_suffix = Some("(orphaned)".into()); - } - 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 832f03b..79e6802 100644 --- a/src/cli/sync/render.rs +++ b/src/cli/sync/render.rs @@ -341,7 +341,7 @@ mod tests { ], }], current_branch_name: Some("main".into()), - is_current_in_tree: true, + 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 5e6b8c6..5e5b45d 100644 --- a/src/cli/tree/render.rs +++ b/src/cli/tree/render.rs @@ -26,7 +26,7 @@ pub fn render_stack_tree(view: &TreeView) -> String { &|node| node.children.as_slice(), ); - if !view.is_current_in_tree { + 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}"), @@ -218,7 +218,7 @@ mod tests { }, ], current_branch_name: Some("feat/auth-ui".into()), - is_current_in_tree: true, + is_current_visible: true, current_branch_suffix: None, }); @@ -265,7 +265,7 @@ mod tests { }, ], current_branch_name: Some("feat/auth-ui".into()), - is_current_in_tree: true, + is_current_visible: true, current_branch_suffix: None, }); @@ -303,7 +303,7 @@ mod tests { }, ], current_branch_name: Some("feat/auth-ui".into()), - is_current_in_tree: true, + is_current_visible: true, current_branch_suffix: None, }); @@ -318,7 +318,30 @@ mod tests { } #[test] - fn renders_untracked_current_branch_at_bottom() { + 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(), @@ -332,7 +355,7 @@ mod tests { children: vec![], }], current_branch_name: Some("feat/untracked".into()), - is_current_in_tree: false, + is_current_visible: false, current_branch_suffix: Some("(orphaned)".into()), }); diff --git a/src/core/tree.rs b/src/core/tree.rs index 7408da1..8adc8d9 100644 --- a/src/core/tree.rs +++ b/src/core/tree.rs @@ -33,7 +33,7 @@ pub struct TreeView { pub root_label: Option, pub roots: Vec, pub current_branch_name: Option, - pub is_current_in_tree: bool, + pub is_current_visible: bool, pub current_branch_suffix: Option, } @@ -112,24 +112,34 @@ fn build_tree_view( .map(|node| build_tree_node(node, current_branch, &child_lookup)) .collect(); - let is_current_trunk = current_branch == Some(trunk_branch); - let is_current_in_tree = is_current_trunk || roots.iter().any(|root| is_current_in_node(root)); - - TreeView { + with_current_visibility(TreeView { root_label: Some(TreeLabel { branch_name: trunk_branch.to_string(), - is_current: is_current_trunk, + is_current: current_branch == Some(trunk_branch), pull_request_number: None, }), roots, current_branch_name: current_branch.map(String::from), - is_current_in_tree, + 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 is_current_in_node(node: &TreeNode) -> bool { - node.is_current || node.children.iter().any(is_current_in_node) +fn with_current_visibility(mut view: TreeView) -> TreeView { + view.is_current_visible = is_current_visible(&view); + view } fn filter_tree_view(mut view: TreeView, requested_branch: Option<&str>) -> io::Result { @@ -146,7 +156,7 @@ fn filter_tree_view(mut view: TreeView, requested_branch: Option<&str>) -> io::R if requested_branch == root_label.branch_name { view.root_label = None; - return Ok(view); + return Ok(with_current_visibility(view)); } let selected_node = view @@ -170,7 +180,7 @@ fn filter_tree_view(mut view: TreeView, requested_branch: Option<&str>) -> io::R }); view.roots = selected_node.children.clone(); - Ok(view) + Ok(with_current_visibility(view)) } fn focus_tree_view(view: TreeView, requested_branch: &str) -> io::Result { @@ -204,13 +214,13 @@ fn focus_tree_view(view: TreeView, requested_branch: &str) -> io::Result