use crate::config::Config;
use crate::engine::{BranchMetadata, Stack};
use crate::forge;
use crate::git::{refs, GitRepo};
use crate::remote::{self, RemoteInfo};
use anyhow::Result;
use colored::Colorize;
use std::process::Command;
pub fn run() -> Result<()> {
println!("{}", "stax doctor".bold());
println!();
let repo = match GitRepo::open() {
Ok(repo) => repo,
Err(err) => {
println!("{} {}", "✗".red(), err);
return Ok(());
}
};
let config = Config::load()?;
let mut issues = 0;
if repo.is_initialized() {
println!("{} {}", "✓".green(), "Repo initialized".dimmed());
} else {
println!(
"{} {}",
"✗".red(),
"Repo not initialized (run `stax` once)".yellow()
);
issues += 1;
}
match repo.trunk_branch() {
Ok(trunk) => println!("{} {} {}", "✓".green(), "Trunk:".dimmed(), trunk.cyan()),
Err(err) => {
println!("{} {} {}", "✗".red(), "Trunk not set:".yellow(), err);
issues += 1;
}
}
let remote_name = config.remote_name();
match remote::get_remote_url(repo.workdir()?, remote_name) {
Ok(url) => println!(
"{} {} {}",
"✓".green(),
"Remote:".dimmed(),
format!("{} ({})", remote_name, url).cyan()
),
Err(err) => {
println!("{} {} {}", "✗".red(), "Remote missing:".yellow(), err);
issues += 1;
}
}
let remote_info = RemoteInfo::from_repo(&repo, &config).ok();
let forge_label = remote_info
.as_ref()
.map(|info| info.forge.to_string())
.unwrap_or_else(|| "Forge".to_string());
let has_token = remote_info
.as_ref()
.map(|info| forge::forge_token(info.forge).is_some())
.unwrap_or_else(|| Config::github_token().is_some());
if has_token {
println!(
"{} {}",
"✓".green(),
format!("{} API token available", forge_label).dimmed()
);
} else {
println!(
"{} {}",
"⚠".yellow(),
format!(
"{} API token missing (run `stax auth` — needed for PR/submit against this remote)",
forge_label
)
.yellow()
);
}
if repo.is_dirty()? {
println!("{} {}", "⚠".yellow(), "Working tree is dirty".yellow());
} else {
println!("{} {}", "✓".green(), "Working tree clean".dimmed());
}
if repo.rebase_in_progress()? {
println!(
"{} {}",
"⚠".yellow(),
"Rebase in progress (run `stax continue`)".yellow()
);
}
if let Ok(stack) = Stack::load(&repo) {
let mut orphaned = Vec::new();
for (name, info) in &stack.branches {
if let Some(parent) = &info.parent {
if repo.branch_commit(parent).is_err() {
orphaned.push((name.clone(), parent.clone()));
}
}
}
if !orphaned.is_empty() {
issues += 1;
println!(
"{} {}",
"✗".red(),
"Branches with missing parents:".yellow()
);
for (branch, parent) in orphaned {
println!(" {} → {}", branch, parent);
}
}
let needs_restack = stack.needs_restack();
if !needs_restack.is_empty() {
println!(
"{} {}",
"⚠".yellow(),
format!(
"{} {} need restack",
needs_restack.len(),
if needs_restack.len() == 1 {
"branch"
} else {
"branches"
}
)
.yellow()
);
}
}
if let Ok(trunk) = repo.trunk_branch() {
let remote_trunk = format!("{}/{}", remote_name, trunk);
match repo.is_ancestor(&trunk, &remote_trunk) {
Ok(true) => {
println!(
"{} {}",
"✓".green(),
"Local trunk is ancestor of remote trunk".dimmed()
);
}
Ok(false) => {
issues += 1;
println!(
"{} {}",
"⚠".yellow(),
format!(
"Local {} has diverged from {}/{} (remote may have been force-pushed)",
trunk, remote_name, trunk
)
.yellow()
);
}
Err(_) => {
}
}
}
{
let rerere_ok = git_config_is_true(repo.workdir().ok(), "rerere.enabled");
let autostash_ok = git_config_is_true(repo.workdir().ok(), "rebase.autoStash");
if rerere_ok && autostash_ok {
println!(
"{} {}",
"✓".green(),
"Git config: rerere.enabled and rebase.autoStash are set".dimmed()
);
} else {
let mut missing = Vec::new();
if !rerere_ok {
missing.push("rerere.enabled");
}
if !autostash_ok {
missing.push("rebase.autoStash");
}
println!(
"{} {}",
"⚠".yellow(),
format!(
"Recommended git config not set: {}. Run: {}",
missing.join(", "),
missing
.iter()
.map(|k| format!("git config --global {} true", k))
.collect::<Vec<_>>()
.join(" && ")
)
.yellow()
);
}
}
{
let local_branches: std::collections::HashSet<String> = repo
.list_branches()
.unwrap_or_default()
.into_iter()
.collect();
let metadata_branches = refs::list_metadata_branches(repo.inner()).unwrap_or_default();
let mut stale = Vec::new();
for branch_name in &metadata_branches {
if local_branches.contains(branch_name) {
continue;
}
if let Ok(Some(meta)) = BranchMetadata::read(repo.inner(), branch_name) {
if let Some(pr) = &meta.pr_info {
if pr.state == "OPEN" {
stale.push((branch_name.clone(), pr.number));
}
}
}
}
if stale.is_empty() {
println!("{} {}", "✓".green(), "No stale PR metadata found".dimmed());
} else {
issues += 1;
println!(
"{} {}",
"⚠".yellow(),
format!(
"{} branch(es) have OPEN PR metadata but no local branch:",
stale.len()
)
.yellow()
);
for (branch, pr_num) in &stale {
println!(" {} (PR #{})", branch, pr_num);
}
}
}
println!();
if issues == 0 {
println!("{}", "✓ Doctor check complete (no critical issues)".green());
} else {
println!("{}", format!("✗ Doctor found {} issue(s)", issues).yellow());
}
Ok(())
}
fn git_config_is_true(workdir: Option<&std::path::Path>, key: &str) -> bool {
let mut cmd = Command::new("git");
cmd.args(["config", "--get", key]);
if let Some(cwd) = workdir {
cmd.current_dir(cwd);
}
match cmd.output() {
Ok(output) if output.status.success() => {
let value = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
value == "true"
}
_ => false,
}
}