mod auth;
mod cli;
mod config;
mod docs;
mod error;
mod forge;
mod graph;
mod jj;
mod markdown;
mod select;
mod submit;
use clap::CommandFactory;
use clap::FromArgMatches;
use crate::cli::Cli;
use crate::cli::Commands;
use crate::cli::GraphArgs;
use crate::cli::GraphFormat;
use crate::cli::submit::SubmitArgs;
use crate::error::StakkError::Interrupted;
use crate::error::StakkError::{self};
use crate::forge::comment::StackPlacement;
use crate::jj::Jj;
use crate::jj::remote::parse_github_url;
use crate::jj::remote::parse_remote_url;
use crate::jj::runner::RealJjRunner;
use crate::jj::version::MIN_SUPPORTED_JJ_VERSION;
pub const GITHUB_COM: &str = "github.com";
#[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.clone(), Cli::command());
let cli = Cli::from_arg_matches(&cmd.get_matches())?;
if matches!(&cli.command, Some(Commands::Submit(_)) | None) {
config::warn_removed_env_vars();
}
let runs_jj = match &cli.command {
Some(Commands::Completions { .. } | Commands::Docs { .. }) => false,
_ => true, };
if runs_jj {
warn_if_jj_too_old().await;
}
let github_host = cli
.github_host
.clone()
.or_else(|| std::env::var("GH_HOST").ok().filter(|h| !h.is_empty()));
let github_host = github_host.as_deref();
match cli.command {
Some(Commands::Submit(args)) => {
submit_bookmark(&args, github_host).await?;
}
Some(Commands::Graph(args)) => {
print_graph(&args, github_host).await?;
}
Some(Commands::Completions { shell }) => {
clap_complete::generate(shell, &mut Cli::command(), "stakk", &mut std::io::stdout());
}
Some(Commands::Docs { topic }) => {
docs::print(topic);
}
None => {
let args = cli::default_submit_args(config).unwrap_or_else(|e| e.exit());
submit_bookmark(&args, github_host).await?;
}
}
Ok(())
}
async fn warn_if_jj_too_old() {
let jj = Jj::new(RealJjRunner);
if let Ok(Some(version)) = jj.version().await
&& version < MIN_SUPPORTED_JJ_VERSION
{
eprintln!(
"Warning: jj {version} is older than the minimum supported version \
({MIN_SUPPORTED_JJ_VERSION}). stakk may not work correctly — consider upgrading jj."
);
}
}
async fn submit_bookmark(args: &SubmitArgs, github_host: Option<&str>) -> Result<(), StakkError> {
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
let jj = Jj::new(RealJjRunner);
pb.set_message("Resolving GitHub remote...");
let (remote_name, github_repo) = resolve_github_remote(Some(&args.remote), github_host).await?;
pb.set_message("Resolving authentication...");
let auth_token = auth::resolve_token(&github_repo.host).await?;
let forge = forge::github::GitHubForge::new(
&auth_token.token,
github_repo.owner.clone(),
github_repo.repo.clone(),
github_repo.api_base_uri().as_deref(),
)?;
pb.set_message("Building change graph...");
let change_graph = graph::build_change_graph(
&jj,
&args.revset.bookmarks_revset,
&args.revset.heads_revset,
)
.await?;
pb.set_message("Detecting default branch...");
let default_branch = jj.get_default_branch().await?;
pb.finish_and_clear();
let spec = select::explicit::SelectionSpec::from_args(args)?;
let reserved_names = jj.get_local_bookmark_names().await?;
let selection = if spec.is_empty() {
select::resolve_bookmark_interactively(
&change_graph,
args.bookmark_command.as_deref(),
args.auto_prefix.as_deref(),
&reserved_names,
)?
} else {
Some(
select::explicit::resolve_bookmarks_explicitly(
&change_graph,
&spec,
args.auto_prefix.as_deref(),
args.bookmark_command.as_deref(),
&reserved_names,
)
.await?,
)
};
let (analysis, bookmark_creations) = match selection {
Some(result) => {
let analysis = submit::analysis_from_selection(
&result.path,
&result.assignments,
&default_branch,
)?;
let creations: Vec<submit::BookmarkCreation> = result
.assignments
.iter()
.filter(|a| a.is_new)
.map(|a| submit::BookmarkCreation {
bookmark_name: a.bookmark_name.clone(),
change_id: a.change_id.clone(),
short_change_id: a.short_change_id.clone(),
})
.collect();
(analysis, creations)
}
None => return Ok(()),
};
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message("Checking for existing pull requests...");
let plan = submit::create_submission_plan(
&analysis,
bookmark_creations,
&forge,
&remote_name,
args.pr_mode,
args.sync_pr_content,
args.trailers,
)
.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_path, args.stack_placement) {
(Some(path), StackPlacement::Comment | StackPlacement::Body) => Some(
std::fs::read_to_string(path).map_err(|e| StakkError::TemplateLoadFailed {
path: path.clone(),
reason: e.to_string(),
})?,
),
_ => None,
};
let comment_env = forge::comment::build_comment_env(template_source.as_deref())?;
println!("\nExecuting:");
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>,
github_host: 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, github_host) {
return Ok((remote.name.clone(), repo));
}
if let Some(parsed) = parse_remote_url(&remote.url) {
return Err(StakkError::RemoteHostNotConfigured {
name: name.to_string(),
url: remote.url.clone(),
host: parsed.host,
});
}
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, github_host) {
return Ok((remote.name.clone(), repo));
}
}
Err(StakkError::NoGithubRemote)
}
async fn print_graph(args: &GraphArgs, github_host: Option<&str>) -> Result<(), StakkError> {
let spinner = matches!(args.format, GraphFormat::Pretty).then(|| {
let pb = indicatif::ProgressBar::new_spinner();
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message("Loading repository status...");
pb
});
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.revset.bookmarks_revset,
&args.revset.heads_revset,
)
.await?;
if let Some(pb) = spinner {
pb.finish_and_clear();
}
let data = graph::output::GraphData {
default_branch: &default_branch,
remotes: &remotes,
graph: &change_graph,
github_host,
};
print!(
"{}",
graph::output::render(&data, args.format, console::colors_enabled())
);
Ok(())
}