From 0e9c810a41ec0122be9aa11296a11b49a1b4b114 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 8 Feb 2026 02:34:48 +0000 Subject: [PATCH 1/2] feat: implement config system with set/get/show/unset and alias expansion - config set/get/show/unset commands with dotted key paths (user.name, default.bookmark, aliases.c, etc.) - local config overrides global config per rule 21 - alias expansion at CLI level before dispatch per rule 23 - InvalidConfigKey error variant - 28 tests covering all config operations, global/local resolution, and alias expansion --- src/cli.rs | 95 +++++++- src/config.rs | 163 ++++++++++++++ src/error.rs | 2 + tests/config.rs | 579 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 811 insertions(+), 28 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 65338f3..16ccbdb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,6 +3,7 @@ use std::path::Path; use clap::{Parser, Subcommand}; +use crate::config; use crate::diff; use crate::ignore::IgnoreRules; use crate::inspect; @@ -310,8 +311,43 @@ pub enum RemoteCommand { List, } +pub fn expand_aliases(args: Vec) -> Vec { + if args.len() < 2 { + return args; + } + + let subcmd = &args[1]; + + let aliases = load_aliases(); + if aliases.is_empty() { + return args; + } + + if let Some(expansion) = aliases.get(subcmd) { + let mut result = vec![args[0].clone()]; + result.push(expansion.clone()); + result.extend_from_slice(&args[2..]); + result + } else { + args + } +} + +fn load_aliases() -> std::collections::HashMap { + let repo = Repository::discover(Path::new(".")).ok(); + let local = repo + .as_ref() + .and_then(|r| crate::config::Config::load_local(r).ok()) + .flatten(); + let global = crate::config::Config::load_global().ok().flatten(); + let eff = crate::config::Config::effective(local, global); + eff.aliases +} + pub fn parse() -> Cli { - Cli::parse() + let args: Vec = std::env::args().collect(); + let expanded = expand_aliases(args); + Cli::parse_from(expanded) } pub fn dispatch(cli: Cli) { @@ -592,20 +628,61 @@ pub fn dispatch(cli: Cli) { } Command::Config { command } => match command { ConfigCommand::Set { global, key, value } => { - let scope = if global { "global" } else { "local" }; - println!("arc config set ({scope}): {key} = {value} (not yet implemented)"); + let repo = if global { + None + } else { + Some(open_repo_or_exit()) + }; + match config::config_set(repo.as_ref(), global, &key, &value) { + Ok(()) => println!("{key} = {value}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } ConfigCommand::Get { global, key } => { - let scope = if global { "global" } else { "local" }; - println!("arc config get ({scope}): {key} (not yet implemented)"); + let repo = if global { + None + } else { + Repository::discover(Path::new(".")).ok() + }; + match config::config_get(repo.as_ref(), global, &key) { + Ok(Some(value)) => println!("{value}"), + Ok(None) => println!("{key} is not set"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } ConfigCommand::Show { global } => { - let scope = if global { "global" } else { "local" }; - println!("arc config show ({scope}) (not yet implemented)"); + let repo = if global { + None + } else { + Repository::discover(Path::new(".")).ok() + }; + match config::config_show(repo.as_ref(), global) { + Ok(yaml) => print!("{yaml}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } ConfigCommand::Unset { global, key } => { - let scope = if global { "global" } else { "local" }; - println!("arc config unset ({scope}): {key} (not yet implemented)"); + let repo = if global { + None + } else { + Some(open_repo_or_exit()) + }; + match config::config_unset(repo.as_ref(), global, &key) { + Ok(()) => println!("unset {key}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } }, Command::Sync { push } => { diff --git a/src/config.rs b/src/config.rs index 006d427..ac4915f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,7 @@ pub struct DefaultConfig { pub remote: Option, } +#[derive(Serialize)] pub struct EffectiveConfig { pub user_name: Option, pub user_email: Option, @@ -134,6 +135,168 @@ pub fn load_effective(repo: &crate::repo::Repository) -> EffectiveConfig { Config::effective(local, global) } +fn parse_key(key: &str) -> Result<(&str, &str)> { + let (section, field) = key + .split_once('.') + .ok_or_else(|| ArcError::InvalidConfigKey(key.to_string()))?; + Ok((section, field)) +} + +fn get_field(config: &Config, section: &str, field: &str) -> Result> { + match section { + "user" => { + let user = config.user.as_ref(); + match field { + "name" => Ok(user.and_then(|u| u.name.clone())), + "email" => Ok(user.and_then(|u| u.email.clone())), + "key" => Ok(user.and_then(|u| u.key.clone())), + _ => Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + } + "default" => { + let defaults = config.defaults.as_ref(); + match field { + "bookmark" => Ok(defaults.and_then(|d| d.bookmark.clone())), + "remote" => Ok(defaults.and_then(|d| d.remote.clone())), + _ => Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + } + "aliases" => { + let aliases = config.aliases.as_ref(); + Ok(aliases.and_then(|a| a.get(field).cloned())) + } + _ => Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } +} + +fn set_field(config: &mut Config, section: &str, field: &str, value: &str) -> Result<()> { + match section { + "user" => { + let user = config.user.get_or_insert_with(UserConfig::default); + match field { + "name" => user.name = Some(value.to_string()), + "email" => user.email = Some(value.to_string()), + "key" => user.key = Some(value.to_string()), + _ => return Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + } + "default" => { + let defaults = config.defaults.get_or_insert_with(DefaultConfig::default); + match field { + "bookmark" => defaults.bookmark = Some(value.to_string()), + "remote" => defaults.remote = Some(value.to_string()), + _ => return Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + } + "aliases" => { + let aliases = config.aliases.get_or_insert_with(HashMap::new); + aliases.insert(field.to_string(), value.to_string()); + } + _ => return Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + Ok(()) +} + +fn unset_field(config: &mut Config, section: &str, field: &str) -> Result<()> { + match section { + "user" => { + if let Some(user) = config.user.as_mut() { + match field { + "name" => user.name = None, + "email" => user.email = None, + "key" => user.key = None, + _ => return Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + } + } + "default" => { + if let Some(defaults) = config.defaults.as_mut() { + match field { + "bookmark" => defaults.bookmark = None, + "remote" => defaults.remote = None, + _ => return Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + } + } + "aliases" => { + if let Some(aliases) = config.aliases.as_mut() { + aliases.remove(field); + } + } + _ => return Err(ArcError::InvalidConfigKey(format!("{section}.{field}"))), + } + Ok(()) +} + +/// Set a configuration value in the local or global config file. +pub fn config_set(repo: Option<&Repository>, global: bool, key: &str, value: &str) -> Result<()> { + let (section, field) = parse_key(key)?; + if global { + let path = Config::global_config_path(); + let mut config = Config::load_global()?.unwrap_or_default(); + set_field(&mut config, section, field, value)?; + config.save_to(&path) + } else { + let repo = repo.ok_or(ArcError::RepoNotFound)?; + let path = repo.local_config_path(); + let mut config = Config::load_local(repo)?.unwrap_or_default(); + set_field(&mut config, section, field, value)?; + config.save_to(&path) + } +} + +/// Get a configuration value, resolving local-first then global. +pub fn config_get(repo: Option<&Repository>, global: bool, key: &str) -> Result> { + let (section, field) = parse_key(key)?; + if global { + let config = Config::load_global()?.unwrap_or_default(); + get_field(&config, section, field) + } else { + if let Some(repo) = repo + && let Some(local) = Config::load_local(repo)? + { + let val = get_field(&local, section, field)?; + if val.is_some() { + return Ok(val); + } + } + let global_config = Config::load_global()?.unwrap_or_default(); + get_field(&global_config, section, field) + } +} + +/// Show the full configuration as YAML. +pub fn config_show(repo: Option<&Repository>, global: bool) -> Result { + if global { + let config = Config::load_global()?.unwrap_or_default(); + let yaml = serde_yaml::to_string(&config)?; + Ok(yaml) + } else { + let local = repo.and_then(|r| Config::load_local(r).ok()).flatten(); + let global_config = Config::load_global().ok().flatten(); + let effective = Config::effective(local, global_config); + let yaml = serde_yaml::to_string(&effective)?; + Ok(yaml) + } +} + +/// Remove a configuration key from the local or global config file. +pub fn config_unset(repo: Option<&Repository>, global: bool, key: &str) -> Result<()> { + let (section, field) = parse_key(key)?; + if global { + let path = Config::global_config_path(); + let mut config = Config::load_global()?.unwrap_or_default(); + unset_field(&mut config, section, field)?; + config.save_to(&path) + } else { + let repo = repo.ok_or(ArcError::RepoNotFound)?; + let path = repo.local_config_path(); + let mut config = Config::load_local(repo)?.unwrap_or_default(); + unset_field(&mut config, section, field)?; + config.save_to(&path) + } +} + impl ArcError { pub fn invalid_path(msg: impl Into) -> Self { Self::InvalidPath(msg.into()) diff --git a/src/error.rs b/src/error.rs index 5ac4dd1..e2c065b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,7 @@ pub enum ArcError { NothingToStash, StashEmpty(String), StashBaseMismatch, + InvalidConfigKey(String), } impl fmt::Display for ArcError { @@ -81,6 +82,7 @@ impl fmt::Display for ArcError { Self::StashBaseMismatch => { write!(f, "stash base does not match current HEAD") } + Self::InvalidConfigKey(key) => write!(f, "invalid config key: {key}"), } } } diff --git a/tests/config.rs b/tests/config.rs index dcbcc12..f5e4f52 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -5,14 +5,18 @@ fn arc_cmd() -> Command { Command::new(env!("CARGO_BIN_EXE_arc")) } -#[test] -fn config_yml_is_valid_yaml() { - let dir = TempDir::new().unwrap(); +fn init_repo(dir: &TempDir) { arc_cmd() .arg("init") .current_dir(dir.path()) .output() - .expect("failed to run arc"); + .expect("failed to run arc init"); +} + +#[test] +fn config_yml_is_valid_yaml() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); let config_path = dir.path().join(".arc").join("config.yml"); let contents = std::fs::read_to_string(&config_path).unwrap(); @@ -23,11 +27,7 @@ fn config_yml_is_valid_yaml() { #[test] fn config_has_default_section() { let dir = TempDir::new().unwrap(); - arc_cmd() - .arg("init") - .current_dir(dir.path()) - .output() - .expect("failed to run arc"); + init_repo(&dir); let config_path = dir.path().join(".arc").join("config.yml"); let contents = std::fs::read_to_string(&config_path).unwrap(); @@ -41,11 +41,7 @@ fn config_has_default_section() { #[test] fn head_is_valid_yaml() { let dir = TempDir::new().unwrap(); - arc_cmd() - .arg("init") - .current_dir(dir.path()) - .output() - .expect("failed to run arc"); + init_repo(&dir); let head_path = dir.path().join(".arc").join("HEAD"); let contents = std::fs::read_to_string(&head_path).unwrap(); @@ -58,14 +54,559 @@ fn head_is_valid_yaml() { #[test] fn bookmark_ref_is_valid_yaml() { let dir = TempDir::new().unwrap(); - arc_cmd() - .arg("init") - .current_dir(dir.path()) - .output() - .expect("failed to run arc"); + init_repo(&dir); let bookmark_path = dir.path().join(".arc").join("bookmarks").join("main"); let contents = std::fs::read_to_string(&bookmark_path).unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap(); assert!(value["commit"].is_null()); } + +#[test] +fn config_set_and_get_user_name() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + let out = arc_cmd() + .args(["config", "set", "user.name", "alice"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("user.name = alice")); + + let out = arc_cmd() + .args(["config", "get", "user.name"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout.trim(), "alice"); +} + +#[test] +fn config_set_and_get_user_email() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "user.email", "alice@example.com"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "user.email"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "alice@example.com" + ); +} + +#[test] +fn config_set_and_get_user_key() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "user.key", "~/.ssh/id_ed25519"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "user.key"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "~/.ssh/id_ed25519" + ); +} + +#[test] +fn config_set_and_get_default_bookmark() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "default.bookmark", "develop"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "default.bookmark"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "develop"); +} + +#[test] +fn config_set_and_get_default_remote() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "default.remote", "upstream"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "default.remote"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "upstream"); +} + +#[test] +fn config_set_and_get_alias() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "aliases.c", "commit"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "aliases.c"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "commit"); +} + +#[test] +fn config_get_unset_key_returns_not_set() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + let out = arc_cmd() + .args(["config", "get", "user.name"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("is not set")); +} + +#[test] +fn config_unset_key() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "user.name", "alice"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "unset", "user.name"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("unset user.name")); + + let out = arc_cmd() + .args(["config", "get", "user.name"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&out.stdout).contains("is not set")); +} + +#[test] +fn config_unset_alias() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "aliases.c", "commit"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["config", "unset", "aliases.c"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "aliases.c"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&out.stdout).contains("is not set")); +} + +#[test] +fn config_show_displays_yaml() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "user.name", "bob"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "show"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("bob")); +} + +#[test] +fn config_invalid_key_fails() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + let out = arc_cmd() + .args(["config", "set", "invalid", "value"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("invalid config key")); +} + +#[test] +fn config_invalid_section_fails() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + let out = arc_cmd() + .args(["config", "set", "bogus.field", "value"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("invalid config key")); +} + +#[test] +fn config_invalid_field_fails() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + let out = arc_cmd() + .args(["config", "set", "user.bogus", "value"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("invalid config key")); +} + +#[test] +fn config_set_overwrites_existing() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "user.name", "alice"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["config", "set", "user.name", "bob"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "user.name"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "bob"); +} + +#[test] +fn config_global_set_and_get() { + let dir = TempDir::new().unwrap(); + let global_dir = TempDir::new().unwrap(); + + init_repo(&dir); + + let out = arc_cmd() + .args(["config", "set", "-g", "user.name", "global-user"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + + let out = arc_cmd() + .args(["config", "get", "-g", "user.name"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "global-user"); +} + +#[test] +fn config_local_overrides_global() { + let dir = TempDir::new().unwrap(); + let global_dir = TempDir::new().unwrap(); + + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "-g", "user.name", "global-user"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["config", "set", "user.name", "local-user"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "user.name"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "local-user"); +} + +#[test] +fn config_get_falls_back_to_global() { + let dir = TempDir::new().unwrap(); + let global_dir = TempDir::new().unwrap(); + + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "-g", "user.email", "global@example.com"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "user.email"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "global@example.com" + ); +} + +#[test] +fn config_global_unset() { + let dir = TempDir::new().unwrap(); + let global_dir = TempDir::new().unwrap(); + + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "-g", "user.name", "global-user"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["config", "unset", "-g", "user.name"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "-g", "user.name"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&out.stdout).contains("is not set")); +} + +#[test] +fn config_global_show() { + let dir = TempDir::new().unwrap(); + let global_dir = TempDir::new().unwrap(); + + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "-g", "user.name", "global-user"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "show", "-g"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("global-user")); +} + +#[test] +fn config_show_effective_merges_local_and_global() { + let dir = TempDir::new().unwrap(); + let global_dir = TempDir::new().unwrap(); + + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "-g", "user.name", "global-user"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["config", "set", "user.email", "local@example.com"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "show"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", global_dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("global-user")); + assert!(stdout.contains("local@example.com")); +} + +#[test] +fn config_persisted_to_yaml_file() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "user.name", "alice"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let config_path = dir.path().join(".arc").join("config.yml"); + let contents = std::fs::read_to_string(&config_path).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap(); + assert_eq!(value["user"]["name"].as_str(), Some("alice")); +} + +#[test] +fn alias_expansion_via_config() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "aliases.s", "status"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd().arg("s").current_dir(dir.path()).output().unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("clean") || stdout.contains("no changes") || stdout.is_empty(), + "unexpected status output: {stdout}" + ); +} + +#[test] +fn alias_expansion_passes_arguments() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap(); + + arc_cmd() + .args(["config", "set", "aliases.c", "commit"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["c", "test commit via alias"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("committed")); +} + +#[test] +fn config_multiple_aliases() { + let dir = TempDir::new().unwrap(); + init_repo(&dir); + + arc_cmd() + .args(["config", "set", "aliases.c", "commit"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["config", "set", "aliases.s", "status"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let out = arc_cmd() + .args(["config", "get", "aliases.c"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "commit"); + + let out = arc_cmd() + .args(["config", "get", "aliases.s"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "status"); +} From d7694737de323c22ae83ea6c51a887e5bbabf870 Mon Sep 17 00:00:00 2001 From: hanna Date: Sun, 8 Feb 2026 02:53:12 +0000 Subject: [PATCH 2/2] feat: implement phase 9 - git bridge and remotes - Add git2 dependency with vendored libgit2/openssl for nix compatibility - Add src/remote.rs: remote add/rm/list with YAML persistence (.arc/remotes.yml) - Add src/bridge.rs: git bridge with shadow repo (.arc/git/) and commit mapping - arc-to-git conversion: materialize trees, create git commits via TreeBuilder - git-to-arc conversion: walk git trees, compute deltas, create arc commits - Bidirectional mapping cache in .arc/git-map.yml - Implement push: convert arc commits to git, push bookmarks as branches and tags as git tags (rules 19/20) via git2 with SSH agent support - Implement pull: fetch from remote, import new commits, fast-forward bookmarks - Implement clone: git2 clone into shadow repo, import all history, set up worktree at specified branch with origin remote auto-configured - Implement migrate: convert existing git repo to arc, preserving all branches as bookmarks, tags, commit history, and git remotes - Implement sync: ensure shadow git mirrors arc refs, optionally push to remote - Wire all CLI stubs (push/pull/clone/migrate/sync/remote) to implementations - Add new error variants: Git, RemoteNotFound, RemoteAlreadyExists, etc. - Exclude .git/ from worktree scanning alongside .arc/ for coexistence - Update flake.nix with cmake/perl/git build inputs for vendored compilation - Add 23 new integration tests (16 bridge + 7 remote) covering all commands --- Cargo.lock | 373 +++++++++++++++++++++ Cargo.toml | 1 + flake.nix | 5 + src/bridge.rs | 870 ++++++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 95 ++++-- src/error.rs | 18 + src/ignore.rs | 6 +- src/main.rs | 2 + src/remote.rs | 87 +++++ src/tracking.rs | 2 +- tests/bridge.rs | 575 ++++++++++++++++++++++++++++++++ tests/cli.rs | 7 + tests/remote.rs | 151 +++++++++ 13 files changed, 2170 insertions(+), 22 deletions(-) create mode 100644 src/bridge.rs create mode 100644 src/remote.rs create mode 100644 tests/bridge.rs create mode 100644 tests/remote.rs diff --git a/Cargo.lock b/Cargo.lock index dc5c8bf..caf8ead 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,7 @@ name = "arc" version = "0.1.0" dependencies = [ "clap", + "git2", "hex", "rmp-serde", "serde", @@ -180,6 +181,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -208,6 +220,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -230,6 +251,21 @@ dependencies = [ "wasip2", ] +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -248,6 +284,108 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -286,12 +424,64 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libgit2-sys" +version = "0.17.0+1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "num-traits" version = "0.2.19" @@ -313,12 +503,55 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -441,6 +674,18 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -458,6 +703,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -471,6 +727,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.19.0" @@ -489,12 +755,36 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -531,6 +821,89 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index ed6560a..728b22b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ rmp-serde = "1" zstd = "0.13" sha2 = "0.10" hex = "0.4" +git2 = { version = "0.19", features = ["vendored-libgit2", "vendored-openssl"] } [dev-dependencies] tempfile = "3" diff --git a/flake.nix b/flake.nix index f07ac4f..7b66fe1 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,8 @@ version = "0.1.0"; inherit src; strictDeps = true; + nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.perl ]; + nativeCheckInputs = [ pkgs.git ]; }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; @@ -83,6 +85,9 @@ packages = [ rustToolchain pkgs.git + pkgs.pkg-config + pkgs.cmake + pkgs.perl ]; RUST_BACKTRACE = "1"; diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..8551d61 --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,870 @@ +//! Git bridge for converting between arc commits and git commits. +//! +//! Maintains a shadow git repository under `.arc/git/` and a mapping +//! cache in `.arc/git-map.yml` to translate between arc `CommitId` +//! and git `Oid` values. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::error::{ArcError, Result}; +use crate::model::{Commit, CommitId, Delta, Head, RefTarget, Signature}; +use crate::remote; +use crate::repo::Repository; +use crate::store::{self, CommitObject}; +use crate::tracking; + +/// Bidirectional mapping between arc commit IDs and git OIDs. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct GitMap { + /// Arc commit ID → git OID hex string. + pub arc_to_git: BTreeMap, + /// Git OID hex string → arc commit ID. + pub git_to_arc: BTreeMap, +} + +/// State for a git bridge session, holding the shadow git repo and mapping. +pub struct GitBridge { + /// The shadow git2 repository under `.arc/git/`. + pub git_repo: git2::Repository, + /// The mapping between arc and git commit IDs. + pub map: GitMap, + map_path: PathBuf, +} + +fn shadow_git_dir(repo: &Repository) -> PathBuf { + repo.arc_dir.join("git") +} + +fn git_map_path(repo: &Repository) -> PathBuf { + repo.arc_dir.join("git-map.yml") +} + +/// Load the git map from disk, returning an empty map if the file does not exist. +fn load_git_map(path: &std::path::Path) -> Result { + if !path.exists() { + return Ok(GitMap::default()); + } + let contents = fs::read_to_string(path)?; + let map: GitMap = serde_yaml::from_str(&contents)?; + Ok(map) +} + +/// Save the git map to disk. +fn save_git_map(path: &std::path::Path, map: &GitMap) -> Result<()> { + let yaml = serde_yaml::to_string(map)?; + fs::write(path, yaml)?; + Ok(()) +} + +impl GitBridge { + /// Open (or initialise) the shadow git repository and load the mapping cache. + pub fn open(repo: &Repository) -> Result { + let git_dir = shadow_git_dir(repo); + let git_repo = if git_dir.exists() { + git2::Repository::open_bare(&git_dir)? + } else { + git2::Repository::init_bare(&git_dir)? + }; + let map_path = git_map_path(repo); + let map = load_git_map(&map_path)?; + Ok(Self { + git_repo, + map, + map_path, + }) + } + + /// Persist the mapping cache to disk. + pub fn save_map(&self) -> Result<()> { + save_git_map(&self.map_path, &self.map) + } + + /// Convert an arc commit to a git commit, recursively converting parents. + /// + /// Returns the git OID for the newly created (or cached) git commit. + pub fn arc_to_git(&mut self, arc_repo: &Repository, arc_id: &CommitId) -> Result { + if let Some(hex) = self.map.arc_to_git.get(&arc_id.0) { + let oid = git2::Oid::from_str(hex)?; + return Ok(oid); + } + + let obj = store::read_commit_object(arc_repo, arc_id)?; + let c = &obj.commit; + + let mut parent_oids = Vec::new(); + for parent in &c.parents { + let oid = self.arc_to_git(arc_repo, parent)?; + parent_oids.push(oid); + } + + let tree = tracking::materialize_committed_tree(arc_repo, arc_id)?; + let git_tree_oid = self.write_file_tree_to_git(&tree)?; + let git_tree = self.git_repo.find_tree(git_tree_oid)?; + + let sig = self.make_signature(c); + let parent_commits: Vec> = parent_oids + .iter() + .map(|oid| self.git_repo.find_commit(*oid)) + .collect::, _>>()?; + let parent_refs: Vec<&git2::Commit<'_>> = parent_commits.iter().collect(); + + let oid = self + .git_repo + .commit(None, &sig, &sig, &c.message, &git_tree, &parent_refs)?; + + self.map + .arc_to_git + .insert(arc_id.0.clone(), oid.to_string()); + self.map + .git_to_arc + .insert(oid.to_string(), arc_id.0.clone()); + + Ok(oid) + } + + /// Convert a git commit to an arc commit, recursively converting parents. + /// + /// Returns the arc `CommitId` for the newly created (or cached) commit. + pub fn git_to_arc(&mut self, arc_repo: &Repository, git_oid: git2::Oid) -> Result { + let oid_hex = git_oid.to_string(); + if let Some(arc_id) = self.map.git_to_arc.get(&oid_hex) { + return Ok(CommitId(arc_id.clone())); + } + + let (parent_oids, message, author, timestamp, tree_data) = { + let git_commit = self.git_repo.find_commit(git_oid)?; + let poids: Vec = (0..git_commit.parent_count()) + .map(|i| git_commit.parent_id(i)) + .collect::, _>>()?; + let msg = git_commit.message().unwrap_or("").to_string(); + let ga = git_commit.author(); + let auth = Some(Signature { + name: ga.name().unwrap_or("unknown").to_string(), + email: ga.email().unwrap_or("unknown").to_string(), + }); + let ts = git_commit.time().seconds(); + let tree = git_commit.tree()?; + let ft = self.git_tree_to_file_tree(&tree)?; + (poids, msg, auth, ts, ft) + }; + + let mut arc_parents = Vec::new(); + for parent_oid in &parent_oids { + let parent_arc_id = self.git_to_arc(arc_repo, *parent_oid)?; + arc_parents.push(parent_arc_id); + } + + let parent_tree = if arc_parents.is_empty() { + BTreeMap::new() + } else { + tracking::materialize_committed_tree(arc_repo, &arc_parents[0])? + }; + + let changes = tracking::detect_changes(&parent_tree, &tree_data); + + let base = arc_parents.first().cloned(); + let delta_id = store::compute_delta_id(&base, &changes)?; + let delta = Delta { + id: delta_id.clone(), + base: base.clone(), + changes, + }; + + let commit_id = + store::compute_commit_id(&arc_parents, &delta_id, &message, &author, timestamp)?; + + let commit_obj = Commit { + id: commit_id.clone(), + parents: arc_parents, + delta: delta_id, + message, + author, + timestamp, + }; + + let obj = CommitObject { + commit: commit_obj, + delta, + }; + store::write_commit_object(arc_repo, &obj)?; + + self.map + .arc_to_git + .insert(commit_id.0.clone(), oid_hex.clone()); + self.map.git_to_arc.insert(oid_hex, commit_id.0.clone()); + + Ok(commit_id) + } + + /// Write a `FileTree` as git blobs and trees, returning the root tree OID. + fn write_file_tree_to_git(&self, tree: &BTreeMap>) -> Result { + let mut dirs: BTreeMap)>> = BTreeMap::new(); + let mut root_files: Vec<(String, Vec)> = Vec::new(); + + for (path, bytes) in tree { + if let Some(slash_pos) = path.find('/') { + let dir = path[..slash_pos].to_string(); + let rest = path[slash_pos + 1..].to_string(); + dirs.entry(dir).or_default().push((rest, bytes.clone())); + } else { + root_files.push((path.clone(), bytes.clone())); + } + } + + self.build_tree_recursive(&root_files, &dirs) + } + + /// Recursively build git tree objects from files and subdirectories. + fn build_tree_recursive( + &self, + files: &[(String, Vec)], + subdirs: &BTreeMap)>>, + ) -> Result { + let mut builder = self.git_repo.treebuilder(None)?; + + for (name, bytes) in files { + let blob_oid = self.git_repo.blob(bytes)?; + builder.insert(name, blob_oid, 0o100644)?; + } + + for (dir_name, entries) in subdirs { + let mut child_files = Vec::new(); + let mut child_dirs: BTreeMap)>> = BTreeMap::new(); + + for (path, bytes) in entries { + if let Some(slash_pos) = path.find('/') { + let sub = path[..slash_pos].to_string(); + let rest = path[slash_pos + 1..].to_string(); + child_dirs + .entry(sub) + .or_default() + .push((rest, bytes.clone())); + } else { + child_files.push((path.clone(), bytes.clone())); + } + } + + let child_oid = self.build_tree_recursive(&child_files, &child_dirs)?; + builder.insert(dir_name, child_oid, 0o040000)?; + } + + let oid = builder.write()?; + Ok(oid) + } + + /// Convert a git tree object into a flat `FileTree` map. + fn git_tree_to_file_tree(&self, tree: &git2::Tree<'_>) -> Result>> { + let mut file_tree = BTreeMap::new(); + self.walk_git_tree(tree, "", &mut file_tree)?; + Ok(file_tree) + } + + /// Recursively walk a git tree, collecting blob entries into the file tree. + fn walk_git_tree( + &self, + tree: &git2::Tree<'_>, + prefix: &str, + file_tree: &mut BTreeMap>, + ) -> Result<()> { + for entry in tree.iter() { + let name = entry.name().unwrap_or(""); + let path = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + + match entry.kind() { + Some(git2::ObjectType::Blob) => { + let blob = self.git_repo.find_blob(entry.id())?; + file_tree.insert(path, blob.content().to_vec()); + } + Some(git2::ObjectType::Tree) => { + let subtree = self.git_repo.find_tree(entry.id())?; + self.walk_git_tree(&subtree, &path, file_tree)?; + } + _ => {} + } + } + Ok(()) + } + + /// Build a `git2::Signature` from an arc commit. + fn make_signature(&self, commit: &Commit) -> git2::Signature<'static> { + let (name, email) = match &commit.author { + Some(sig) => (sig.name.clone(), sig.email.clone()), + None => ("arc".to_string(), "arc@local".to_string()), + }; + let time = git2::Time::new(commit.timestamp, 0); + git2::Signature::new(&name, &email, &time) + .unwrap_or_else(|_| git2::Signature::now("arc", "arc@local").expect("valid signature")) + } +} + +/// Push arc bookmarks and tags to a git remote. +/// +/// Converts all reachable commits, updates shadow refs, and pushes. +pub fn push(arc_repo: &Repository, remote_name: &str) -> Result { + let remotes = remote::load(arc_repo)?; + let entry = remotes + .remotes + .get(remote_name) + .ok_or_else(|| ArcError::RemoteNotFound(remote_name.to_string()))?; + let url = &entry.url; + + let mut bridge = GitBridge::open(arc_repo)?; + + let mut ref_specs = Vec::new(); + + let bookmarks_dir = arc_repo.bookmarks_dir(); + if bookmarks_dir.is_dir() { + let mut names: Vec = Vec::new(); + for entry in fs::read_dir(&bookmarks_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + names.push(entry.file_name().to_string_lossy().to_string()); + } + } + names.sort(); + + for name in &names { + let contents = fs::read_to_string(bookmarks_dir.join(name))?; + let ref_target: RefTarget = serde_yaml::from_str(&contents)?; + if let Some(arc_id) = &ref_target.commit { + let git_oid = bridge.arc_to_git(arc_repo, arc_id)?; + let refname = format!("refs/heads/{name}"); + bridge + .git_repo + .reference(&refname, git_oid, true, "arc push")?; + ref_specs.push(format!("{refname}:{refname}")); + } + } + } + + let tags_dir = arc_repo.tags_dir(); + if tags_dir.is_dir() { + let mut names: Vec = Vec::new(); + for entry in fs::read_dir(&tags_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + names.push(entry.file_name().to_string_lossy().to_string()); + } + } + names.sort(); + + for name in &names { + let contents = fs::read_to_string(tags_dir.join(name))?; + let ref_target: RefTarget = serde_yaml::from_str(&contents)?; + if let Some(arc_id) = &ref_target.commit { + let git_oid = bridge.arc_to_git(arc_repo, arc_id)?; + let refname = format!("refs/tags/{name}"); + bridge + .git_repo + .reference(&refname, git_oid, true, "arc push")?; + ref_specs.push(format!("{refname}:{refname}")); + } + } + } + + if ref_specs.is_empty() { + bridge.save_map()?; + return Ok("nothing to push".to_string()); + } + + let spec_strs: Vec<&str> = ref_specs.iter().map(|s| s.as_str()).collect(); + + { + let mut git_remote = bridge.git_repo.remote_anonymous(url)?; + let mut opts = git2::PushOptions::new(); + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_url, username, allowed| { + if allowed.contains(git2::CredentialType::SSH_KEY) { + let user = username.unwrap_or("git"); + git2::Cred::ssh_key_from_agent(user) + } else if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + git2::Cred::userpass_plaintext("", "") + } else { + git2::Cred::default() + } + }); + opts.remote_callbacks(callbacks); + git_remote.push(&spec_strs, Some(&mut opts))?; + } + + bridge.save_map()?; + + let count = ref_specs.len(); + Ok(format!("pushed {count} ref(s) to {remote_name}")) +} + +/// Pull commits from a git remote and import them as arc commits. +/// +/// Fetches, then imports reachable commits for each remote branch +/// and updates local bookmarks that can be fast-forwarded. +pub fn pull(arc_repo: &Repository, remote_name: &str) -> Result { + let remotes = remote::load(arc_repo)?; + let entry = remotes + .remotes + .get(remote_name) + .ok_or_else(|| ArcError::RemoteNotFound(remote_name.to_string()))?; + let url = &entry.url; + + let mut bridge = GitBridge::open(arc_repo)?; + + { + let mut git_remote = bridge.git_repo.remote_anonymous(url)?; + let mut opts = git2::FetchOptions::new(); + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_url, username, allowed| { + if allowed.contains(git2::CredentialType::SSH_KEY) { + let user = username.unwrap_or("git"); + git2::Cred::ssh_key_from_agent(user) + } else if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + git2::Cred::userpass_plaintext("", "") + } else { + git2::Cred::default() + } + }); + opts.remote_callbacks(callbacks); + git_remote.fetch::<&str>(&[], Some(&mut opts), None)?; + + let _default_branch = git_remote + .default_branch() + .ok() + .and_then(|b| b.as_str().map(String::from)); + + let refs: Vec<(String, git2::Oid)> = git_remote + .list()? + .iter() + .filter_map(|head| { + let name = head.name(); + if name.starts_with("refs/heads/") || name.starts_with("refs/tags/") { + Some((name.to_string(), head.oid())) + } else { + None + } + }) + .collect(); + + for (refname, oid) in &refs { + bridge + .git_repo + .reference(refname, *oid, true, "arc pull fetch")?; + } + } + + let mut imported = 0usize; + let mut updated_bookmarks = Vec::new(); + let mut updated_tags = Vec::new(); + + let refs: Vec<(String, git2::Oid)> = { + let mut result = Vec::new(); + let references = bridge.git_repo.references()?; + for reference in references.flatten() { + if let Some(name) = reference.name() + && (name.starts_with("refs/heads/") || name.starts_with("refs/tags/")) + && let Some(oid) = reference.target() + { + result.push((name.to_string(), oid)); + } + } + result + }; + + for (_refname, oid) in &refs { + if bridge.map.git_to_arc.contains_key(&oid.to_string()) { + continue; + } + bridge.git_to_arc(arc_repo, *oid)?; + imported += 1; + } + + for (refname, oid) in &refs { + let oid_hex = oid.to_string(); + let arc_id = match bridge.map.git_to_arc.get(&oid_hex) { + Some(id) => CommitId(id.clone()), + None => continue, + }; + + if let Some(branch_name) = refname.strip_prefix("refs/heads/") { + let bookmark_path = arc_repo.bookmarks_dir().join(branch_name); + let should_update = if bookmark_path.exists() { + let contents = fs::read_to_string(&bookmark_path)?; + let ref_target: RefTarget = serde_yaml::from_str(&contents)?; + match &ref_target.commit { + Some(local_id) if *local_id == arc_id => false, + Some(local_id) => is_ancestor(arc_repo, local_id, &arc_id), + None => true, + } + } else { + true + }; + + if should_update { + let ref_target = RefTarget { + commit: Some(arc_id.clone()), + }; + let yaml = serde_yaml::to_string(&ref_target)?; + fs::write(&bookmark_path, yaml)?; + updated_bookmarks.push(branch_name.to_string()); + + let head = arc_repo.load_head()?; + if let Head::Attached { bookmark, .. } = &head + && bookmark == branch_name + { + arc_repo.save_head(&Head::Attached { + bookmark: bookmark.clone(), + commit: arc_id, + })?; + } + } + } else if let Some(tag_name) = refname.strip_prefix("refs/tags/") { + let tag_path = arc_repo.tags_dir().join(tag_name); + if !tag_path.exists() { + let ref_target = RefTarget { + commit: Some(arc_id), + }; + let yaml = serde_yaml::to_string(&ref_target)?; + fs::write(&tag_path, yaml)?; + updated_tags.push(tag_name.to_string()); + } + } + } + + bridge.save_map()?; + + let mut msg = String::new(); + if imported > 0 { + msg.push_str(&format!("imported {imported} commit(s)")); + } + if !updated_bookmarks.is_empty() { + if !msg.is_empty() { + msg.push_str(", "); + } + msg.push_str(&format!( + "updated bookmark(s): {}", + updated_bookmarks.join(", ") + )); + } + if !updated_tags.is_empty() { + if !msg.is_empty() { + msg.push_str(", "); + } + msg.push_str(&format!("updated tag(s): {}", updated_tags.join(", "))); + } + if msg.is_empty() { + msg = "already up to date".to_string(); + } + + Ok(msg) +} + +/// Clone a git repository and convert it to an arc repository. +/// +/// Creates the target directory, initialises an arc repo, imports all +/// git history, and sets up the worktree at the specified branch. +pub fn clone(url: &str, path: &str, branch: &str) -> Result { + let target = std::path::Path::new(path); + if !target.exists() { + fs::create_dir_all(target)?; + } + + let arc_repo = Repository::init(target)?; + + remote::add(&arc_repo, "origin", url)?; + + let git_dir = shadow_git_dir(&arc_repo); + let git_repo = git2::build::RepoBuilder::new() + .bare(true) + .clone(url, &git_dir)?; + + let mut bridge = GitBridge { + git_repo, + map: GitMap::default(), + map_path: git_map_path(&arc_repo), + }; + + let branch_ref = format!("refs/heads/{branch}"); + let target_oid = if let Ok(r) = bridge.git_repo.find_reference(&branch_ref) { + r.target() + .ok_or(ArcError::UnknownRevision(branch.to_string()))? + } else if let Ok(head) = bridge.git_repo.find_reference("HEAD") { + if let Ok(resolved) = head.resolve() { + resolved.target().ok_or(ArcError::NoCommitsYet)? + } else { + let mut found = None; + if let Ok(refs) = bridge.git_repo.references() { + for r in refs.flatten() { + if let Some(name) = r.name() + && name.starts_with("refs/heads/") + && let Some(oid) = r.target() + { + found = Some(oid); + break; + } + } + } + found.ok_or(ArcError::NoCommitsYet)? + } + } else { + return Err(ArcError::NoCommitsYet); + }; + + let arc_id = bridge.git_to_arc(&arc_repo, target_oid)?; + + let all_refs: Vec<(String, git2::Oid)> = { + let mut result = Vec::new(); + let references = bridge.git_repo.references()?; + for reference in references.flatten() { + if let Some(name) = reference.name() + && let Some(oid) = reference.target() + { + result.push((name.to_string(), oid)); + } + } + result + }; + + for (refname, oid) in &all_refs { + if !bridge.map.git_to_arc.contains_key(&oid.to_string()) { + bridge.git_to_arc(&arc_repo, *oid)?; + } + + let arc_commit_id = match bridge.map.git_to_arc.get(&oid.to_string()) { + Some(id) => CommitId(id.clone()), + None => continue, + }; + + if let Some(bname) = refname.strip_prefix("refs/heads/") { + let ref_target = RefTarget { + commit: Some(arc_commit_id), + }; + let yaml = serde_yaml::to_string(&ref_target)?; + fs::write(arc_repo.bookmarks_dir().join(bname), yaml)?; + } else if let Some(tname) = refname.strip_prefix("refs/tags/") { + let ref_target = RefTarget { + commit: Some(arc_commit_id), + }; + let yaml = serde_yaml::to_string(&ref_target)?; + fs::write(arc_repo.tags_dir().join(tname), yaml)?; + } + } + + let ref_target = RefTarget { + commit: Some(arc_id.clone()), + }; + let yaml = serde_yaml::to_string(&ref_target)?; + fs::write(arc_repo.bookmarks_dir().join(branch), &yaml)?; + + arc_repo.save_head(&Head::Attached { + bookmark: branch.to_string(), + commit: arc_id.clone(), + })?; + + let tree = tracking::materialize_committed_tree(&arc_repo, &arc_id)?; + crate::refs::write_tree(&arc_repo, &tree)?; + + bridge.save_map()?; + + Ok(format!( + "cloned into {} on bookmark '{branch}'", + target.display() + )) +} + +/// Migrate an existing git repository in the current directory to an arc repository. +/// +/// Creates `.arc/` alongside the existing `.git/`, imports all branches +/// and tags from git history as arc commits. +pub fn migrate(path: &std::path::Path) -> Result { + let git_repo = git2::Repository::discover(path).map_err(|_| ArcError::NotAGitRepo)?; + + let workdir = git_repo.workdir().ok_or(ArcError::NotAGitRepo)?; + + let arc_dir = workdir.join(".arc"); + if arc_dir.exists() { + return Err(ArcError::RepoAlreadyExists); + } + + let arc_repo = Repository::init(workdir)?; + + let git_remotes = git_repo.remotes()?; + for i in 0..git_remotes.len() { + if let Some(name) = git_remotes.get(i) + && let Ok(r) = git_repo.find_remote(name) + && let Some(url) = r.url() + { + let _ = remote::add(&arc_repo, name, url); + } + } + + let refs: Vec<(String, git2::Oid)> = { + let mut result = Vec::new(); + for reference in git_repo.references()?.flatten() { + if let (Some(name), Some(oid)) = (reference.name(), reference.target()) + && (name.starts_with("refs/heads/") || name.starts_with("refs/tags/")) + { + let commit_oid = + if let Ok(obj) = git_repo.find_object(oid, Some(git2::ObjectType::Tag)) { + obj.peel_to_commit().map(|c| c.id()).unwrap_or(oid) + } else { + oid + }; + result.push((name.to_string(), commit_oid)); + } + } + result + }; + + let mut bridge = GitBridge { + git_repo, + map: GitMap::default(), + map_path: git_map_path(&arc_repo), + }; + + let mut imported = 0usize; + let mut bookmarks = Vec::new(); + let mut tags = Vec::new(); + + for (_refname, oid) in &refs { + if bridge.map.git_to_arc.contains_key(&oid.to_string()) { + continue; + } + bridge.git_to_arc(&arc_repo, *oid)?; + imported += 1; + } + + for (refname, oid) in &refs { + let oid_hex = oid.to_string(); + let arc_id = match bridge.map.git_to_arc.get(&oid_hex) { + Some(id) => CommitId(id.clone()), + None => continue, + }; + + if let Some(branch_name) = refname.strip_prefix("refs/heads/") { + let ref_target = RefTarget { + commit: Some(arc_id), + }; + let yaml = serde_yaml::to_string(&ref_target)?; + fs::write(arc_repo.bookmarks_dir().join(branch_name), yaml)?; + bookmarks.push(branch_name.to_string()); + } else if let Some(tag_name) = refname.strip_prefix("refs/tags/") { + let ref_target = RefTarget { + commit: Some(arc_id), + }; + let yaml = serde_yaml::to_string(&ref_target)?; + fs::write(arc_repo.tags_dir().join(tag_name), yaml)?; + tags.push(tag_name.to_string()); + } + } + + let head_ref = bridge.git_repo.head().ok(); + let head_branch = head_ref + .as_ref() + .and_then(|r| r.name()) + .and_then(|n| n.strip_prefix("refs/heads/")) + .unwrap_or("main"); + + let bookmark_path = arc_repo.bookmarks_dir().join(head_branch); + if bookmark_path.exists() { + let contents = fs::read_to_string(&bookmark_path)?; + let ref_target: RefTarget = serde_yaml::from_str(&contents)?; + if let Some(commit) = ref_target.commit { + arc_repo.save_head(&Head::Attached { + bookmark: head_branch.to_string(), + commit, + })?; + } + } + + bridge.save_map()?; + + Ok(format!( + "migrated {imported} commit(s), {} bookmark(s), {} tag(s)", + bookmarks.len(), + tags.len() + )) +} + +/// Sync arc refs to the shadow git repo, and optionally push to a remote. +/// +/// Without `--push`, this ensures the shadow git repo mirrors arc state. +/// With `--push`, it also pushes all refs to the default remote. +pub fn sync(arc_repo: &Repository, do_push: bool) -> Result { + let mut bridge = GitBridge::open(arc_repo)?; + let mut synced = 0usize; + + let bookmarks_dir = arc_repo.bookmarks_dir(); + if bookmarks_dir.is_dir() { + for entry in fs::read_dir(&bookmarks_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + let name = entry.file_name().to_string_lossy().to_string(); + let contents = fs::read_to_string(bookmarks_dir.join(&name))?; + let ref_target: RefTarget = serde_yaml::from_str(&contents)?; + if let Some(arc_id) = &ref_target.commit { + let git_oid = bridge.arc_to_git(arc_repo, arc_id)?; + let refname = format!("refs/heads/{name}"); + bridge + .git_repo + .reference(&refname, git_oid, true, "arc sync")?; + synced += 1; + } + } + } + } + + let tags_dir = arc_repo.tags_dir(); + if tags_dir.is_dir() { + for entry in fs::read_dir(&tags_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + let name = entry.file_name().to_string_lossy().to_string(); + let contents = fs::read_to_string(tags_dir.join(&name))?; + let ref_target: RefTarget = serde_yaml::from_str(&contents)?; + if let Some(arc_id) = &ref_target.commit { + let git_oid = bridge.arc_to_git(arc_repo, arc_id)?; + let refname = format!("refs/tags/{name}"); + bridge + .git_repo + .reference(&refname, git_oid, true, "arc sync")?; + synced += 1; + } + } + } + } + + bridge.save_map()?; + + if do_push { + let config = crate::config::load_effective(arc_repo); + let remote_name = config.default_remote; + push(arc_repo, &remote_name)?; + Ok(format!("synced {synced} ref(s), pushed to {remote_name}")) + } else { + Ok(format!("synced {synced} ref(s)")) + } +} + +/// Check whether `ancestor` is an ancestor of `descendant` by walking +/// the first-parent chain from `descendant`. +fn is_ancestor(repo: &Repository, ancestor: &CommitId, descendant: &CommitId) -> bool { + let mut current = descendant.clone(); + loop { + if current == *ancestor { + return true; + } + let obj = match store::read_commit_object(repo, ¤t) { + Ok(o) => o, + Err(_) => return false, + }; + if obj.commit.parents.is_empty() { + return false; + } + current = obj.commit.parents[0].clone(); + } +} diff --git a/src/cli.rs b/src/cli.rs index 16ccbdb..a8c12f9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,12 +3,14 @@ use std::path::Path; use clap::{Parser, Subcommand}; +use crate::bridge; use crate::config; use crate::diff; use crate::ignore::IgnoreRules; use crate::inspect; use crate::modify; use crate::refs; +use crate::remote; use crate::repo::Repository; use crate::stash; use crate::tracking; @@ -485,21 +487,47 @@ pub fn dispatch(cli: Cli) { } } Command::Push { remote } => { - let r = remote.as_deref().unwrap_or("origin"); - println!("arc push: {r} (not yet implemented)"); + let repo = open_repo_or_exit(); + let config = config::load_effective(&repo); + let r = remote.as_deref().unwrap_or(&config.default_remote); + match bridge::push(&repo, r) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } Command::Pull { remote } => { - let r = remote.as_deref().unwrap_or("origin"); - println!("arc pull: {r} (not yet implemented)"); + let repo = open_repo_or_exit(); + let config = config::load_effective(&repo); + let r = remote.as_deref().unwrap_or(&config.default_remote); + match bridge::pull(&repo, r) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } Command::Clone { url, path, branch } => { let p = path.as_deref().unwrap_or("."); let b = branch.as_deref().unwrap_or("main"); - println!("arc clone: {url} -> {p} (branch: {b}) (not yet implemented)"); - } - Command::Migrate => { - println!("arc migrate (not yet implemented)"); + match bridge::clone(&url, p, b) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } + Command::Migrate => match bridge::migrate(Path::new(".")) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, Command::Mark { command } => { let repo = open_repo_or_exit(); match command { @@ -686,20 +714,47 @@ pub fn dispatch(cli: Cli) { } }, Command::Sync { push } => { - let mode = if push { "push" } else { "local" }; - println!("arc sync ({mode}) (not yet implemented)"); + let repo = open_repo_or_exit(); + match bridge::sync(&repo, push) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } - Command::Remote { command } => match command { - RemoteCommand::Add { name, url } => { - println!("arc remote add: {name} {url} (not yet implemented)"); + Command::Remote { command } => { + let repo = open_repo_or_exit(); + match command { + RemoteCommand::Add { name, url } => match remote::add(&repo, &name, &url) { + Ok(()) => println!("remote '{name}' added ({url})"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + RemoteCommand::Rm { name } => match remote::rm(&repo, &name) { + Ok(()) => println!("remote '{name}' removed"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + RemoteCommand::List => match remote::list(&repo) { + Ok(output) => { + if output.is_empty() { + println!("no remotes configured"); + } else { + print!("{output}"); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, } - RemoteCommand::Rm { name } => { - println!("arc remote rm: {name} (not yet implemented)"); - } - RemoteCommand::List => { - println!("arc remote list (not yet implemented)"); - } - }, + } } } diff --git a/src/error.rs b/src/error.rs index e2c065b..dd52aab 100644 --- a/src/error.rs +++ b/src/error.rs @@ -34,6 +34,12 @@ pub enum ArcError { StashEmpty(String), StashBaseMismatch, InvalidConfigKey(String), + Git(String), + RemoteNotFound(String), + RemoteAlreadyExists(String), + NothingToPull, + NotAGitRepo, + FastForwardOnly(String), } impl fmt::Display for ArcError { @@ -83,6 +89,12 @@ impl fmt::Display for ArcError { write!(f, "stash base does not match current HEAD") } Self::InvalidConfigKey(key) => write!(f, "invalid config key: {key}"), + Self::Git(msg) => write!(f, "git error: {msg}"), + Self::RemoteNotFound(name) => write!(f, "remote not configured: {name}"), + Self::RemoteAlreadyExists(name) => write!(f, "remote already exists: {name}"), + Self::NothingToPull => write!(f, "nothing new to pull"), + Self::NotAGitRepo => write!(f, "not a git repository"), + Self::FastForwardOnly(reason) => write!(f, "cannot fast-forward: {reason}"), } } } @@ -113,4 +125,10 @@ impl From for ArcError { } } +impl From for ArcError { + fn from(e: git2::Error) -> Self { + Self::Git(e.message().to_string()) + } +} + pub type Result = std::result::Result; diff --git a/src/ignore.rs b/src/ignore.rs index aed5c16..0e86a7e 100644 --- a/src/ignore.rs +++ b/src/ignore.rs @@ -70,7 +70,11 @@ impl IgnoreRules { } pub fn matches(&self, rel_path: &str, is_dir: bool) -> bool { - if rel_path == ".arc" || rel_path.starts_with(".arc/") { + if rel_path == ".arc" + || rel_path.starts_with(".arc/") + || rel_path == ".git" + || rel_path.starts_with(".git/") + { return true; } diff --git a/src/main.rs b/src/main.rs index 3611eab..1a7badd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +pub mod bridge; mod cli; pub mod config; pub mod diff; @@ -8,6 +9,7 @@ pub mod merge; pub mod model; pub mod modify; pub mod refs; +pub mod remote; pub mod repo; pub mod resolve; pub mod stash; diff --git a/src/remote.rs b/src/remote.rs new file mode 100644 index 0000000..ca972ea --- /dev/null +++ b/src/remote.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; + +use crate::error::{ArcError, Result}; +use crate::repo::Repository; + +/// A single remote entry storing the URL of a remote repository. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RemoteEntry { + pub url: String, +} + +/// Top-level structure for the `.arc/remotes.yml` file. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RemotesFile { + pub remotes: BTreeMap, +} + +fn remotes_path(repo: &Repository) -> std::path::PathBuf { + repo.arc_dir.join("remotes.yml") +} + +/// Load the remotes file from `.arc/remotes.yml`. +/// +/// Returns an empty `RemotesFile` if the file does not yet exist. +pub fn load(repo: &Repository) -> Result { + let path = remotes_path(repo); + if !path.exists() { + return Ok(RemotesFile { + remotes: BTreeMap::new(), + }); + } + let contents = fs::read_to_string(&path)?; + let file: RemotesFile = serde_yaml::from_str(&contents)?; + Ok(file) +} + +/// Save the remotes file to `.arc/remotes.yml`. +pub fn save(repo: &Repository, file: &RemotesFile) -> Result<()> { + let yaml = serde_yaml::to_string(file)?; + fs::write(remotes_path(repo), yaml)?; + Ok(()) +} + +/// Add a new remote with the given name and URL. +/// +/// The name is validated as a ref name. Returns an error if the remote +/// already exists. +pub fn add(repo: &Repository, name: &str, url: &str) -> Result<()> { + crate::repo::validate_ref_name(name)?; + let mut file = load(repo)?; + if file.remotes.contains_key(name) { + return Err(ArcError::RemoteAlreadyExists(name.to_string())); + } + file.remotes.insert( + name.to_string(), + RemoteEntry { + url: url.to_string(), + }, + ); + save(repo, &file) +} + +/// Remove an existing remote by name. +/// +/// Returns an error if the remote does not exist. +pub fn rm(repo: &Repository, name: &str) -> Result<()> { + let mut file = load(repo)?; + if file.remotes.remove(name).is_none() { + return Err(ArcError::RemoteNotFound(name.to_string())); + } + save(repo, &file) +} + +/// List all configured remotes as a formatted string. +/// +/// Each line has the form ` \t\n`. +/// Returns an empty string if no remotes are configured. +pub fn list(repo: &Repository) -> Result { + let file = load(repo)?; + let mut out = String::new(); + for (name, entry) in &file.remotes { + out.push_str(&format!(" {name}\t{}\n", entry.url)); + } + Ok(out) +} diff --git a/src/tracking.rs b/src/tracking.rs index 12f276d..b0da494 100644 --- a/src/tracking.rs +++ b/src/tracking.rs @@ -29,7 +29,7 @@ fn scan_dir(root: &Path, dir: &Path, ignore: &IgnoreRules, tree: &mut FileTree) let path = entry.path(); let rel = to_rel_string(root, &path); - if rel == ".arc" || rel.starts_with(".arc/") { + if rel == ".arc" || rel.starts_with(".arc/") || rel == ".git" || rel.starts_with(".git/") { continue; } diff --git a/tests/bridge.rs b/tests/bridge.rs new file mode 100644 index 0000000..86abf07 --- /dev/null +++ b/tests/bridge.rs @@ -0,0 +1,575 @@ +use std::process::Command; +use tempfile::TempDir; + +fn arc_cmd() -> Command { + Command::new(env!("CARGO_BIN_EXE_arc")) +} + +fn git_cmd() -> Command { + Command::new("git") +} + +fn create_git_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + git_cmd() + .args(["init"]) + .current_dir(dir.path()) + .output() + .expect("failed to git init"); + git_cmd() + .args(["config", "user.name", "test"]) + .current_dir(dir.path()) + .output() + .unwrap(); + git_cmd() + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir.path()) + .output() + .unwrap(); + dir +} + +fn git_commit(dir: &TempDir, name: &str, content: &str, msg: &str) { + let file_path = dir.path().join(name); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&file_path, content).unwrap(); + git_cmd() + .args(["add", "."]) + .current_dir(dir.path()) + .output() + .unwrap(); + git_cmd() + .args(["commit", "-m", msg]) + .current_dir(dir.path()) + .output() + .unwrap(); +} + +fn create_bare_git_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + git_cmd() + .args(["init", "--bare", "--initial-branch=main"]) + .current_dir(dir.path()) + .output() + .expect("failed to git init --bare"); + dir +} + +fn init_arc_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + arc_cmd() + .arg("init") + .current_dir(dir.path()) + .output() + .expect("failed to init arc"); + dir +} + +fn arc_commit(dir: &TempDir, name: &str, content: &str, msg: &str) { + std::fs::write(dir.path().join(name), content).unwrap(); + let output = arc_cmd() + .args(["commit", msg]) + .current_dir(dir.path()) + .output() + .expect("failed to commit"); + assert!( + output.status.success(), + "commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn migrate_converts_git_repo() { + let dir = create_git_repo(); + git_commit(&dir, "hello.txt", "hello world\n", "initial commit"); + git_commit(&dir, "hello.txt", "hello world v2\n", "second commit"); + + let output = arc_cmd() + .arg("migrate") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "migrate failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("migrated")); + assert!(stdout.contains("commit(s)")); + + assert!(dir.path().join(".arc").is_dir()); + assert!(dir.path().join(".arc").join("commits").is_dir()); + + let log_output = arc_cmd() + .args(["log"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(log_output.status.success()); + let log_stdout = String::from_utf8_lossy(&log_output.stdout); + assert!(log_stdout.contains("initial commit")); + assert!(log_stdout.contains("second commit")); +} + +#[test] +fn migrate_preserves_branches_as_bookmarks() { + let dir = create_git_repo(); + git_commit(&dir, "hello.txt", "hello\n", "first"); + + git_cmd() + .args(["branch", "feature"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + arc_cmd() + .arg("migrate") + .current_dir(dir.path()) + .output() + .unwrap(); + + let output = arc_cmd() + .args(["mark", "list"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("main") || stdout.contains("master")); + assert!(stdout.contains("feature")); +} + +#[test] +fn migrate_preserves_tags() { + let dir = create_git_repo(); + git_commit(&dir, "hello.txt", "hello\n", "first"); + + git_cmd() + .args(["tag", "v1.0"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + arc_cmd() + .arg("migrate") + .current_dir(dir.path()) + .output() + .unwrap(); + + let output = arc_cmd() + .args(["tag", "list"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("v1.0")); +} + +#[test] +fn migrate_fails_if_not_git_repo() { + let dir = TempDir::new().unwrap(); + let output = arc_cmd() + .arg("migrate") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not a git repository")); +} + +#[test] +fn migrate_fails_if_arc_already_exists() { + let dir = create_git_repo(); + git_commit(&dir, "hello.txt", "hello\n", "first"); + + arc_cmd() + .arg("migrate") + .current_dir(dir.path()) + .output() + .unwrap(); + + let output = arc_cmd() + .arg("migrate") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("already exists")); +} + +#[test] +fn clone_from_local_git_repo() { + let git_dir = create_git_repo(); + git_commit(&git_dir, "hello.txt", "hello world\n", "initial commit"); + git_commit(&git_dir, "readme.md", "readme\n", "add readme"); + + let clone_dir = TempDir::new().unwrap(); + let clone_path = clone_dir.path().join("cloned"); + + let output = arc_cmd() + .args([ + "clone", + git_dir.path().to_str().unwrap(), + clone_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("cloned")); + + assert!(clone_path.join(".arc").is_dir()); + assert!(clone_path.join("hello.txt").exists()); + assert!(clone_path.join("readme.md").exists()); + + let content = std::fs::read_to_string(clone_path.join("hello.txt")).unwrap(); + assert_eq!(content, "hello world\n"); + + let log_output = arc_cmd() + .args(["log"]) + .current_dir(&clone_path) + .output() + .unwrap(); + assert!(log_output.status.success()); + let log_stdout = String::from_utf8_lossy(&log_output.stdout); + assert!(log_stdout.contains("initial commit")); + assert!(log_stdout.contains("add readme")); +} + +#[test] +fn clone_sets_origin_remote() { + let git_dir = create_git_repo(); + git_commit(&git_dir, "hello.txt", "hello\n", "first"); + + let clone_dir = TempDir::new().unwrap(); + let clone_path = clone_dir.path().join("cloned"); + + arc_cmd() + .args([ + "clone", + git_dir.path().to_str().unwrap(), + clone_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + + let output = arc_cmd() + .args(["remote", "list"]) + .current_dir(&clone_path) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("origin")); +} + +#[test] +fn push_to_bare_git_repo() { + let arc_dir = init_arc_repo(); + let bare_dir = create_bare_git_repo(); + + arc_cmd() + .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + arc_commit(&arc_dir, "hello.txt", "hello world\n", "first commit"); + arc_commit(&arc_dir, "hello.txt", "hello world v2\n", "second commit"); + + let output = arc_cmd() + .args(["push"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("pushed")); + + let git_log = git_cmd() + .args(["log", "--oneline", "main"]) + .current_dir(bare_dir.path()) + .output() + .unwrap(); + assert!( + git_log.status.success(), + "git log failed: {}", + String::from_utf8_lossy(&git_log.stderr) + ); + let log_stdout = String::from_utf8_lossy(&git_log.stdout); + assert!(log_stdout.contains("first commit")); + assert!(log_stdout.contains("second commit")); +} + +#[test] +fn push_preserves_tags_as_git_tags() { + let arc_dir = init_arc_repo(); + let bare_dir = create_bare_git_repo(); + + arc_cmd() + .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); + + arc_cmd() + .args(["tag", "add", "v1.0"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["push"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + let git_tags = git_cmd() + .args(["tag", "-l"]) + .current_dir(bare_dir.path()) + .output() + .unwrap(); + let tags_stdout = String::from_utf8_lossy(&git_tags.stdout); + assert!(tags_stdout.contains("v1.0")); +} + +#[test] +fn push_bookmarks_as_git_branches() { + let arc_dir = init_arc_repo(); + let bare_dir = create_bare_git_repo(); + + arc_cmd() + .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); + + arc_cmd() + .args(["mark", "add", "feature"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + arc_cmd() + .args(["push"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + let git_branches = git_cmd() + .args(["branch", "-a"]) + .current_dir(bare_dir.path()) + .output() + .unwrap(); + let branches_stdout = String::from_utf8_lossy(&git_branches.stdout); + assert!(branches_stdout.contains("main")); + assert!(branches_stdout.contains("feature")); +} + +#[test] +fn pull_imports_new_commits() { + let git_dir = create_git_repo(); + git_commit(&git_dir, "hello.txt", "hello\n", "initial"); + + let clone_dir = TempDir::new().unwrap(); + let clone_path = clone_dir.path().join("cloned"); + + arc_cmd() + .args([ + "clone", + git_dir.path().to_str().unwrap(), + clone_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + + git_commit(&git_dir, "hello.txt", "hello v2\n", "update"); + + let output = arc_cmd() + .args(["pull"]) + .current_dir(&clone_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "pull failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("imported") || stdout.contains("updated") || stdout.contains("up to date"), + "unexpected pull output: {stdout}" + ); +} + +#[test] +fn sync_syncs_refs_to_shadow_git() { + let arc_dir = init_arc_repo(); + arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); + + let output = arc_cmd() + .args(["sync"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "sync failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("synced")); + + assert!(arc_dir.path().join(".arc").join("git").is_dir()); +} + +#[test] +fn push_then_clone_roundtrip() { + let arc_dir = init_arc_repo(); + let bare_dir = create_bare_git_repo(); + + arc_cmd() + .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + arc_commit(&arc_dir, "hello.txt", "hello world\n", "first commit"); + arc_commit(&arc_dir, "data.txt", "data here\n", "add data"); + + arc_cmd() + .args(["push"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + + let clone_dir = TempDir::new().unwrap(); + let clone_path = clone_dir.path().join("roundtrip"); + + let output = arc_cmd() + .args([ + "clone", + bare_dir.path().to_str().unwrap(), + clone_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!(clone_path.join("hello.txt").exists()); + assert!(clone_path.join("data.txt").exists()); + assert_eq!( + std::fs::read_to_string(clone_path.join("hello.txt")).unwrap(), + "hello world\n" + ); + assert_eq!( + std::fs::read_to_string(clone_path.join("data.txt")).unwrap(), + "data here\n" + ); + + let log_output = arc_cmd() + .args(["log"]) + .current_dir(&clone_path) + .output() + .unwrap(); + let log_stdout = String::from_utf8_lossy(&log_output.stdout); + assert!(log_stdout.contains("first commit")); + assert!(log_stdout.contains("add data")); +} + +#[test] +fn migrate_preserves_file_content() { + let dir = create_git_repo(); + git_commit(&dir, "hello.txt", "content A\n", "first"); + git_commit(&dir, "sub/nested.txt", "nested content\n", "nested"); + + arc_cmd() + .arg("migrate") + .current_dir(dir.path()) + .output() + .unwrap(); + + let status_output = arc_cmd() + .args(["status"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let status_stdout = String::from_utf8_lossy(&status_output.stdout); + assert!( + status_stdout.contains("clean") || status_stdout.is_empty(), + "unexpected status after migrate: {status_stdout}" + ); +} + +#[test] +fn push_fails_without_remote() { + let arc_dir = init_arc_repo(); + arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); + + let output = arc_cmd() + .args(["push"]) + .current_dir(arc_dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("remote not configured")); +} + +#[test] +fn clone_subdirectory_files() { + let git_dir = create_git_repo(); + std::fs::create_dir_all(git_dir.path().join("src")).unwrap(); + std::fs::write(git_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); + git_cmd() + .args(["add", "."]) + .current_dir(git_dir.path()) + .output() + .unwrap(); + git_cmd() + .args(["commit", "-m", "add src"]) + .current_dir(git_dir.path()) + .output() + .unwrap(); + + let clone_dir = TempDir::new().unwrap(); + let clone_path = clone_dir.path().join("cloned"); + + arc_cmd() + .args([ + "clone", + git_dir.path().to_str().unwrap(), + clone_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!(clone_path.join("src").join("main.rs").exists()); + assert_eq!( + std::fs::read_to_string(clone_path.join("src/main.rs")).unwrap(), + "fn main() {}\n" + ); +} diff --git a/tests/cli.rs b/tests/cli.rs index 898aa09..a41d07c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -100,8 +100,15 @@ fn config_show_subcommand_succeeds() { #[test] fn remote_list_subcommand_succeeds() { + let dir = TempDir::new().unwrap(); + arc_cmd() + .arg("init") + .current_dir(dir.path()) + .output() + .expect("failed to init"); let output = arc_cmd() .args(["remote", "list"]) + .current_dir(dir.path()) .output() .expect("failed to run arc"); assert!(output.status.success()); diff --git a/tests/remote.rs b/tests/remote.rs new file mode 100644 index 0000000..6c2be55 --- /dev/null +++ b/tests/remote.rs @@ -0,0 +1,151 @@ +use std::process::Command; +use tempfile::TempDir; + +fn arc_cmd() -> Command { + Command::new(env!("CARGO_BIN_EXE_arc")) +} + +fn init_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + arc_cmd() + .arg("init") + .current_dir(dir.path()) + .output() + .expect("failed to init"); + dir +} + +#[test] +fn remote_add_creates_entry() { + let dir = init_repo(); + let output = arc_cmd() + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("remote 'origin' added")); + + let remotes_path = dir.path().join(".arc").join("remotes.yml"); + assert!(remotes_path.exists()); + let contents = std::fs::read_to_string(&remotes_path).unwrap(); + assert!(contents.contains("origin")); + assert!(contents.contains("https://example.com/repo.git")); +} + +#[test] +fn remote_add_fails_if_exists() { + let dir = init_repo(); + arc_cmd() + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let output = arc_cmd() + .args(["remote", "add", "origin", "https://other.com/repo.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("remote already exists")); +} + +#[test] +fn remote_rm_removes_entry() { + let dir = init_repo(); + arc_cmd() + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let output = arc_cmd() + .args(["remote", "rm", "origin"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("remote 'origin' removed")); +} + +#[test] +fn remote_rm_fails_if_not_found() { + let dir = init_repo(); + let output = arc_cmd() + .args(["remote", "rm", "nonexistent"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("remote not configured")); +} + +#[test] +fn remote_list_shows_remotes() { + let dir = init_repo(); + arc_cmd() + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + arc_cmd() + .args(["remote", "add", "upstream", "https://upstream.com/repo.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let output = arc_cmd() + .args(["remote", "list"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("origin")); + assert!(stdout.contains("https://example.com/repo.git")); + assert!(stdout.contains("upstream")); + assert!(stdout.contains("https://upstream.com/repo.git")); +} + +#[test] +fn remote_list_empty() { + let dir = init_repo(); + let output = arc_cmd() + .args(["remote", "list"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("no remotes configured")); +} + +#[test] +fn remote_list_sorted_alphabetically() { + let dir = init_repo(); + arc_cmd() + .args(["remote", "add", "zebra", "https://z.com"]) + .current_dir(dir.path()) + .output() + .unwrap(); + arc_cmd() + .args(["remote", "add", "alpha", "https://a.com"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let output = arc_cmd() + .args(["remote", "list"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let alpha_pos = stdout.find("alpha").unwrap(); + let zebra_pos = stdout.find("zebra").unwrap(); + assert!(alpha_pos < zebra_pos); +}