mod auth;
mod cli;
mod config;
mod error;
mod forge;
mod graph;
mod jj;
mod select;
mod submit;
use std::collections::HashSet;
use clap::CommandFactory;
use clap::FromArgMatches;
use crate::cli::Cli;
use crate::cli::Commands;
use crate::cli::ShowArgs;
use crate::cli::auth::AuthCommands;
use crate::cli::submit::SubmitArgs;
use crate::error::StakkError::Interrupted;
use crate::error::StakkError::{self};
use crate::forge::Forge;
use crate::jj::Jj;
use crate::jj::remote::parse_github_url;
use crate::jj::runner::RealJjRunner;
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
if matches!(e, Interrupted) {
std::process::exit(130);
}
eprintln!("{:?}", miette::Report::new(e));
std::process::exit(1);
}
}
async fn run() -> Result<(), StakkError> {
let config_path = config::pre_parse_config_path();
let config = config::Config::load(config_path)?;
let cmd = cli::apply_config_defaults(config, Cli::command());
let cli = Cli::from_arg_matches(&cmd.get_matches())?;
match cli.command {
Some(Commands::Submit(args)) => {
submit_bookmark(&args).await?;
}
Some(Commands::Auth(args)) => match args.command {
AuthCommands::Test => {
auth_test().await?;
}
AuthCommands::Setup => {
auth_setup();
}
},
Some(Commands::Show(args)) => {
show_status(&args).await?;
}
Some(Commands::Completions { shell }) => {
clap_complete::generate(shell, &mut Cli::command(), "stakk", &mut std::io::stdout());
}
None => {
submit_bookmark(&cli.submit_args).await?;
}
}
Ok(())
}
async fn auth_test() -> Result<(), StakkError> {
let auth_token = auth::resolve_token().await?;
println!("Authentication source: {}", auth_token.source);
let (_, github_repo) = resolve_github_remote(None).await?;
let forge =
forge::github::GitHubForge::new(&auth_token.token, github_repo.owner, github_repo.repo)?;
let username = forge.get_authenticated_user().await?;
println!("Authenticated as: {username}");
Ok(())
}
fn auth_setup() {
println!("stakk resolves GitHub authentication in this order:\n");
println!(" 1. GitHub CLI: Run `gh auth login` to authenticate.");
println!(" This is the recommended method.\n");
println!(" 2. GITHUB_TOKEN: Set the GITHUB_TOKEN environment variable");
println!(" to a personal access token with `repo` scope.\n");
println!(" 3. GH_TOKEN: Set the GH_TOKEN environment variable");
println!(" (same as GITHUB_TOKEN, alternative name).\n");
println!("To verify: run `stakk auth test`");
}
async fn submit_bookmark(args: &SubmitArgs) -> Result<(), StakkError> {
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message("Resolving authentication...");
let jj = Jj::new(RealJjRunner);
let auth_token = auth::resolve_token().await?;
pb.set_message("Resolving GitHub remote...");
let (remote_name, github_repo) = resolve_github_remote(Some(&args.remote)).await?;
let forge = forge::github::GitHubForge::new(
&auth_token.token,
github_repo.owner.clone(),
github_repo.repo.clone(),
)?;
pb.set_message("Building change graph...");
let change_graph =
graph::build_change_graph(&jj, &args.graph.bookmarks_revset, &args.graph.heads_revset)
.await?;
pb.set_message("Detecting default branch...");
let default_branch = jj.get_default_branch().await?;
pb.finish_and_clear();
let (bookmark, change_graph, selected_bookmarks) = match &args.bookmark {
Some(name) => {
let selected = HashSet::from([name.clone()]);
(name.clone(), change_graph, selected)
}
None => match select::resolve_bookmark_interactively(
&change_graph,
args.bookmark_command.as_deref(),
args.auto_prefix.as_deref(),
)? {
Some(result) => {
let has_new = result.assignments.iter().any(|a| a.is_new);
for assignment in &result.assignments {
if assignment.is_new {
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message(format!(
"Creating bookmark {}...",
assignment.bookmark_name
));
jj.create_bookmark(&assignment.bookmark_name, &assignment.change_id)
.await?;
pb.finish_and_clear();
}
}
let selected: HashSet<String> = result
.assignments
.iter()
.map(|a| a.bookmark_name.clone())
.collect();
let leaf_bookmark = result
.assignments
.last()
.map(|a| a.bookmark_name.clone())
.unwrap_or_default();
let graph = if has_new {
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message("Rebuilding change graph...");
let g = graph::build_change_graph(
&jj,
&args.graph.bookmarks_revset,
&args.graph.heads_revset,
)
.await?;
pb.finish_and_clear();
g
} else {
change_graph
};
(leaf_bookmark, graph, selected)
}
None => return Ok(()),
},
};
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message("Analyzing submission...");
let analysis = submit::analyze_submission(
&bookmark,
&change_graph,
&default_branch,
&selected_bookmarks,
)?;
pb.set_message("Checking for existing pull requests...");
let plan =
submit::create_submission_plan(&analysis, &forge, &remote_name, args.pr_mode()).await?;
pb.finish_and_clear();
if args.dry_run {
println!("DRY RUN — no changes will be made.\n");
}
println!("{plan}");
if args.dry_run {
return Ok(());
}
let template_source = match &args.template {
Some(path) => {
Some(
std::fs::read_to_string(path).map_err(|e| StakkError::TemplateLoadFailed {
path: path.clone(),
reason: e.to_string(),
})?,
)
}
None => None,
};
let comment_env = forge::comment::build_comment_env(template_source.as_deref())?;
let result =
submit::execute_submission_plan(&plan, &jj, &forge, &comment_env, args.stack_placement)
.await?;
println!("\nSubmitted {} bookmark(s).", result.stack_entries.len());
Ok(())
}
async fn resolve_github_remote(
preferred: Option<&str>,
) -> Result<(String, jj::remote::GitHubRepo), StakkError> {
let jj = Jj::new(RealJjRunner);
let remotes = jj.get_git_remote_list().await?;
if let Some(name) = preferred {
if let Some(remote) = remotes.iter().find(|r| r.name == name) {
if let Some(repo) = parse_github_url(&remote.url) {
return Ok((remote.name.clone(), repo));
}
return Err(StakkError::RemoteNotGithub {
name: name.to_string(),
url: remote.url.clone(),
});
}
return Err(StakkError::RemoteNotFound {
name: name.to_string(),
});
}
for remote in &remotes {
if let Some(repo) = parse_github_url(&remote.url) {
return Ok((remote.name.clone(), repo));
}
}
Err(StakkError::NoGithubRemote)
}
async fn show_status(args: &ShowArgs) -> Result<(), StakkError> {
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message("Loading repository status...");
let jj = Jj::new(RealJjRunner);
let default_branch = jj.get_default_branch().await?;
let remotes = jj.get_git_remote_list().await?;
let change_graph =
graph::build_change_graph(&jj, &args.graph.bookmarks_revset, &args.graph.heads_revset)
.await?;
pb.finish_and_clear();
println!("Default branch: {default_branch}");
for remote in &remotes {
let github = parse_github_url(&remote.url)
.map(|r| format!(" ({r})"))
.unwrap_or_default();
println!("Remote: {} {}{}", remote.name, remote.url, github);
}
if change_graph.stacks.is_empty() {
println!("\nNo bookmark stacks found.");
} else {
println!("\nStacks ({} found):", change_graph.stacks.len());
for (i, stack) in change_graph.stacks.iter().enumerate() {
println!(" Stack {}:", i + 1);
for segment in &stack.segments {
let names = segment.bookmark_names.join(", ");
let commit_count = segment.commits.len();
let desc = segment
.commits
.first()
.and_then(|c| c.description.lines().next())
.map(str::trim)
.filter(|l| !l.is_empty())
.unwrap_or("(no description)");
println!(" {names} ({commit_count} commit(s)): {desc}");
}
}
if change_graph.excluded_bookmark_count > 0 {
println!(
"\n ({} bookmark(s) excluded due to merge commits)",
change_graph.excluded_bookmark_count,
);
}
}
Ok(())
}