diff --git a/Cargo.lock b/Cargo.lock index caf8ead..dc5c8bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,7 +57,6 @@ name = "arc" version = "0.1.0" dependencies = [ "clap", - "git2", "hex", "rmp-serde", "serde", @@ -181,17 +180,6 @@ 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" @@ -220,15 +208,6 @@ 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" @@ -251,21 +230,6 @@ 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" @@ -284,108 +248,6 @@ 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" @@ -424,64 +286,12 @@ 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" @@ -503,55 +313,12 @@ 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" @@ -674,18 +441,6 @@ 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" @@ -703,17 +458,6 @@ 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" @@ -727,16 +471,6 @@ 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" @@ -755,36 +489,12 @@ 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" @@ -821,89 +531,6 @@ 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 728b22b..ed6560a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ 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 7b66fe1..f07ac4f 100644 --- a/flake.nix +++ b/flake.nix @@ -43,8 +43,6 @@ version = "0.1.0"; inherit src; strictDeps = true; - nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.perl ]; - nativeCheckInputs = [ pkgs.git ]; }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; @@ -85,9 +83,6 @@ packages = [ rustToolchain pkgs.git - pkgs.pkg-config - pkgs.cmake - pkgs.perl ]; RUST_BACKTRACE = "1"; diff --git a/src/bridge.rs b/src/bridge.rs deleted file mode 100644 index 8551d61..0000000 --- a/src/bridge.rs +++ /dev/null @@ -1,870 +0,0 @@ -//! 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 a8c12f9..65338f3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,14 +3,11 @@ 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; @@ -313,43 +310,8 @@ 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 { - let args: Vec = std::env::args().collect(); - let expanded = expand_aliases(args); - Cli::parse_from(expanded) + Cli::parse() } pub fn dispatch(cli: Cli) { @@ -487,47 +449,21 @@ pub fn dispatch(cli: Cli) { } } Command::Push { remote } => { - 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); - } - } + let r = remote.as_deref().unwrap_or("origin"); + println!("arc push: {r} (not yet implemented)"); } Command::Pull { remote } => { - 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); - } - } + let r = remote.as_deref().unwrap_or("origin"); + println!("arc pull: {r} (not yet implemented)"); } Command::Clone { url, path, branch } => { let p = path.as_deref().unwrap_or("."); let b = branch.as_deref().unwrap_or("main"); - match bridge::clone(&url, p, b) { - Ok(msg) => println!("{msg}"), - Err(e) => { - eprintln!("error: {e}"); - std::process::exit(1); - } - } + println!("arc clone: {url} -> {p} (branch: {b}) (not yet implemented)"); + } + Command::Migrate => { + println!("arc migrate (not yet implemented)"); } - 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 { @@ -656,105 +592,37 @@ pub fn dispatch(cli: Cli) { } Command::Config { command } => match command { ConfigCommand::Set { global, key, value } => { - 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); - } - } + let scope = if global { "global" } else { "local" }; + println!("arc config set ({scope}): {key} = {value} (not yet implemented)"); } ConfigCommand::Get { global, key } => { - 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); - } - } + let scope = if global { "global" } else { "local" }; + println!("arc config get ({scope}): {key} (not yet implemented)"); } ConfigCommand::Show { global } => { - 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); - } - } + let scope = if global { "global" } else { "local" }; + println!("arc config show ({scope}) (not yet implemented)"); } ConfigCommand::Unset { global, key } => { - 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); - } - } + let scope = if global { "global" } else { "local" }; + println!("arc config unset ({scope}): {key} (not yet implemented)"); } }, Command::Sync { push } => { - let repo = open_repo_or_exit(); - match bridge::sync(&repo, push) { - Ok(msg) => println!("{msg}"), - Err(e) => { - eprintln!("error: {e}"); - std::process::exit(1); - } - } + let mode = if push { "push" } else { "local" }; + println!("arc sync ({mode}) (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); - } - }, + Command::Remote { command } => match command { + RemoteCommand::Add { name, url } => { + println!("arc remote add: {name} {url} (not yet implemented)"); } - } + RemoteCommand::Rm { name } => { + println!("arc remote rm: {name} (not yet implemented)"); + } + RemoteCommand::List => { + println!("arc remote list (not yet implemented)"); + } + }, } } diff --git a/src/config.rs b/src/config.rs index ac4915f..006d427 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,7 +34,6 @@ pub struct DefaultConfig { pub remote: Option, } -#[derive(Serialize)] pub struct EffectiveConfig { pub user_name: Option, pub user_email: Option, @@ -135,168 +134,6 @@ 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 dd52aab..5ac4dd1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,13 +33,6 @@ pub enum ArcError { NothingToStash, StashEmpty(String), StashBaseMismatch, - InvalidConfigKey(String), - Git(String), - RemoteNotFound(String), - RemoteAlreadyExists(String), - NothingToPull, - NotAGitRepo, - FastForwardOnly(String), } impl fmt::Display for ArcError { @@ -88,13 +81,6 @@ 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}"), - 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}"), } } } @@ -125,10 +111,4 @@ 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 0e86a7e..aed5c16 100644 --- a/src/ignore.rs +++ b/src/ignore.rs @@ -70,11 +70,7 @@ impl IgnoreRules { } pub fn matches(&self, rel_path: &str, is_dir: bool) -> bool { - if rel_path == ".arc" - || rel_path.starts_with(".arc/") - || rel_path == ".git" - || rel_path.starts_with(".git/") - { + if rel_path == ".arc" || rel_path.starts_with(".arc/") { return true; } diff --git a/src/main.rs b/src/main.rs index 1a7badd..3611eab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -pub mod bridge; mod cli; pub mod config; pub mod diff; @@ -9,7 +8,6 @@ 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 deleted file mode 100644 index ca972ea..0000000 --- a/src/remote.rs +++ /dev/null @@ -1,87 +0,0 @@ -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 b0da494..12f276d 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/") || rel == ".git" || rel.starts_with(".git/") { + if rel == ".arc" || rel.starts_with(".arc/") { continue; } diff --git a/tests/bridge.rs b/tests/bridge.rs deleted file mode 100644 index 86abf07..0000000 --- a/tests/bridge.rs +++ /dev/null @@ -1,575 +0,0 @@ -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 a41d07c..898aa09 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -100,15 +100,8 @@ 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/config.rs b/tests/config.rs index f5e4f52..dcbcc12 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -5,18 +5,14 @@ fn arc_cmd() -> Command { Command::new(env!("CARGO_BIN_EXE_arc")) } -fn init_repo(dir: &TempDir) { +#[test] +fn config_yml_is_valid_yaml() { + let dir = TempDir::new().unwrap(); arc_cmd() .arg("init") .current_dir(dir.path()) .output() - .expect("failed to run arc init"); -} - -#[test] -fn config_yml_is_valid_yaml() { - let dir = TempDir::new().unwrap(); - init_repo(&dir); + .expect("failed to run arc"); let config_path = dir.path().join(".arc").join("config.yml"); let contents = std::fs::read_to_string(&config_path).unwrap(); @@ -27,7 +23,11 @@ fn config_yml_is_valid_yaml() { #[test] fn config_has_default_section() { let dir = TempDir::new().unwrap(); - init_repo(&dir); + arc_cmd() + .arg("init") + .current_dir(dir.path()) + .output() + .expect("failed to run arc"); let config_path = dir.path().join(".arc").join("config.yml"); let contents = std::fs::read_to_string(&config_path).unwrap(); @@ -41,7 +41,11 @@ fn config_has_default_section() { #[test] fn head_is_valid_yaml() { let dir = TempDir::new().unwrap(); - init_repo(&dir); + arc_cmd() + .arg("init") + .current_dir(dir.path()) + .output() + .expect("failed to run arc"); let head_path = dir.path().join(".arc").join("HEAD"); let contents = std::fs::read_to_string(&head_path).unwrap(); @@ -54,559 +58,14 @@ fn head_is_valid_yaml() { #[test] fn bookmark_ref_is_valid_yaml() { let dir = TempDir::new().unwrap(); - init_repo(&dir); + arc_cmd() + .arg("init") + .current_dir(dir.path()) + .output() + .expect("failed to run arc"); 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"); -} diff --git a/tests/remote.rs b/tests/remote.rs deleted file mode 100644 index 6c2be55..0000000 --- a/tests/remote.rs +++ /dev/null @@ -1,151 +0,0 @@ -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); -}