use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use colored::Colorize;
use crate::git;
pub fn run(output: Option<String>, max_commits: usize) {
let commits = git::commit_graph(max_commits);
if commits.is_empty() {
println!();
println!(" {} No commits to map.", "⚠".yellow());
println!();
return;
}
let tips = git::branch_tips();
let head = git::head_hash();
let current_branch = git::current_branch();
let mut branch_map: HashMap<String, Vec<String>> = HashMap::new();
for (hash, name) in &tips {
branch_map.entry(hash.clone()).or_default().push(name.clone());
}
let known: HashSet<String> = commits.iter().map(|c| c.hash.clone()).collect();
let mut dot = String::new();
dot.push_str("digraph repo {\n");
dot.push_str(" rankdir=TB;\n");
dot.push_str(" bgcolor=\"#0d1117\";\n");
dot.push_str(" node [style=filled, fontname=\"Consolas,monospace\", fontsize=10];\n");
dot.push_str(" edge [color=\"#8b949e\"];\n");
dot.push_str("\n");
for commit in &commits {
let is_head = head.as_deref() == Some(&commit.hash);
let branches = branch_map.get(&commit.hash);
let is_merge = commit.parents.len() > 1;
let label = escape_dot(&commit.subject);
let short = &commit.short_hash;
let (fillcolor, fontcolor, shape) = if is_head {
("#238636", "#ffffff", "box")
} else if is_merge {
("#1f6feb", "#ffffff", "box")
} else {
("#161b22", "#c9d1d9", "box")
};
let border_color = if is_head { "#3fb950" } else { "#30363d" };
dot.push_str(&format!(
" \"{}\" [label=\"{} {}\" shape={} fillcolor=\"{}\" fontcolor=\"{}\" color=\"{}\"];\n",
commit.hash, short, label, shape, fillcolor, fontcolor, border_color
));
if let Some(names) = branches {
for name in names {
let tag_color = if *name == current_branch {
"#238636"
} else {
"#1f6feb"
};
let tag_id = format!("branch_{}", name.replace('/', "_").replace('-', "_"));
dot.push_str(&format!(
" \"{}\" [label=\"{}\" shape=ellipse fillcolor=\"{}\" fontcolor=\"#ffffff\" color=\"{}\" fontsize=11 style=\"filled,bold\"];\n",
tag_id, name, tag_color, tag_color
));
dot.push_str(&format!(
" \"{}\" -> \"{}\" [style=dashed color=\"{}\"];\n",
tag_id, commit.hash, tag_color
));
}
}
if is_head {
dot.push_str(&format!(
" \"HEAD\" [label=\"HEAD\" shape=diamond fillcolor=\"#f0883e\" fontcolor=\"#ffffff\" color=\"#f0883e\" fontsize=11 style=\"filled,bold\"];\n"
));
dot.push_str(&format!(
" \"HEAD\" -> \"{}\" [style=dashed color=\"#f0883e\"];\n",
commit.hash
));
}
}
dot.push_str("\n");
for commit in &commits {
for parent in &commit.parents {
if known.contains(parent) {
dot.push_str(&format!(
" \"{}\" -> \"{}\";\n",
commit.hash, parent
));
}
}
}
dot.push_str("}\n");
let out_path = match output {
Some(p) => PathBuf::from(p),
None => PathBuf::from("repo-map.dot"),
};
match fs::write(&out_path, &dot) {
Ok(_) => {
println!();
println!(
" {} Repo map exported to {}",
"✔".green().bold(),
out_path.display().to_string().cyan()
);
println!();
println!(" {} commits, {} branches mapped.", commits.len(), tips.len());
println!();
println!(" {}:", "To render".dimmed());
println!(
" {} {}",
"→".dimmed(),
format!("dot -Tpng {} -o repo-map.png", out_path.display()).cyan()
);
println!(
" {} {}",
"→".dimmed(),
format!("dot -Tsvg {} -o repo-map.svg", out_path.display()).cyan()
);
println!();
}
Err(e) => {
println!();
println!(" {} Failed to write {}: {}", "✖".red(), out_path.display(), e);
println!();
}
}
}
fn escape_dot(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
}