use clap::{Args, Parser, Subcommand};
use gkit_core::git::{Git, SystemGit};
use gkit_core::{checks, clone, conf, config, key, report, stmb, submodules};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
#[derive(Parser)]
#[command(name = "gkit", version, about = "A transparent git/ssh toolkit", long_about = None)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Init(InitArgs),
Clone(CloneArgs),
Logoff(LogoffArgs),
Stmb(StmbArgs),
Key(KeyArgs),
}
fn main() -> ExitCode {
match Cli::parse().cmd {
Cmd::Init(a) => init_cmd(a),
Cmd::Clone(a) => clone_cmd(a),
Cmd::Logoff(a) => logoff_cmd(a),
Cmd::Stmb(a) => stmb_cmd(a),
Cmd::Key(a) => key_cmd(a),
}
}
#[derive(Args)]
struct InitArgs {
#[arg(default_value = "repos.toml")]
file: String,
#[arg(long)]
force: bool,
}
fn init_cmd(args: InitArgs) -> ExitCode {
let path = PathBuf::from(&args.file);
if path.exists() && !args.force {
return die(&format!(
"{} already exists (use --force to overwrite)",
args.file
));
}
let origin = SystemGit.run(Path::new("."), &["remote", "get-url", "origin"]);
let parts = if origin.success {
conf::scp_url_parts(origin.trimmed())
} else {
None
};
let (host, ns) = match &parts {
Some((h, n)) => (Some(h.as_str()), Some(n.as_str())),
None => (None, None),
};
let text = conf::template(host, ns);
if let Err(e) = std::fs::write(&path, &text) {
return die(&format!("cannot write {}: {e}", args.file));
}
println!("created {}", args.file);
match parts {
Some((h, n)) => println!(" host/namespace inferred from origin: {h}:{n}"),
None => println!(" fill in `host` and `namespace`, then add [[repo]] blocks"),
}
ExitCode::SUCCESS
}
#[derive(Args)]
struct CloneArgs {
paths: Vec<String>,
#[arg(long)]
no_submodule_branch: bool,
#[arg(long)]
no_direnv: bool,
}
fn resolve_confs(paths: &[String]) -> Result<Vec<PathBuf>, String> {
if paths.is_empty() {
return Err("need at least one conf file, e.g. `repos.toml` or `*.toml`".into());
}
let mut out: Vec<PathBuf> = Vec::new();
for p in paths {
let pb = PathBuf::from(p);
if pb.is_dir() {
return Err(format!(
"`{p}` is a directory — pass conf file(s), e.g. `{}/*.toml`",
p.trim_end_matches('/')
));
}
out.push(pb);
}
out.dedup();
Ok(out)
}
fn clone_cmd(args: CloneArgs) -> ExitCode {
let confs = match resolve_confs(&args.paths) {
Ok(c) => c,
Err(e) => return die(&e),
};
let opts = clone::Opts {
submodule_branch: !args.no_submodule_branch,
direnv: !args.no_direnv,
};
let mut failed = false;
for conf_path in &confs {
if confs.len() > 1 {
println!("== {} ==", conf_path.display());
}
let text = match std::fs::read_to_string(conf_path) {
Ok(t) => t,
Err(e) => {
eprintln!("gkit: cannot read conf `{}`: {e}", conf_path.display());
failed = true;
continue;
}
};
let cfg = match conf::parse(&text) {
Ok(c) => c,
Err(e) => {
eprintln!("gkit: {}: {e}", conf_path.display());
failed = true;
continue;
}
};
if let Err(e) = cfg.validate() {
eprintln!("gkit: {}: {e}", conf_path.display());
failed = true;
continue;
}
let reports = clone::clone_all(&SystemGit, &cfg, &opts);
if reports
.iter()
.any(|r| matches!(r.outcome, clone::Outcome::Failed(_)))
{
failed = true;
}
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
#[derive(Args)]
struct LogoffArgs {
paths: Vec<String>,
#[arg(long)]
conf: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
no_fetch: bool,
#[arg(long)]
base_branch: Option<String>,
}
fn logoff_cmd(args: LogoffArgs) -> ExitCode {
let git = SystemGit;
let mut failed = false;
let mut dirs: Vec<PathBuf> = Vec::new();
if args.conf {
let confs = match resolve_confs(&args.paths) {
Ok(c) => c,
Err(e) => return die(&e),
};
for conf_path in &confs {
if confs.len() > 1 {
println!("== {} ==", conf_path.display());
}
let cfg = match std::fs::read_to_string(conf_path)
.map_err(|e| e.to_string())
.and_then(|t| conf::parse(&t))
{
Ok(c) => c,
Err(e) => {
eprintln!("gkit: {}: {e}", conf_path.display());
failed = true;
continue;
}
};
for r in &cfg.repo {
dirs.push(PathBuf::from(conf::expand_path(&r.dir, |k| {
std::env::var(k).ok()
})));
}
}
} else {
let srcs: Vec<String> = if args.paths.is_empty() {
vec![".".into()]
} else {
args.paths.clone()
};
dirs = srcs.iter().map(|p| canonical(p)).collect();
}
for dir in &dirs {
let base = if args.conf {
None
} else {
args.base_branch.as_deref()
};
let entries = submodules::evaluate_tree(&git, dir, base, !args.no_fetch);
if args.verbose {
report::print_verbose(&entries);
} else {
report::print_default(&entries);
}
if !report::all_ok(&entries) {
failed = true;
}
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
#[derive(Args)]
struct StmbArgs {
#[arg(default_value = ".")]
path: String,
#[arg(long)]
base: Option<String>,
#[arg(long)]
no_recursive: bool,
#[arg(long)]
force: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
dry_run: bool,
}
enum Step {
Switch {
dir: PathBuf,
base: String,
feature: Option<String>,
},
Skip {
dir: PathBuf,
why: String,
},
}
fn stmb_cmd(args: StmbArgs) -> ExitCode {
let git = SystemGit;
let root = canonical(&args.path);
let repos = if args.no_recursive {
vec![root.clone()]
} else {
submodules::repo_paths(&git, &root)
};
let base = match config::resolve_switch_base(&git, &root, args.base.as_deref()) {
Some(b) => b,
None => return die("cannot determine base branch — pass --base <branch>"),
};
let steps: Vec<Step> = repos
.iter()
.map(|dir| {
let cur = config::current_branch_opt(&git, dir);
let dirty = !checks::committed(&git, dir);
match stmb::plan(cur.as_deref(), &base, dirty) {
Ok(p) => Step::Switch {
dir: dir.clone(),
base: p.base,
feature: p.delete_feature,
},
Err(why) => Step::Skip {
dir: dir.clone(),
why,
},
}
})
.collect();
println!("stmb plan ({} repo(s)):", steps.len());
for s in &steps {
match s {
Step::Switch { dir, base, feature } => {
let del = feature
.as_deref()
.map(|f| format!(", delete '{f}'"))
.unwrap_or_default();
println!(" {} -> switch to '{base}', pull{del}", short(dir, &root));
}
Step::Skip { dir, why } => println!(" {} -- skip: {why}", short(dir, &root)),
}
}
if args.dry_run {
return ExitCode::SUCCESS;
}
if !args.yes && !confirm("Proceed?") {
println!("aborted.");
return ExitCode::SUCCESS;
}
let mut failed = false;
for s in &steps {
if let Step::Switch { dir, base, feature } = s {
println!("{}:", short(dir, &root));
if let Err(e) = run_stmb(&git, dir, base, feature.as_deref(), args.force) {
eprintln!("gkit stmb: {}: {e}", short(dir, &root));
failed = true;
}
}
}
println!("\n--- logoff ---");
let entries = submodules::evaluate_tree(&git, &root, None, false);
report::print_default(&entries);
if failed || !report::all_ok(&entries) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn run_stmb(
git: &SystemGit,
dir: &Path,
base: &str,
feature: Option<&str>,
force: bool,
) -> Result<(), String> {
let run = |args: &[&str]| {
println!(" + git {}", args.join(" "));
git.run(dir, args)
};
let co = run(&["checkout", base]);
if !co.success {
return Err(format!("checkout {base} failed: {}", co.stderr.trim()));
}
let _ = run(&["pull", "--rebase", "origin", base]);
if let Some(f) = feature {
let del = run(&["branch", "-d", f]);
if !del.success {
if force {
let force_del = run(&["branch", "-D", f]);
if !force_del.success {
return Err(format!(
"force-delete '{f}' failed: {}",
force_del.stderr.trim()
));
}
} else {
return Err(format!(
"'{f}' not fully merged into {base}; rerun with --force to delete anyway"
));
}
}
}
let _ = run(&["remote", "prune", "origin"]);
Ok(())
}
#[derive(Args)]
struct KeyArgs {
#[command(subcommand)]
action: KeyAction,
}
#[derive(Subcommand)]
enum KeyAction {
Add(KeyAddArgs),
Copy { alias: String },
List,
}
#[derive(Args)]
struct KeyAddArgs {
alias: String,
#[arg(long)]
email: String,
#[arg(long, default_value = "github.com")]
host: String,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
}
fn key_cmd(args: KeyArgs) -> ExitCode {
match args.action {
KeyAction::Add(a) => key_add(a),
KeyAction::Copy { alias } => key_copy(&alias),
KeyAction::List => key_list(),
}
}
fn ssh_dir() -> PathBuf {
key::home_from_env(|k| std::env::var(k).ok())
.unwrap_or_else(|| PathBuf::from("."))
.join(".ssh")
}
fn key_add(a: KeyAddArgs) -> ExitCode {
let ssh = ssh_dir();
let key_path = ssh.join(format!("id_{}", a.alias));
let git_users = ssh.join("git_users");
let ssh_config = ssh.join("config");
let macos = cfg!(target_os = "macos");
let block = key::host_block(&a.alias, &a.host, a.port, macos);
let existing_gu = std::fs::read_to_string(&git_users).unwrap_or_default();
let new_gu = key::upsert_block(&existing_gu, &a.alias, &block);
let existing_cfg = std::fs::read_to_string(&ssh_config).unwrap_or_default();
let new_cfg = key::ensure_include(&existing_cfg);
let need_keygen = !key_path.exists();
println!("gkit key add '{}':", a.alias);
if need_keygen {
println!(
" ssh-keygen -t ed25519 -C {} -f {}",
a.email,
key_path.display()
);
} else {
println!(" (key {} already exists — keeping it)", key_path.display());
}
println!(" upsert Host block into {}:", git_users.display());
for l in block.lines() {
println!(" {l}");
}
match &new_cfg {
Some(_) => println!(
" ensure `Include git_users` in {} (asks first)",
ssh_config.display()
),
None => println!(
" (`Include git_users` already present in {})",
ssh_config.display()
),
}
println!(" ssh-add the key, then copy the public key to the clipboard");
if a.dry_run {
return ExitCode::SUCCESS;
}
if !a.yes && !confirm("Proceed?") {
println!("aborted.");
return ExitCode::SUCCESS;
}
if let Err(e) = std::fs::create_dir_all(&ssh) {
return die(&format!("cannot create {}: {e}", ssh.display()));
}
if need_keygen {
let st = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-C", &a.email, "-f"])
.arg(&key_path)
.status();
if !matches!(st, Ok(s) if s.success()) {
return die("ssh-keygen failed");
}
}
if let Err(e) = std::fs::write(&git_users, &new_gu) {
return die(&format!("cannot write {}: {e}", git_users.display()));
}
match new_cfg {
None => println!(
"✓ `Include git_users` already present in {}",
ssh_config.display()
),
Some(c) => {
println!(
"! {} does not `Include git_users` — without it, ssh ignores the Host",
ssh_config.display()
);
println!(" block(s) gkit manages in {}.", git_users.display());
if a.yes
|| confirm(&format!(
"Add `Include git_users` to {}?",
ssh_config.display()
))
{
if let Err(e) = std::fs::write(&ssh_config, c) {
return die(&format!("cannot write {}: {e}", ssh_config.display()));
}
println!(" added `Include git_users`.");
} else {
println!(
" skipped — add `Include git_users` to {} yourself to activate the key.",
ssh_config.display()
);
}
}
}
let mut add = Command::new("ssh-add");
if macos {
add.arg("--apple-use-keychain");
}
let _ = add.arg(&key_path).status();
let pubfile = key_path.with_extension("pub");
match std::fs::read_to_string(&pubfile) {
Ok(pubkey) => match clipboard_copy(&pubkey) {
Some(tool) => {
println!(
"done. id_{}.pub copied to clipboard ({tool}) — paste it into {}.",
a.alias, a.host
)
}
None => {
println!("done. public key (upload to {}):", a.host);
print!("{pubkey}");
}
},
Err(e) => println!("done, but cannot read {}: {e}", pubfile.display()),
}
ExitCode::SUCCESS
}
fn key_copy(alias: &str) -> ExitCode {
let pubfile = ssh_dir().join(format!("id_{alias}.pub"));
let pubkey = match std::fs::read_to_string(&pubfile) {
Ok(k) => k,
Err(e) => return die(&format!("cannot read {}: {e}", pubfile.display())),
};
match clipboard_copy(&pubkey) {
Some(tool) => println!("copied id_{alias}.pub to clipboard ({tool})"),
None => print!("{pubkey}"),
}
ExitCode::SUCCESS
}
fn clipboard_copy(text: &str) -> Option<&'static str> {
for (prog, pargs) in key::clipboard_candidates(std::env::consts::OS) {
let Ok(mut child) = Command::new(prog)
.args(&pargs)
.stdin(std::process::Stdio::piped())
.spawn()
else {
continue;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
if child.wait().map(|s| s.success()).unwrap_or(false) {
return Some(prog);
}
}
None
}
fn key_list() -> ExitCode {
let git_users = ssh_dir().join("git_users");
let content = std::fs::read_to_string(&git_users).unwrap_or_default();
let hosts = key::list_hosts(&content);
if hosts.is_empty() {
println!("(no Host blocks in {})", git_users.display());
} else {
for (alias, identity) in hosts {
println!("{alias:<20} {identity}");
}
}
ExitCode::SUCCESS
}
fn canonical(p: &str) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p))
}
fn short(dir: &Path, root: &Path) -> String {
dir.strip_prefix(root)
.ok()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.display().to_string())
.unwrap_or_else(|| ".".to_string())
}
fn confirm(msg: &str) -> bool {
print!("{msg} [y/N]: ");
let _ = std::io::stdout().flush();
let mut s = String::new();
let _ = std::io::stdin().read_line(&mut s);
matches!(s.trim(), "y" | "Y" | "yes" | "Yes")
}
fn die(msg: &str) -> ExitCode {
eprintln!("gkit: {msg}");
ExitCode::from(2)
}
#[cfg(test)]
mod tests {
use super::resolve_confs;
use std::fs;
use std::path::PathBuf;
#[test]
fn resolve_confs_requires_explicit_files() {
let base = std::env::temp_dir().join(format!("gkit-rc-{}", std::process::id()));
let _ = fs::remove_dir_all(&base); fs::create_dir_all(&base).unwrap();
let a = base.join("a.toml");
let b = base.join("b.toml");
fs::write(&a, "").unwrap();
fs::write(&b, "").unwrap();
let s = |p: &std::path::Path| p.to_string_lossy().into_owned();
let names = |r: Result<Vec<PathBuf>, String>| {
r.unwrap()
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect::<Vec<_>>()
};
assert!(resolve_confs(&[]).is_err());
assert!(resolve_confs(&[s(&base)]).is_err());
assert_eq!(names(resolve_confs(&[s(&a), s(&b)])), ["a.toml", "b.toml"]);
assert_eq!(names(resolve_confs(&[s(&a), s(&a)])), ["a.toml"]);
let _ = fs::remove_dir_all(&base);
}
}