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