Compare commits
No commits in common. "d7694737de323c22ae83ea6c51a887e5bbabf870" and "ea07d74457146495817b5c5ff6d8582aab7026ff" have entirely different histories.
d7694737de
...
ea07d74457
15 changed files with 50 additions and 2981 deletions
373
Cargo.lock
generated
373
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
870
src/bridge.rs
870
src/bridge.rs
|
|
@ -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<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, ¤t) {
|
||||
Ok(o) => o,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if obj.commit.parents.is_empty() {
|
||||
return false;
|
||||
}
|
||||
current = obj.commit.parents[0].clone();
|
||||
}
|
||||
}
|
||||
190
src/cli.rs
190
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<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 {
|
||||
let args: Vec<String> = 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)");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
163
src/config.rs
163
src/config.rs
|
|
@ -34,7 +34,6 @@ pub struct DefaultConfig {
|
|||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EffectiveConfig {
|
||||
pub user_name: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
|
|
@ -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<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 {
|
||||
pub fn invalid_path(msg: impl Into<String>) -> Self {
|
||||
Self::InvalidPath(msg.into())
|
||||
|
|
|
|||
20
src/error.rs
20
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<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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
575
tests/bridge.rs
575
tests/bridge.rs
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
579
tests/config.rs
579
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");
|
||||
}
|
||||
|
|
|
|||
151
tests/remote.rs
151
tests/remote.rs
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue