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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ Be cautious about:

## Verification Notes

For formatting, use `rustfmt` from the Rust toolchain managed by `rustup`, not a Homebrew-installed formatter. CI installs `rustfmt` on the stable toolchain and runs `cargo fmt --all --check`, so local verification should use the same toolchain, for example:

- `rustup component add rustfmt`
- `rustup run stable cargo fmt --all`

Before closing a session that changes behavior:

- run `cargo check`
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ Inspect the stack at any time:
dig tree
```

Create or adopt a GitHub pull request for the current tracked branch:

```bash
dig pr --title "feat: auth" --body "Implements authentication." --draft
```

Open the current branch's pull request in the browser:

```bash
dig pr --view
```

List tracked open pull requests in stack order:

```bash
dig pr list
dig pr list --view
```

### Common commands

```bash
Expand All @@ -77,6 +96,11 @@ dig branch <name> -p <parent> # create a tracked branch under a specific paren
dig tree # show the full tracked branch tree
dig tree --branch <branch> # show one branch and its descendants
dig commit -m "message" # commit and restack tracked descendants if needed
dig pr # create or adopt a GitHub PR for the current tracked branch
dig pr --title "title" --body "body" --draft
dig pr --view # open the current branch PR in the browser
dig pr list # list open GitHub PRs that dig is tracking
dig pr list --view # list tracked PRs, then open them in the browser
dig sync # reconcile local dig state, restack stale stacks, then offer cleanup
dig sync --continue # continue a paused restack after resolving conflicts
dig merge <branch> # merge a tracked branch into its tracked parent
Expand Down Expand Up @@ -105,6 +129,22 @@ If cleanup finds merged branches, `dig sync` reuses the same delete prompt as `d

Remote sync is intentionally out of scope for now. Future GitHub and `gh` integration can extend `dig sync`, but the current command only reconciles local branches and local dig state.

### Track GitHub pull requests

`dig pr` uses the GitHub CLI (`gh`) to create a pull request for the current tracked branch, or to adopt the existing open pull request for that branch if one already exists on GitHub.

By default, dig targets the branch's tracked parent as the PR base. Root branches target trunk, child branches target their tracked parent branch, and the tracked PR number is stored locally in `.git/dig/state.json`.

If the branch is not pushed to a resolvable remote yet, `dig pr` prompts before running `git push -u <remote> <branch>` and then continues with PR creation if you confirm.

When dig creates a pull request, it prints both the creation summary and the GitHub link.

`dig tree` annotates tracked branches that have a PR with `(#123)`.

`dig pr --view` opens the current branch's pull request in the browser. If you combine `--view` with a mutating PR command, dig opens the browser after the command completes.

`dig pr list` shows only open pull requests that are both open on GitHub and currently tracked by dig, rendered in dig's stack order. Each line includes `#<number>: <title>` and the GitHub URL.

### Resolve paused commands

Some commands, including `dig commit`, `dig adopt`, `dig reparent`, `dig merge`, `dig clean`, `dig orphan`, and `dig sync`, may pause if `dig` hits a rebase conflict while restacking tracked descendants.
Expand Down
5 changes: 5 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod init;
mod merge;
mod operation;
mod orphan;
mod pr;
mod reparent;
mod sync;
mod tree;
Expand Down Expand Up @@ -48,6 +49,9 @@ enum Commands {
/// Stop tracking a branch in dig while keeping the local branch
Orphan(orphan::OrphanArgs),

/// Create or adopt a GitHub pull request for the current tracked branch
Pr(pr::PrArgs),

/// Change a tracked branch's parent and restack it onto the new base
Reparent(reparent::ReparentArgs),

Expand All @@ -74,6 +78,7 @@ pub fn run() -> ExitCode {
Commands::Commit(args) => commit::execute(args),
Commands::Merge(args) => merge::execute(args),
Commands::Orphan(args) => orphan::execute(args),
Commands::Pr(args) => pr::execute(args),
Commands::Reparent(args) => reparent::execute(args),
Commands::Sync(args) => sync::execute(args),
Commands::Tree(args) => tree::execute(args),
Expand Down
227 changes: 227 additions & 0 deletions src/cli/pr/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
use std::io;

use clap::{Args, Subcommand};

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

use super::CommandOutcome;
use super::common;

#[derive(Args, Debug, Clone)]
pub struct PrArgs {
#[command(subcommand)]
pub command: Option<PrCommand>,

/// Title for the pull request
#[arg(long = "title", value_name = "TITLE")]
pub title: Option<String>,

/// Body for the pull request
#[arg(long = "body", value_name = "BODY")]
pub body: Option<String>,

/// Mark the pull request as a draft
#[arg(long = "draft")]
pub draft: bool,

/// Open the pull request in the browser
#[arg(long = "view")]
pub view: bool,
}

#[derive(Subcommand, Debug, Clone)]
pub enum PrCommand {
/// List open pull requests that are tracked by dig
List(PrListArgs),
}

#[derive(Args, Debug, Clone, Default)]
pub struct PrListArgs {
/// Open each listed pull request in the browser
#[arg(long = "view")]
pub view: bool,
}

pub fn execute(args: PrArgs) -> io::Result<CommandOutcome> {
match args.command.clone() {
Some(PrCommand::List(list_args)) => execute_list(list_args),
None => execute_current(args),
}
}

fn execute_current(args: PrArgs) -> io::Result<CommandOutcome> {
let create_requested = args.title.is_some() || args.body.is_some() || args.draft;
if args.view && !create_requested {
pr::open_current_pull_request_in_browser()?;
return Ok(CommandOutcome {
status: git::success_status()?,
});
}

let mut options: PrOptions = args.clone().into();
if let Some(push_target) = pr::current_branch_push_target_for_create()? {
let confirmed = common::confirm_yes_no(&format!(
"Branch '{}' is not pushed to '{}'. Push it and create the pull request? [y/N] ",
push_target.branch_name, push_target.remote_name
))?;

if !confirmed {
println!(
"Did not create pull request because '{}' is not pushed to '{}'.",
push_target.branch_name, push_target.remote_name
);
return Ok(CommandOutcome {
status: git::success_status()?,
});
}

options.push_if_needed = true;
}

let outcome = pr::run(&options)?;
match outcome.kind {
PrOutcomeKind::AlreadyTracked => {
println!(
"Branch '{}' already tracks pull request #{}.",
outcome.branch_name, outcome.pull_request.number
);
}
PrOutcomeKind::Created => {
println!(
"Created pull request #{} for '{}' into '{}'.",
outcome.pull_request.number, outcome.branch_name, outcome.base_branch_name
);
}
PrOutcomeKind::Adopted => {
println!(
"Tracking existing pull request #{} for '{}' into '{}'.",
outcome.pull_request.number, outcome.branch_name, outcome.base_branch_name
);
}
}

if args.view {
pr::open_pull_request_in_browser(outcome.pull_request.number)?;
}

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

fn execute_list(args: PrListArgs) -> io::Result<CommandOutcome> {
let outcome = pr::list_open_tracked_pull_requests()?;

if outcome.pull_requests.is_empty() {
println!("No open tracked pull requests.");
} else {
println!("{}", render_pull_request_list(&outcome.view));
}

if args.view {
pr::open_pull_requests_in_browser(&outcome.pull_requests)?;
}

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

fn render_pull_request_list(view: &TrackedPullRequestListView) -> String {
common::render_tree(
view.root_label.clone(),
&view.roots,
&format_pull_request_label,
&|node| node.children.as_slice(),
)
}

fn format_pull_request_label(node: &TrackedPullRequestListNode) -> String {
format!(
"#{}: {} - {}",
node.pull_request.number, node.pull_request.title, node.pull_request.url
)
}

impl From<PrArgs> for PrOptions {
fn from(args: PrArgs) -> Self {
Self {
title: args.title,
body: args.body,
draft: args.draft,
push_if_needed: false,
}
}
}

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

#[test]
fn converts_cli_args_into_core_pr_options() {
let options = PrOptions::from(PrArgs {
command: None,
title: Some("feat: auth".into()),
body: Some("Implements auth.".into()),
draft: true,
view: true,
});

assert_eq!(options.title.as_deref(), Some("feat: auth"));
assert_eq!(options.body.as_deref(), Some("Implements auth."));
assert!(options.draft);
}

#[test]
fn preserves_pr_list_subcommand_args() {
match (PrArgs {
command: Some(PrCommand::List(PrListArgs { view: true })),
title: None,
body: None,
draft: false,
view: false,
})
.command
.unwrap()
{
PrCommand::List(args) => assert!(args.view),
}
}

#[test]
fn renders_pull_request_list_as_tree() {
let rendered = render_pull_request_list(&TrackedPullRequestListView {
root_label: Some("main".into()),
roots: vec![TrackedPullRequestListNode {
pull_request: crate::core::gh::PullRequestDetails {
number: 123,
title: "Auth".into(),
url: "https://github.com/acme/dig/pull/123".into(),
},
children: vec![TrackedPullRequestListNode {
pull_request: crate::core::gh::PullRequestDetails {
number: 124,
title: "Auth UI".into(),
url: "https://github.com/acme/dig/pull/124".into(),
},
children: vec![],
}],
}],
});

assert_eq!(
rendered,
concat!(
"main\n",
"└── #123: Auth - https://github.com/acme/dig/pull/123\n",
" └── #124: Auth UI - https://github.com/acme/dig/pull/124"
)
);
}
}
Loading