use std::path::Path;
use dialoguer::{Input, theme::ColorfulTheme};
use crate::config::{Config, VerbosityLevel};
use crate::git;
use crate::output;
use crate::remote;
use crate::urls::resolve_urls;
use crate::validate::validate_repo_name;
macro_rules! vlog {
($verbosity:expr, $level:ident, $($arg:tt)*) => {
if $verbosity >= VerbosityLevel::$level {
println!($($arg)*);
}
};
}
pub fn run(
repo: Option<String>,
alias: Option<String>,
quiet: bool,
debug: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load()?;
let work_dir = std::env::current_dir()?;
let skip_check = std::env::var("ENTANGLE_SKIP_REMOTE_CHECK").is_ok();
run_with_paths(
repo,
alias,
config,
&work_dir,
quiet,
debug,
|origin, mirror| {
if skip_check {
return Ok(());
}
remote::validate_remotes(origin, mirror)
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })
},
)
}
pub fn run_with_paths(
repo: Option<String>,
alias: Option<String>,
config: Config,
work_dir: &Path,
quiet: bool,
debug: bool,
remote_validator: impl Fn(&str, &str) -> Result<(), Box<dyn std::error::Error>>,
) -> Result<(), Box<dyn std::error::Error>> {
let verbosity = config.effective_verbosity(quiet, debug);
let (repo_name, interactive) = match repo {
Some(r) => {
let v = validate_repo_name(&r)?;
(v, false)
}
None => (prompt_repo_name()?, true),
};
let alias_name: Option<String> = match (alias, interactive) {
(Some(a), _) => {
let v = validate_repo_name(&a)?;
Some(v)
}
(None, true) => prompt_alias_optional()?,
(None, false) => None,
};
let (origin_url, mirror_url) = resolve_urls(&config, &repo_name, alias_name.as_deref());
let was_initialized = git::init_if_needed(work_dir)?;
if was_initialized {
vlog!(
verbosity,
Verbose,
"{}",
output::progress("Folder is not a git repository — initializing...")
);
vlog!(
verbosity,
Verbose,
"{}",
output::success("Git repository initialized.")
);
} else {
vlog!(
verbosity,
Verbose,
"{}",
output::success("Git repository detected.")
);
}
if !git::has_gitignore(work_dir) {
vlog!(
verbosity,
Verbose,
"{}",
output::tip("Add a .gitignore to avoid committing build artifacts.")
);
}
if !git::has_readme(work_dir) {
vlog!(
verbosity,
Verbose,
"{}",
output::tip("Add a README.md to describe your project.")
);
}
vlog!(verbosity, Verbose, "");
vlog!(verbosity, Verbose, "Configuring remotes for '{repo_name}':");
vlog!(
verbosity,
Verbose,
" Origin (fetch + push): {}",
output::url(&origin_url)
);
vlog!(
verbosity,
Verbose,
" Mirror (push only): {}",
output::url(&mirror_url)
);
use crate::git::OriginStatus;
let origin_status = git::get_origin_status(work_dir)?;
vlog!(
verbosity,
Debug,
" {} origin status: {:?}",
output::debug_tag(),
origin_status
);
if let OriginStatus::Present { push_urls, .. } = &origin_status {
let has_origin_push = push_urls.iter().any(|u| u == &origin_url);
let has_mirror_push = push_urls.iter().any(|u| u == &mirror_url);
vlog!(
verbosity,
Debug,
" {} push_urls={push_urls:?} has_origin_push={has_origin_push} has_mirror_push={has_mirror_push}",
output::debug_tag()
);
if has_origin_push && has_mirror_push {
vlog!(verbosity, Verbose, "");
vlog!(
verbosity,
Verbose,
"{}",
output::success("Both push remotes are already configured. Nothing to do.")
);
vlog!(
verbosity,
Verbose,
" Run {} to push all branches and tags to both forges.",
output::cmd("entangle shove")
);
return Ok(());
}
}
vlog!(verbosity, Verbose, "");
let spinner = create_remote_check_spinner(verbosity);
let check_result = remote_validator(&origin_url, &mirror_url);
if let Some(sp) = spinner {
sp.finish_and_clear();
}
check_result?;
vlog!(
verbosity,
Verbose,
"{}",
output::success("Both remotes are accessible.")
);
let mut replace_fetch_url = false;
let mut kept_existing_fetch: Option<String> = None;
match &origin_status {
OriginStatus::Absent => {
vlog!(
verbosity,
Debug,
" {} no origin remote found; will create from scratch",
output::debug_tag()
);
}
OriginStatus::Present {
fetch_url,
push_urls: _,
} => {
if fetch_url != &origin_url {
vlog!(
verbosity,
Debug,
" {} fetch URL mismatch: existing={fetch_url} expected={origin_url}",
output::debug_tag()
);
let replace = prompt_replace_origin(fetch_url, &origin_url)?;
if replace {
replace_fetch_url = true;
} else {
let proceed = prompt_proceed_anyway(fetch_url)?;
if !proceed {
println!("Init cancelled. No changes were made.");
return Ok(());
}
kept_existing_fetch = Some(fetch_url.clone());
}
}
}
}
vlog!(
verbosity,
Debug,
" {} replace_fetch_url={replace_fetch_url} kept_existing_fetch={kept_existing_fetch:?}",
output::debug_tag()
);
match &origin_status {
OriginStatus::Absent => {
git::create_origin_remote(
work_dir,
&origin_url,
&[mirror_url.as_str(), origin_url.as_str()],
)?;
vlog!(
verbosity,
Debug,
" {} created origin remote with fetch + 2 push URLs",
output::debug_tag()
);
}
OriginStatus::Present {
fetch_url: _,
push_urls,
} => {
if replace_fetch_url {
git::set_origin_fetch_url(work_dir, &origin_url)?;
vlog!(
verbosity,
Debug,
" {} replaced origin fetch URL → {origin_url}",
output::debug_tag()
);
}
let mut to_add: Vec<&str> = Vec::new();
if !push_urls.iter().any(|u| u == &mirror_url) {
to_add.push(mirror_url.as_str());
}
if !push_urls.iter().any(|u| u == &origin_url) {
to_add.push(origin_url.as_str());
}
vlog!(
verbosity,
Debug,
" {} push URLs to add: {:?}",
output::debug_tag(),
to_add
);
if !to_add.is_empty() {
git::add_push_urls_to_origin(work_dir, &to_add)?;
}
}
}
let final_fetch_url = match &kept_existing_fetch {
Some(url) => url.as_str(),
None => origin_url.as_str(),
};
vlog!(verbosity, Verbose, "");
vlog!(
verbosity,
Verbose,
"{}",
output::success(&format!("Remotes configured for '{repo_name}':"))
);
vlog!(verbosity, Verbose, "");
vlog!(
verbosity,
Verbose,
" origin {} (fetch)",
output::url(final_fetch_url)
);
vlog!(
verbosity,
Verbose,
" origin {} (push)",
output::url(&mirror_url)
);
vlog!(
verbosity,
Verbose,
" origin {} (push)",
output::url(&origin_url)
);
vlog!(verbosity, Verbose, "");
vlog!(
verbosity,
Verbose,
"Run {} to push all branches and tags to both forges.",
output::cmd("entangle shove")
);
if let Some(ref existing_url) = kept_existing_fetch {
vlog!(verbosity, Verbose, "");
vlog!(
verbosity,
Verbose,
"{}",
output::warn(&format!(
"Note: origin fetch URL ({existing_url}) was kept as-is."
))
);
vlog!(
verbosity,
Verbose,
" Push URLs have been added — pushes will reach both forges,"
);
vlog!(
verbosity,
Verbose,
" but fetches will come from this origin."
);
}
Ok(())
}
#[cfg_attr(test, mutants::skip)]
fn prompt_replace_origin(
existing_url: &str,
new_url: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
println!();
println!("An origin remote already exists: {existing_url}");
let theme = ColorfulTheme::default();
match dialoguer::Confirm::with_theme(&theme)
.with_prompt(format!("Replace it with {new_url}?"))
.default(true)
.interact()
{
Ok(v) => Ok(v),
Err(e) if is_cancelled(&e) => {
eprintln!("\nInit cancelled. No changes were made.");
Err(e.into())
}
Err(e) => Err(e.into()),
}
}
#[cfg_attr(test, mutants::skip)]
fn prompt_proceed_anyway(existing_url: &str) -> Result<bool, Box<dyn std::error::Error>> {
let theme = ColorfulTheme::default();
match dialoguer::Confirm::with_theme(&theme)
.with_prompt(format!(
"Add push URLs to existing origin ({existing_url}) anyway?"
))
.default(true)
.interact()
{
Ok(v) => Ok(v),
Err(e) if is_cancelled(&e) => {
eprintln!("\nInit cancelled. No changes were made.");
Err(e.into())
}
Err(e) => Err(e.into()),
}
}
#[cfg_attr(test, mutants::skip)]
fn prompt_repo_name() -> Result<String, Box<dyn std::error::Error>> {
let theme = ColorfulTheme::default();
loop {
let raw = match Input::<String>::with_theme(&theme)
.with_prompt("Repository name (on your origin forge)")
.interact_text()
{
Ok(v) => v,
Err(e) if is_cancelled(&e) => {
eprintln!("\nInit cancelled. No changes were made.");
return Err(e.into());
}
Err(e) => return Err(e.into()),
};
match validate_repo_name(&raw) {
Ok(validated) => return Ok(validated),
Err(e) => eprintln!("{}", output::error_inline(&e.to_string())),
}
}
}
#[cfg_attr(test, mutants::skip)]
fn prompt_alias_optional() -> Result<Option<String>, Box<dyn std::error::Error>> {
let theme = ColorfulTheme::default();
loop {
let raw = match Input::<String>::with_theme(&theme)
.with_prompt("Alias on mirror forge (leave blank to use the same name)")
.allow_empty(true)
.interact_text()
{
Ok(v) => v,
Err(e) if is_cancelled(&e) => {
eprintln!("\nInit cancelled. No changes were made.");
return Err(e.into());
}
Err(e) => return Err(e.into()),
};
if raw.trim().is_empty() {
return Ok(None);
}
match validate_repo_name(&raw) {
Ok(validated) => return Ok(Some(validated)),
Err(e) => eprintln!(" ✗ {e}"),
}
}
}
#[cfg_attr(test, mutants::skip)]
fn create_remote_check_spinner(verbosity: VerbosityLevel) -> Option<indicatif::ProgressBar> {
if verbosity >= VerbosityLevel::Verbose {
Some(output::remote_check_spinner(
"Checking remote accessibility…",
))
} else {
None
}
}
#[cfg_attr(test, mutants::skip)]
fn is_cancelled(e: &dialoguer::Error) -> bool {
match e {
dialoguer::Error::IO(io_err) => matches!(
io_err.kind(),
std::io::ErrorKind::Interrupted | std::io::ErrorKind::BrokenPipe
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, OriginPreference};
use tempfile::TempDir;
fn skip_validate(_: &str, _: &str) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn test_config() -> Config {
Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Github,
verbosity_preference: Default::default(),
}
}
fn fresh_work_dir() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let work_dir = dir.path().join("work");
std::fs::create_dir(&work_dir).unwrap();
(dir, work_dir)
}
#[test]
fn run_initializes_git_repo_in_fresh_directory() {
let (_dir, work_dir) = fresh_work_dir();
assert!(
!git::is_git_repo(&work_dir),
"precondition: not yet a git repo"
);
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
assert!(
git::is_git_repo(&work_dir),
"work_dir must be a git repo after run_with_paths"
);
}
#[test]
fn run_is_idempotent_on_existing_repo() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
assert!(git::is_git_repo(&work_dir));
}
#[test]
fn invalid_repo_name_returns_error() {
let (_dir, work_dir) = fresh_work_dir();
let result = run_with_paths(
Some("-invalid-leading-hyphen".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
);
assert!(result.is_err(), "invalid repo name must cause an error");
assert!(
!git::is_git_repo(&work_dir),
"git repo must not be created when repo name is invalid"
);
}
#[test]
fn invalid_alias_returns_error() {
let (_dir, work_dir) = fresh_work_dir();
let result = run_with_paths(
Some("my-repo".to_string()),
Some("-bad-alias".to_string()),
test_config(),
&work_dir,
false,
false,
skip_validate,
);
assert!(result.is_err(), "invalid alias must cause an error");
}
#[test]
fn valid_alias_is_accepted() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("my-repo".to_string()),
Some("mirror-name".to_string()),
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
}
fn append_origin(work_dir: &Path, fetch_url: &str, push_urls: &[&str]) {
use std::io::Write as _;
let cfg = work_dir.join(".git").join("config");
let mut f = std::fs::OpenOptions::new().append(true).open(cfg).unwrap();
writeln!(f, "\n[remote \"origin\"]").unwrap();
writeln!(f, "\turl = {fetch_url}").unwrap();
writeln!(f, "\tfetch = +refs/heads/*:refs/remotes/origin/*").unwrap();
for u in push_urls {
writeln!(f, "\tpushurl = {u}").unwrap();
}
}
#[test]
fn run_with_no_origin_proceeds_to_url_preview() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.expect("must succeed when no origin remote is configured");
}
#[test]
fn run_with_matching_origin_url_proceeds_to_url_preview() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
append_origin(&work_dir, "git@github.com:cyrusae/entangle.git", &[]);
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.expect("must succeed when origin fetch URL matches expected URL");
}
#[test]
fn run_exits_early_when_both_push_urls_already_configured() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
append_origin(
&work_dir,
"git@github.com:cyrusae/entangle.git",
&[
"git@github.com:cyrusae/entangle.git",
"git@tangled.org:atdot.fyi/entangle",
],
);
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.expect("must succeed (early exit) when both push URLs are already configured");
}
#[test]
fn run_proceeds_when_only_one_push_url_present() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
append_origin(
&work_dir,
"git@github.com:cyrusae/entangle.git",
&["git@github.com:cyrusae/entangle.git"],
);
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.expect("must succeed when only one push URL is configured");
}
#[test]
fn run_configures_origin_remote_in_fresh_repo() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
let status = git::get_origin_status(&work_dir).unwrap();
match status {
git::OriginStatus::Present {
fetch_url,
push_urls,
} => {
assert_eq!(fetch_url, "git@github.com:cyrusae/entangle.git");
assert_eq!(
push_urls.len(),
2,
"must configure both push URLs: {push_urls:?}"
);
assert_eq!(
push_urls[0], "git@tangled.org:atdot.fyi/entangle",
"Tangled (mirror) must be the first push URL"
);
assert_eq!(
push_urls[1], "git@github.com:cyrusae/entangle.git",
"GitHub (origin) must be the second (last) push URL"
);
}
git::OriginStatus::Absent => panic!("expected Present after init, got Absent"),
}
}
#[test]
fn run_adds_both_push_urls_when_origin_has_matching_url_but_none() {
let (_dir, work_dir) = fresh_work_dir();
gix::init(&work_dir).unwrap();
{
use std::io::Write as _;
let cfg = work_dir.join(".git").join("config");
let mut f = std::fs::OpenOptions::new().append(true).open(cfg).unwrap();
writeln!(f, "\n[remote \"origin\"]").unwrap();
writeln!(f, "\turl = git@github.com:cyrusae/entangle.git").unwrap();
writeln!(f, "\tfetch = +refs/heads/*:refs/remotes/origin/*").unwrap();
}
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
let status = git::get_origin_status(&work_dir).unwrap();
match status {
git::OriginStatus::Present {
fetch_url,
push_urls,
} => {
assert_eq!(fetch_url, "git@github.com:cyrusae/entangle.git");
assert_eq!(
push_urls.len(),
2,
"both push URLs must be added: {push_urls:?}"
);
assert_eq!(
push_urls[0], "git@tangled.org:atdot.fyi/entangle",
"Tangled (mirror) must be the first push URL"
);
assert_eq!(
push_urls[1], "git@github.com:cyrusae/entangle.git",
"GitHub (origin) must be the second (last) push URL"
);
}
git::OriginStatus::Absent => panic!("expected Present"),
}
}
#[test]
fn run_is_fully_idempotent_after_step_10() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
let status = git::get_origin_status(&work_dir).unwrap();
match status {
git::OriginStatus::Present { push_urls, .. } => {
let origin_count = push_urls
.iter()
.filter(|u| u.as_str() == "git@github.com:cyrusae/entangle.git")
.count();
let mirror_count = push_urls
.iter()
.filter(|u| u.as_str() == "git@tangled.org:atdot.fyi/entangle")
.count();
assert_eq!(origin_count, 1, "origin push URL must not be duplicated");
assert_eq!(mirror_count, 1, "mirror push URL must not be duplicated");
}
git::OriginStatus::Absent => panic!("expected Present"),
}
}
#[test]
fn run_with_alias_uses_alias_for_tangled_push_url() {
let (_dir, work_dir) = fresh_work_dir();
run_with_paths(
Some("my-repo".to_string()),
Some("mirror-alias".to_string()),
test_config(),
&work_dir,
false,
false,
skip_validate,
)
.unwrap();
let status = git::get_origin_status(&work_dir).unwrap();
match status {
git::OriginStatus::Present { push_urls, .. } => {
assert!(
push_urls.iter().any(|u| u.contains("mirror-alias")),
"Tangled push URL must use the alias: {push_urls:?}"
);
assert!(
push_urls.iter().any(|u| u.contains("my-repo")),
"GitHub push URL must use the primary repo name: {push_urls:?}"
);
}
git::OriginStatus::Absent => panic!("expected Present"),
}
}
#[test]
fn corrupt_git_config_does_not_panic() {
let (_dir, work_dir) = fresh_work_dir();
gix::init(&work_dir).unwrap();
let config_path = work_dir.join(".git").join("config");
std::fs::write(
&config_path,
"[core\n\trepositoryformatversion = 0\nfilemode = true\n",
)
.unwrap();
let result = run_with_paths(
Some("entangle".to_string()),
None,
test_config(),
&work_dir,
false,
false,
skip_validate,
);
drop(result);
}
}