use clap::{Args, Parser, Subcommand};
use gkit_core::git::{Git, SystemGit};
use gkit_core::{checks, clone, conf, config, fixsub, key, report, stamp, stmb, submodules};
use std::io::{IsTerminal, 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),
Stamp(StampArgs),
Logoff(LogoffArgs),
Stmb(StmbArgs),
Fixsub(FixsubArgs),
Key(KeyArgs),
}
fn main() -> ExitCode {
match Cli::parse().cmd {
Cmd::Init(a) => init_cmd(a),
Cmd::Clone(a) => clone_cmd(a),
Cmd::Stamp(a) => stamp_cmd(a),
Cmd::Logoff(a) => logoff_cmd(a),
Cmd::Stmb(a) => stmb_cmd(a),
Cmd::Fixsub(a) => fixsub_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,
#[arg(long)]
user_name: Option<String>,
#[arg(long)]
user_email: Option<String>,
#[arg(long)]
no_insteadof: 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 interactive = std::io::stdin().is_terminal();
let user_name = resolve_identity(args.user_name, interactive, || {
prompt_identity("git user.name", git_config_global("user.name"))
});
let user_email = resolve_identity(args.user_email, interactive, || {
prompt_identity("git user.email", git_config_global("user.email"))
});
let git_users = std::fs::read_to_string(ssh_dir().join("git_users")).unwrap_or_default();
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;
}
if !args.no_insteadof {
setup_insteadof(&git_users, &cfg);
}
let abs_conf = std::fs::canonicalize(conf_path)
.unwrap_or_else(|_| conf_path.clone())
.to_string_lossy()
.into_owned();
let opts = clone::Opts {
submodule_branch: !args.no_submodule_branch,
direnv: !args.no_direnv,
user_name: user_name.clone(),
user_email: user_email.clone(),
conf_path: Some(abs_conf),
};
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
}
}
fn setup_insteadof(git_users: &str, cfg: &conf::CloneConf) {
let hostname = match key::hostname_for(git_users, &cfg.host) {
Some(h) => h,
None => {
eprintln!(
"gkit: warning: no HostName for ssh alias '{}' in ~/.ssh/git_users — \
skipping insteadOf routing (run `gkit key add {}` to enable it)",
cfg.host, cfg.host
);
return;
}
};
let home = key::home_from_env(|k| std::env::var(k).ok()).unwrap_or_else(|| PathBuf::from("."));
let routing = home.join(".gitconfig-gkit").to_string_lossy().into_owned();
let git = SystemGit;
let have = git.run(
Path::new("."),
&["config", "--global", "--get-all", "include.path"],
);
if !have.stdout.lines().any(|l| l.trim() == routing) {
println!("+ git config --global --add include.path {routing}");
let out = git.run(
Path::new("."),
&["config", "--global", "--add", "include.path", &routing],
);
if !out.success {
eprintln!(
"gkit: warning: could not add include.path: {}",
out.stderr.trim()
);
}
}
for ns in clone::distinct_namespaces(cfg) {
let (key, val) = clone::insteadof_pair(&cfg.host, &hostname, &ns);
println!("+ git config -f {routing} --replace-all {key} {val}");
let out = git.run(
Path::new("."),
&["config", "-f", &routing, "--replace-all", &key, &val],
);
if !out.success {
eprintln!("gkit: warning: could not set {key}: {}", out.stderr.trim());
}
}
}
#[derive(Args)]
struct StampArgs {
paths: Vec<String>,
#[arg(long)]
conf: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
}
fn stamp_cmd(args: StampArgs) -> ExitCode {
if args.conf {
stamp_conf_mode(args)
} else {
stamp_repo_mode(args)
}
}
fn stamp_conf_mode(args: StampArgs) -> ExitCode {
let confs = match resolve_confs(&args.paths) {
Ok(c) => c,
Err(e) => return die(&e),
};
let mut failed = false;
let mut loaded: Vec<(PathBuf, conf::CloneConf)> = Vec::new();
for conf_path in &confs {
match std::fs::read_to_string(conf_path)
.map_err(|e| e.to_string())
.and_then(|t| conf::parse(&t))
.and_then(|c| c.validate().map(|_| c))
{
Ok(c) => loaded.push((conf_path.clone(), c)),
Err(e) => {
eprintln!("gkit: {}: {e}", conf_path.display());
failed = true;
}
}
}
println!("stamp plan (conf mode):");
for (path, cfg) in &loaded {
if loaded.len() > 1 {
println!("== {} ==", path.display());
}
for r in &cfg.repo {
let dir = conf::expand_path(&r.dir, |k| std::env::var(k).ok());
let hooks = stamp::effective_post_clone(cfg, r);
if hooks.is_empty() {
println!(
" {} ({dir}) -- no post-clone hooks (gkit.conf back-filled)",
r.name()
);
} else {
println!(" {} ({dir}):", r.name());
for h in &hooks {
println!(" + {h}");
}
}
}
}
if args.dry_run {
return if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
};
}
if !args.yes && !confirm("Proceed?") {
println!("aborted.");
return ExitCode::SUCCESS;
}
for (path, cfg) in &loaded {
if loaded.len() > 1 {
println!("== {} ==", path.display());
}
let abs = std::fs::canonicalize(path)
.unwrap_or_else(|_| path.clone())
.to_string_lossy()
.into_owned();
stamp::backfill_conf(&SystemGit, cfg, &abs);
let reports = stamp::stamp_all(&SystemGit, cfg);
if reports
.iter()
.any(|r| matches!(r.outcome, stamp::Outcome::Failed(_)))
{
failed = true;
}
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn stamp_repo_mode(args: StampArgs) -> ExitCode {
let git = SystemGit;
let srcs: Vec<String> = if args.paths.is_empty() {
vec![".".into()]
} else {
args.paths.clone()
};
let dirs: Vec<PathBuf> = srcs.iter().map(|p| canonical(p)).collect();
println!("stamp plan (repo mode):");
for dir in &dirs {
match stamp::plan_repo(&git, dir) {
Ok(plan) => {
println!(" {} (conf: {})", dir.display(), plan.conf_path);
if plan.hooks.is_empty() {
println!(" -- no post-clone hooks");
} else {
for h in &plan.hooks {
println!(" + {h}");
}
}
}
Err(e) => println!(" {} -- {e}", dir.display()),
}
}
if args.dry_run {
return ExitCode::SUCCESS;
}
if !args.yes && !confirm("Proceed?") {
println!("aborted.");
return ExitCode::SUCCESS;
}
let mut failed = false;
for dir in &dirs {
let report = stamp::stamp_repo(&git, dir);
if matches!(report.outcome, stamp::Outcome::Failed(_)) {
failed = true;
}
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
#[derive(Args)]
struct LogoffArgs {
paths: Vec<String>,
#[arg(long)]
conf: bool,
#[arg(short = 'v', action = clap::ArgAction::Count)]
verbose: u8,
#[arg(short = 'e', value_name = "RULE")]
explain: Option<Option<u8>>,
#[arg(long)]
no_fetch: bool,
#[arg(long)]
base_branch: Option<String>,
}
fn logoff_cmd(args: LogoffArgs) -> ExitCode {
if let Some(which) = args.explain {
match which {
None => {
report::print_rules();
return ExitCode::SUCCESS;
}
Some(n) => {
let Some(rule) = checks::RuleId::from_num(n) else {
return die(&format!("-e: no such rule {n} (valid rules are R1..R6)"));
};
let git = SystemGit;
let dir = canonical(args.paths.first().map(String::as_str).unwrap_or("."));
let base = config::resolve_base(&git, &dir, args.base_branch.as_deref());
let solo = config::resolve_solo(&git, &dir);
let allow_diverged = config::resolve_allow_diverged(&git, &dir);
report::print_rule_detail(&checks::rule_report(
&git,
&dir,
&base,
solo,
allow_diverged,
rule,
));
return ExitCode::SUCCESS;
}
}
}
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 >= 1 {
report::print_verbose(&entries, args.verbose >= 2);
} 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(&["switch", base]);
if !co.success {
return Err(format!("switch to {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 FixsubArgs {
#[arg(default_value = ".")]
path: String,
#[arg(long)]
no_direnv: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
}
fn fixsub_cmd(args: FixsubArgs) -> ExitCode {
let git = SystemGit;
let root = canonical(&args.path);
let direnv = !args.no_direnv;
println!("fixsub plan:");
if let fixsub::Outcome::Failed(e) = fixsub::fixsub(&git, &root, true, direnv).outcome {
eprintln!("gkit fixsub: {e}");
return ExitCode::FAILURE;
}
if args.dry_run {
return ExitCode::SUCCESS;
}
if !args.yes && !confirm("Proceed?") {
println!("aborted.");
return ExitCode::SUCCESS;
}
match fixsub::fixsub(&git, &root, false, direnv).outcome {
fixsub::Outcome::Failed(e) => {
eprintln!("gkit fixsub: {e}");
ExitCode::FAILURE
}
_ => ExitCode::SUCCESS,
}
}
#[derive(Args)]
struct KeyArgs {
#[command(subcommand)]
action: KeyAction,
}
#[derive(Subcommand)]
enum KeyAction {
Add(KeyAddArgs),
List,
}
#[derive(Args)]
struct KeyAddArgs {
alias: Option<String>,
#[arg(long)]
email: Option<String>,
#[arg(long)]
host: Option<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::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 interactive = std::io::stdin().is_terminal();
let alias = match resolve_arg(
a.alias,
interactive,
"alias (ssh Host = key name id_<alias>)",
) {
Ok(v) => v,
Err(c) => return c,
};
let email = match resolve_arg(a.email, interactive, "email (key comment)") {
Ok(v) => v,
Err(c) => return c,
};
let host = match a.host {
Some(h) => h,
None if interactive => prompt_provider(),
None => key::PROVIDERS[0].to_string(),
};
let ssh = ssh_dir();
let key_path = ssh.join(format!("id_{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(&alias, &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, &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 '{alias}':");
if need_keygen {
println!(
" ssh-keygen -t ed25519 -C {} -f {}",
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", &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_default(
&format!("Add `Include git_users` to {}?", ssh_config.display()),
true,
)
{
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_{alias}.pub copied to clipboard ({tool}) — paste it into {host}."
)
}
None => {
println!("done. public key (upload to {host}):");
print!("{pubkey}");
}
},
Err(e) => println!("done, but cannot read {}: {e}", pubfile.display()),
}
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 {
confirm_default(msg, false)
}
fn confirm_default(msg: &str, default_yes: bool) -> bool {
let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
print!("{msg} {hint}: ");
let _ = std::io::stdout().flush();
let mut s = String::new();
if std::io::stdin().read_line(&mut s).unwrap_or(0) == 0 {
return default_yes; }
match s.trim() {
"" => default_yes,
t => matches!(t, "y" | "Y" | "yes" | "Yes"),
}
}
fn resolve_arg(val: Option<String>, interactive: bool, label: &str) -> Result<String, ExitCode> {
if let Some(v) = val {
return Ok(v);
}
let what = label.split_whitespace().next().unwrap_or("value");
if !interactive {
return Err(die(&format!("missing {what} (pass it as an argument)")));
}
read_line(label).ok_or_else(|| die(&format!("missing {what}")))
}
fn read_line(label: &str) -> Option<String> {
print!("{label}: ");
let _ = std::io::stdout().flush();
let mut s = String::new();
if std::io::stdin().read_line(&mut s).unwrap_or(0) == 0 {
return None; }
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
fn resolve_identity(
flag: Option<String>,
interactive: bool,
prompt: impl FnOnce() -> Option<String>,
) -> Option<String> {
match flag {
Some(v) => Some(v),
None if interactive => prompt(),
None => None,
}
}
fn git_config_global(key: &str) -> Option<String> {
let out = SystemGit.run(Path::new("."), &["config", "--global", key]);
let v = out.trimmed();
(out.success && !v.is_empty()).then(|| v.to_string())
}
fn prompt_identity(label: &str, default: Option<String>) -> Option<String> {
match &default {
Some(d) => print!("{label} [{d}]: "),
None => print!("{label}: "),
}
let _ = std::io::stdout().flush();
let mut s = String::new();
if std::io::stdin().read_line(&mut s).unwrap_or(0) == 0 {
return default; }
let t = s.trim();
if t.is_empty() {
default
} else {
Some(t.to_string())
}
}
fn prompt_provider() -> String {
loop {
println!("provider:");
for (i, p) in key::PROVIDERS.iter().enumerate() {
let tag = if i == 0 { " (default)" } else { "" };
println!(" {}) {p}{tag}", i + 1);
}
println!(" {}) other (custom hostname)", key::PROVIDERS.len() + 1);
let raw =
read_line(&format!("choose [1-{}]", key::PROVIDERS.len() + 1)).unwrap_or_default();
match key::provider_choice(&raw) {
key::ProviderChoice::Host(h) => return h,
key::ProviderChoice::Custom => {
if let Some(h) = read_line("hostname (e.g. git.mycorp.com)") {
return h;
}
return key::PROVIDERS[0].to_string();
}
key::ProviderChoice::Invalid => {
println!(" ? not a listed option — try again");
continue;
}
}
}
}
fn die(msg: &str) -> ExitCode {
eprintln!("gkit: {msg}");
ExitCode::from(2)
}
#[cfg(test)]
mod tests {
use super::{resolve_confs, resolve_identity};
use std::fs;
use std::path::PathBuf;
#[test]
fn resolve_identity_flag_prompt_or_none() {
assert_eq!(
resolve_identity(Some("Jane".into()), false, || panic!("must not prompt")),
Some("Jane".to_string())
);
assert_eq!(
resolve_identity(None, true, || Some("Prompted".into())),
Some("Prompted".to_string())
);
assert_eq!(resolve_identity(None, true, || None), None);
assert_eq!(
resolve_identity(None, false, || panic!("must not prompt")),
None
);
}
#[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);
}
}