Compare commits

...

2 commits

Author SHA1 Message Date
d7694737de
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
2026-02-08 02:53:12 +00:00
0e9c810a41
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
2026-02-08 02:34:48 +00:00
15 changed files with 2981 additions and 50 deletions

373
Cargo.lock generated
View file

@ -57,6 +57,7 @@ name = "arc"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"git2",
"hex", "hex",
"rmp-serde", "rmp-serde",
"serde", "serde",
@ -180,6 +181,17 @@ dependencies = [
"crypto-common", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -208,6 +220,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 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]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -230,6 +251,21 @@ dependencies = [
"wasip2", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@ -248,6 +284,108 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@ -286,12 +424,64 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" 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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -313,12 +503,55 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -441,6 +674,18 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@ -458,6 +703,17 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.24.0" version = "3.24.0"
@ -471,6 +727,16 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@ -489,12 +755,36 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 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]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -531,6 +821,89 @@ version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 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]] [[package]]
name = "zstd" name = "zstd"
version = "0.13.3" version = "0.13.3"

View file

@ -11,6 +11,7 @@ rmp-serde = "1"
zstd = "0.13" zstd = "0.13"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
git2 = { version = "0.19", features = ["vendored-libgit2", "vendored-openssl"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -43,6 +43,8 @@
version = "0.1.0"; version = "0.1.0";
inherit src; inherit src;
strictDeps = true; strictDeps = true;
nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.perl ];
nativeCheckInputs = [ pkgs.git ];
}; };
cargoArtifacts = craneLib.buildDepsOnly commonArgs; cargoArtifacts = craneLib.buildDepsOnly commonArgs;
@ -83,6 +85,9 @@
packages = [ packages = [
rustToolchain rustToolchain
pkgs.git pkgs.git
pkgs.pkg-config
pkgs.cmake
pkgs.perl
]; ];
RUST_BACKTRACE = "1"; RUST_BACKTRACE = "1";

870
src/bridge.rs Normal file
View file

@ -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<String, String>,
/// Git OID hex string → arc commit ID.
pub git_to_arc: BTreeMap<String, String>,
}
/// 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<GitMap> {
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<Self> {
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<git2::Oid> {
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<git2::Commit<'_>> = parent_oids
.iter()
.map(|oid| self.git_repo.find_commit(*oid))
.collect::<std::result::Result<Vec<_>, _>>()?;
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<CommitId> {
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<git2::Oid> = (0..git_commit.parent_count())
.map(|i| git_commit.parent_id(i))
.collect::<std::result::Result<Vec<_>, _>>()?;
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<String, Vec<u8>>) -> Result<git2::Oid> {
let mut dirs: BTreeMap<String, Vec<(String, Vec<u8>)>> = BTreeMap::new();
let mut root_files: Vec<(String, Vec<u8>)> = 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<u8>)],
subdirs: &BTreeMap<String, Vec<(String, Vec<u8>)>>,
) -> Result<git2::Oid> {
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<String, Vec<(String, Vec<u8>)>> = 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<BTreeMap<String, Vec<u8>>> {
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<String, Vec<u8>>,
) -> 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<String> {
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<String> = 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<String> = 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<String> {
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<String> {
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<String> {
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<String> {
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, &current) {
Ok(o) => o,
Err(_) => return false,
};
if obj.commit.parents.is_empty() {
return false;
}
current = obj.commit.parents[0].clone();
}
}

View file

@ -3,11 +3,14 @@ use std::path::Path;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use crate::bridge;
use crate::config;
use crate::diff; use crate::diff;
use crate::ignore::IgnoreRules; use crate::ignore::IgnoreRules;
use crate::inspect; use crate::inspect;
use crate::modify; use crate::modify;
use crate::refs; use crate::refs;
use crate::remote;
use crate::repo::Repository; use crate::repo::Repository;
use crate::stash; use crate::stash;
use crate::tracking; use crate::tracking;
@ -310,8 +313,43 @@ pub enum RemoteCommand {
List, List,
} }
pub fn expand_aliases(args: Vec<String>) -> Vec<String> {
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<String, String> {
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 { pub fn parse() -> Cli {
Cli::parse() let args: Vec<String> = std::env::args().collect();
let expanded = expand_aliases(args);
Cli::parse_from(expanded)
} }
pub fn dispatch(cli: Cli) { pub fn dispatch(cli: Cli) {
@ -449,21 +487,47 @@ pub fn dispatch(cli: Cli) {
} }
} }
Command::Push { remote } => { Command::Push { remote } => {
let r = remote.as_deref().unwrap_or("origin"); let repo = open_repo_or_exit();
println!("arc push: {r} (not yet implemented)"); 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 } => { Command::Pull { remote } => {
let r = remote.as_deref().unwrap_or("origin"); let repo = open_repo_or_exit();
println!("arc pull: {r} (not yet implemented)"); 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 } => { Command::Clone { url, path, branch } => {
let p = path.as_deref().unwrap_or("."); let p = path.as_deref().unwrap_or(".");
let b = branch.as_deref().unwrap_or("main"); let b = branch.as_deref().unwrap_or("main");
println!("arc clone: {url} -> {p} (branch: {b}) (not yet implemented)"); match bridge::clone(&url, p, b) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
} }
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 } => { Command::Mark { command } => {
let repo = open_repo_or_exit(); let repo = open_repo_or_exit();
match command { match command {
@ -592,37 +656,105 @@ pub fn dispatch(cli: Cli) {
} }
Command::Config { command } => match command { Command::Config { command } => match command {
ConfigCommand::Set { global, key, value } => { ConfigCommand::Set { global, key, value } => {
let scope = if global { "global" } else { "local" }; let repo = if global {
println!("arc config set ({scope}): {key} = {value} (not yet implemented)"); 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 } => { ConfigCommand::Get { global, key } => {
let scope = if global { "global" } else { "local" }; let repo = if global {
println!("arc config get ({scope}): {key} (not yet implemented)"); 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 } => { ConfigCommand::Show { global } => {
let scope = if global { "global" } else { "local" }; let repo = if global {
println!("arc config show ({scope}) (not yet implemented)"); 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 } => { ConfigCommand::Unset { global, key } => {
let scope = if global { "global" } else { "local" }; let repo = if global {
println!("arc config unset ({scope}): {key} (not yet implemented)"); 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 } => { Command::Sync { push } => {
let mode = if push { "push" } else { "local" }; let repo = open_repo_or_exit();
println!("arc sync ({mode}) (not yet implemented)"); 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)");
} }
RemoteCommand::Rm { name } => {
println!("arc remote rm: {name} (not yet implemented)");
} }
RemoteCommand::List => { Command::Remote { command } => {
println!("arc remote list (not yet implemented)"); 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);
}
},
}
}
} }
} }

View file

@ -34,6 +34,7 @@ pub struct DefaultConfig {
pub remote: Option<String>, pub remote: Option<String>,
} }
#[derive(Serialize)]
pub struct EffectiveConfig { pub struct EffectiveConfig {
pub user_name: Option<String>, pub user_name: Option<String>,
pub user_email: Option<String>, pub user_email: Option<String>,
@ -134,6 +135,168 @@ pub fn load_effective(repo: &crate::repo::Repository) -> EffectiveConfig {
Config::effective(local, global) 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<Option<String>> {
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<Option<String>> {
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<String> {
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 { impl ArcError {
pub fn invalid_path(msg: impl Into<String>) -> Self { pub fn invalid_path(msg: impl Into<String>) -> Self {
Self::InvalidPath(msg.into()) Self::InvalidPath(msg.into())

View file

@ -33,6 +33,13 @@ pub enum ArcError {
NothingToStash, NothingToStash,
StashEmpty(String), StashEmpty(String),
StashBaseMismatch, StashBaseMismatch,
InvalidConfigKey(String),
Git(String),
RemoteNotFound(String),
RemoteAlreadyExists(String),
NothingToPull,
NotAGitRepo,
FastForwardOnly(String),
} }
impl fmt::Display for ArcError { impl fmt::Display for ArcError {
@ -81,6 +88,13 @@ impl fmt::Display for ArcError {
Self::StashBaseMismatch => { Self::StashBaseMismatch => {
write!(f, "stash base does not match current HEAD") 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}"),
} }
} }
} }
@ -111,4 +125,10 @@ impl From<rmp_serde::decode::Error> for ArcError {
} }
} }
impl From<git2::Error> for ArcError {
fn from(e: git2::Error) -> Self {
Self::Git(e.message().to_string())
}
}
pub type Result<T> = std::result::Result<T, ArcError>; pub type Result<T> = std::result::Result<T, ArcError>;

View file

@ -70,7 +70,11 @@ impl IgnoreRules {
} }
pub fn matches(&self, rel_path: &str, is_dir: bool) -> bool { 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; return true;
} }

View file

@ -1,3 +1,4 @@
pub mod bridge;
mod cli; mod cli;
pub mod config; pub mod config;
pub mod diff; pub mod diff;
@ -8,6 +9,7 @@ pub mod merge;
pub mod model; pub mod model;
pub mod modify; pub mod modify;
pub mod refs; pub mod refs;
pub mod remote;
pub mod repo; pub mod repo;
pub mod resolve; pub mod resolve;
pub mod stash; pub mod stash;

87
src/remote.rs Normal file
View file

@ -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<String, RemoteEntry>,
}
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<RemotesFile> {
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 ` <name>\t<url>\n`.
/// Returns an empty string if no remotes are configured.
pub fn list(repo: &Repository) -> Result<String> {
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)
}

View file

@ -29,7 +29,7 @@ fn scan_dir(root: &Path, dir: &Path, ignore: &IgnoreRules, tree: &mut FileTree)
let path = entry.path(); let path = entry.path();
let rel = to_rel_string(root, &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; continue;
} }

575
tests/bridge.rs Normal file
View file

@ -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"
);
}

View file

@ -100,8 +100,15 @@ fn config_show_subcommand_succeeds() {
#[test] #[test]
fn remote_list_subcommand_succeeds() { 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() let output = arc_cmd()
.args(["remote", "list"]) .args(["remote", "list"])
.current_dir(dir.path())
.output() .output()
.expect("failed to run arc"); .expect("failed to run arc");
assert!(output.status.success()); assert!(output.status.success());

View file

@ -5,14 +5,18 @@ fn arc_cmd() -> Command {
Command::new(env!("CARGO_BIN_EXE_arc")) Command::new(env!("CARGO_BIN_EXE_arc"))
} }
#[test] fn init_repo(dir: &TempDir) {
fn config_yml_is_valid_yaml() {
let dir = TempDir::new().unwrap();
arc_cmd() arc_cmd()
.arg("init") .arg("init")
.current_dir(dir.path()) .current_dir(dir.path())
.output() .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 config_path = dir.path().join(".arc").join("config.yml");
let contents = std::fs::read_to_string(&config_path).unwrap(); let contents = std::fs::read_to_string(&config_path).unwrap();
@ -23,11 +27,7 @@ fn config_yml_is_valid_yaml() {
#[test] #[test]
fn config_has_default_section() { fn config_has_default_section() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
arc_cmd() init_repo(&dir);
.arg("init")
.current_dir(dir.path())
.output()
.expect("failed to run arc");
let config_path = dir.path().join(".arc").join("config.yml"); let config_path = dir.path().join(".arc").join("config.yml");
let contents = std::fs::read_to_string(&config_path).unwrap(); let contents = std::fs::read_to_string(&config_path).unwrap();
@ -41,11 +41,7 @@ fn config_has_default_section() {
#[test] #[test]
fn head_is_valid_yaml() { fn head_is_valid_yaml() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
arc_cmd() init_repo(&dir);
.arg("init")
.current_dir(dir.path())
.output()
.expect("failed to run arc");
let head_path = dir.path().join(".arc").join("HEAD"); let head_path = dir.path().join(".arc").join("HEAD");
let contents = std::fs::read_to_string(&head_path).unwrap(); let contents = std::fs::read_to_string(&head_path).unwrap();
@ -58,14 +54,559 @@ fn head_is_valid_yaml() {
#[test] #[test]
fn bookmark_ref_is_valid_yaml() { fn bookmark_ref_is_valid_yaml() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
arc_cmd() init_repo(&dir);
.arg("init")
.current_dir(dir.path())
.output()
.expect("failed to run arc");
let bookmark_path = dir.path().join(".arc").join("bookmarks").join("main"); let bookmark_path = dir.path().join(".arc").join("bookmarks").join("main");
let contents = std::fs::read_to_string(&bookmark_path).unwrap(); let contents = std::fs::read_to_string(&bookmark_path).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap();
assert!(value["commit"].is_null()); 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");
}

151
tests/remote.rs Normal file
View file

@ -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);
}