Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions COPYRIGHT
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Copyright (C) 2026 Mark Pro

The `dig` source code and repository contents are copyright Mark Pro and
contributors, unless otherwise noted.

`dig` is licensed under the GNU General Public License, version 3 or, at your
option, any later version. See the LICENSE file for the full license text.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "dig"
version = "0.0.1"
edition = "2024"
license = "GPL-3.0-or-later"

[dependencies]
clap = { version = "4.6.0", features = ["derive"] }
Expand Down
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,15 @@ dig sync --continue
If the next descendant also conflicts, repeat the same process and run `dig sync --continue` again.

While an operation is paused, start by finishing or aborting that rebase before running more `dig` workflow commands. If you abort with `git rebase --abort`, rerun the original `dig` command after the rebase state has been cleared.

## License

`dig` is licensed under the GNU General Public License, version 3 or, at your option, any later version. See [LICENSE](LICENSE) for the full text.

Copyright (C) 2026 Mark Pro. See [COPYRIGHT](COPYRIGHT) for the project copyright notice.

Commercial use of `dig` is allowed. You may use `dig` in commercial environments, on private repositories, and on proprietary codebases.

Using `dig` as a tool against a repository does not by itself change the license of that repository or require that repository to be open source. In other words, running `dig` on your project does not impose the GPL on your project's source code merely because `dig` was used as part of the workflow.

If you modify and redistribute `dig` itself, or distribute a larger combined work that incorporates `dig`'s GPL-covered code, those distributions must comply with the GPL.
106 changes: 103 additions & 3 deletions src/cli/pr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use clap::{Args, Subcommand};

use crate::core::git;
use crate::core::pr::{
self, PrOptions, PrOutcomeKind, TrackedPullRequestListNode, TrackedPullRequestListView,
self, PrMergeOutcome, PrOptions, PrOutcomeKind, RetargetedPullRequest,
TrackedPullRequestListNode, TrackedPullRequestListView,
};

use super::CommandOutcome;
Expand Down Expand Up @@ -36,6 +37,9 @@ pub struct PrArgs {
pub enum PrCommand {
/// List open pull requests that are tracked by dig
List(PrListArgs),

/// Merge the current tracked pull request on GitHub
Merge(PrMergeArgs),
}

#[derive(Args, Debug, Clone, Default)]
Expand All @@ -45,9 +49,13 @@ pub struct PrListArgs {
pub view: bool,
}

#[derive(Args, Debug, Clone, Default)]
pub struct PrMergeArgs {}

pub fn execute(args: PrArgs) -> io::Result<CommandOutcome> {
match args.command.clone() {
Some(PrCommand::List(list_args)) => execute_list(list_args),
Some(PrCommand::Merge(_)) => execute_merge(),
None => execute_current(args),
}
}
Expand Down Expand Up @@ -130,6 +138,18 @@ fn execute_list(args: PrListArgs) -> io::Result<CommandOutcome> {
})
}

fn execute_merge() -> io::Result<CommandOutcome> {
let outcome = pr::merge_current_pull_request()?;
let output = format_pr_merge_output(&outcome);
if !output.is_empty() {
println!("{output}");
}

Ok(CommandOutcome {
status: outcome.status,
})
}

fn render_pull_request_list(view: &TrackedPullRequestListView) -> String {
common::render_tree(
view.root_label.clone(),
Expand All @@ -146,6 +166,40 @@ fn format_pull_request_label(node: &TrackedPullRequestListNode) -> String {
)
}

fn format_pr_merge_output(outcome: &PrMergeOutcome) -> String {
let mut sections = Vec::new();

let retargeted = format_retargeted_pull_requests(&outcome.retargeted_pull_requests);
if !retargeted.is_empty() {
sections.push(retargeted);
}

sections.push(format!(
"Merged pull request #{} for '{}' into '{}'.",
outcome.pull_request_number, outcome.branch_name, outcome.base_branch_name
));

common::join_sections(&sections)
}

fn format_retargeted_pull_requests(retargeted: &[RetargetedPullRequest]) -> String {
if retargeted.is_empty() {
return String::new();
}

let mut lines = vec!["Retargeted child pull requests:".to_string()];
for pull_request in retargeted {
lines.push(format!(
"- #{} for {} to {}",
pull_request.pull_request_number,
pull_request.branch_name,
pull_request.new_base_branch_name
));
}

lines.join("\n")
}

impl From<PrArgs> for PrOptions {
fn from(args: PrArgs) -> Self {
Self {
Expand All @@ -159,9 +213,13 @@ impl From<PrArgs> for PrOptions {

#[cfg(test)]
mod tests {
use super::{PrArgs, PrCommand, PrListArgs, render_pull_request_list};
use super::{
PrArgs, PrCommand, PrListArgs, PrMergeArgs, RetargetedPullRequest, format_pr_merge_output,
render_pull_request_list,
};
use crate::core::git;
use crate::core::pr::PrOptions;
use crate::core::pr::{TrackedPullRequestListNode, TrackedPullRequestListView};
use crate::core::pr::{PrMergeOutcome, TrackedPullRequestListNode, TrackedPullRequestListView};

#[test]
fn converts_cli_args_into_core_pr_options() {
Expand Down Expand Up @@ -191,6 +249,24 @@ mod tests {
.unwrap()
{
PrCommand::List(args) => assert!(args.view),
PrCommand::Merge(_) => unreachable!(),
}
}

#[test]
fn preserves_pr_merge_subcommand_args() {
match (PrArgs {
command: Some(PrCommand::Merge(PrMergeArgs::default())),
title: None,
body: None,
draft: false,
view: false,
})
.command
.unwrap()
{
PrCommand::Merge(_) => {}
_ => unreachable!(),
}
}

Expand Down Expand Up @@ -224,4 +300,28 @@ mod tests {
)
);
}

#[test]
fn formats_pr_merge_output_with_retargeted_children() {
let output = format_pr_merge_output(&PrMergeOutcome {
status: git::success_status().unwrap(),
branch_name: "feat/auth".into(),
base_branch_name: "main".into(),
pull_request_number: 123,
retargeted_pull_requests: vec![RetargetedPullRequest {
branch_name: "feat/auth-ui".into(),
pull_request_number: 124,
new_base_branch_name: "main".into(),
}],
});

assert_eq!(
output,
concat!(
"Retargeted child pull requests:\n",
"- #124 for feat/auth-ui to main\n\n",
"Merged pull request #123 for 'feat/auth' into 'main'."
)
);
}
}
51 changes: 51 additions & 0 deletions src/cli/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,25 @@ pub fn execute(args: SyncArgs) -> io::Result<CommandOutcome> {
}
}

if final_status.success() {
let pull_request_update_plan =
sync::plan_pull_request_updates(&restacked_branch_names)?;
if !pull_request_update_plan.actions.is_empty() {
if printed_output {
println!();
}

let updated_pull_requests =
sync::execute_pull_request_update_plan(&pull_request_update_plan)?;
let output =
format_pull_request_update_success_output(&updated_pull_requests);
if !output.is_empty() {
println!("{output}");
printed_output = true;
}
}
}

if final_status.success() {
let push_plan =
sync::plan_remote_pushes(&restacked_branch_names, &excluded_branch_names)?;
Expand Down Expand Up @@ -241,6 +260,20 @@ impl From<SyncArgs> for SyncOptions {
fn format_full_sync_summary(outcome: &sync::FullSyncOutcome) -> String {
let mut sections = Vec::new();

if !outcome.repaired_pull_requests.is_empty() {
let mut lines = vec!["Recovered pull requests:".to_string()];
for repair in &outcome.repaired_pull_requests {
lines.push(format!(
"- {} (#{}): reopened as draft and retargeted from {} to {}",
repair.branch_name,
repair.pull_request_number,
repair.old_base_branch_name,
repair.new_base_branch_name
));
}
sections.push(lines.join("\n"));
}

if !outcome.deleted_branches.is_empty() {
let mut lines = vec!["Deleted locally and no longer tracked by dig:".to_string()];
for branch_name in &outcome.deleted_branches {
Expand Down Expand Up @@ -280,6 +313,24 @@ fn format_remote_push_plan(plan: &sync::RemotePushPlan) -> String {
lines.join("\n")
}

fn format_pull_request_update_success_output(
updated_pull_requests: &[sync::PullRequestUpdateAction],
) -> String {
if updated_pull_requests.is_empty() {
return String::new();
}

let mut lines = vec!["Updated pull requests:".to_string()];
for action in updated_pull_requests {
lines.push(format!(
"- retargeted #{} for {} to {}",
action.pull_request_number, action.branch_name, action.new_base_branch_name
));
}

lines.join("\n")
}

fn confirm_remote_pushes() -> io::Result<bool> {
common::confirm_yes_no("Push these remote updates? [y/N] ")
}
Expand Down
20 changes: 12 additions & 8 deletions src/core/clean/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,17 @@ fn evaluate_integrated_branch(

let graph = BranchGraph::new(state);

let Some(parent_branch_name) = graph.parent_branch_name(node, trunk_branch) else {
return Ok(BranchEvaluation::Blocked(BlockedBranch {
branch_name: node.branch_name.clone(),
reason: CleanBlockReason::ParentMissingFromDig,
}));
};
let (local_parent_base, resolved_parent) =
match deleted_local::resolve_replacement_parent(state, trunk_branch, &node.parent) {
Ok(resolved) => resolved,
Err(_) => {
return Ok(BranchEvaluation::Blocked(BlockedBranch {
branch_name: node.branch_name.clone(),
reason: CleanBlockReason::ParentMissingFromDig,
}));
}
};
let parent_branch_name = local_parent_base.branch_name.clone();

if !git::branch_exists(&parent_branch_name)? {
return Ok(BranchEvaluation::Blocked(BlockedBranch {
Expand All @@ -232,7 +237,6 @@ fn evaluate_integrated_branch(
}));
}

let local_parent_base = RestackBaseTarget::local(&parent_branch_name);
let tracked_pull_request_number = node.pull_request.as_ref().map(|pr| pr.number);
let parent_base = if branch_is_integrated_for_pull_request(
local_parent_base.rebase_ref(),
Expand Down Expand Up @@ -274,7 +278,7 @@ fn evaluate_integrated_branch(
node.id,
&node.branch_name,
&parent_base,
&node.parent,
&resolved_parent,
)?;

Ok(BranchEvaluation::Cleanable(CleanCandidate {
Expand Down
4 changes: 2 additions & 2 deletions src/core/deleted_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ pub(crate) fn plan_deleted_local_step_for_branch(
plan_deleted_local_step(state, trunk_branch, node.id)
}

fn resolve_replacement_parent(
pub(crate) fn resolve_replacement_parent(
state: &DigState,
trunk_branch: &str,
parent: &ParentRef,
Expand All @@ -215,7 +215,7 @@ fn resolve_replacement_parent(
}
ParentRef::Branch { node_id } => {
let parent_node = state
.find_branch_by_id(node_id)
.find_any_branch_by_id(node_id)
.ok_or_else(|| io::Error::other("tracked parent branch was not found"))?;

if !parent_node.archived && git::branch_exists(&parent_node.branch_name)? {
Expand Down
Loading