diff options
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | Cargo.lock | 590 | ||||
-rw-r--r-- | Cargo.toml | 15 | ||||
-rw-r--r-- | HACKING.md | 4 | ||||
-rw-r--r-- | ganarchy/__init__.py | 16 | ||||
-rw-r--r-- | ganarchy/__main__.py | 29 | ||||
-rw-r--r-- | ganarchy/cli/__init__.py | 27 | ||||
-rw-r--r-- | ganarchy/cli/db.py | 71 | ||||
-rw-r--r-- | ganarchy/cli/debug.py | 112 | ||||
-rw-r--r-- | ganarchy/cli/merge_configs.py | 25 | ||||
-rw-r--r-- | ganarchy/cli/run_targets.py | 231 | ||||
-rw-r--r-- | ganarchy/core.py | 286 | ||||
-rw-r--r-- | ganarchy/data.py | 565 | ||||
-rw-r--r-- | ganarchy/db.py | 369 | ||||
-rw-r--r-- | ganarchy/dirs.py | 53 | ||||
-rw-r--r-- | ganarchy/git.py | 173 | ||||
-rw-r--r-- | ganarchy/templating/__init__.py | 21 | ||||
-rw-r--r-- | ganarchy/templating/environment.py | 31 | ||||
-rw-r--r-- | ganarchy/templating/templates.py | 128 | ||||
-rw-r--r-- | ganarchy/templating/toml.py | 33 | ||||
-rw-r--r-- | index.js | 29 | ||||
-rw-r--r-- | requirements.txt | 12 | ||||
-rw-r--r-- | requirements_test.txt | 21 | ||||
-rw-r--r-- | setup.py | 5 | ||||
-rw-r--r-- | setup_env.bash | 3 | ||||
-rw-r--r-- | setup_testenv.bash | 4 | ||||
-rw-r--r-- | src/git.rs | 728 | ||||
-rw-r--r-- | src/git/tests.rs | 389 | ||||
-rw-r--r-- | src/lib.rs | 23 | ||||
-rw-r--r-- | src/marker.rs | 23 | ||||
-rw-r--r-- | src/test_util.rs | 19 | ||||
-rw-r--r-- | src/util.rs | 108 |
32 files changed, 1900 insertions, 2248 deletions
diff --git a/.gitignore b/.gitignore index d7ce2cc..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ -__pycache__/ -/public/ -/venv/ -/vtestenv/ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d570f62 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,590 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "aho-corasick" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" +dependencies = [ + "memchr", +] + +[[package]] +name = "ar" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "450575f58f7bee32816abbff470cbc47797397c2a81e0eaced4b98436daf52e1" + +[[package]] +name = "ascii" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf56136a5198c7b01a49e3afcbef6cf84597273d298f54432926024107b0109" + +[[package]] +name = "assert_fs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3203d5bb9979ac7210f01a150578ebafef6f08b55e79f6db32673c0977b94340" +dependencies = [ + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bstr" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a40b47ad93e1a5404e6c18dec46b628214fee441c70f4ab5d6942142cc268a3d" +dependencies = [ + "memchr", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "winapi", +] + +[[package]] +name = "chunked_transfer" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" + +[[package]] +name = "crossbeam-utils" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9d99fa91428effe99c5c6d4634cdeba32b8cf784fc428a2a687f61a952c49" +dependencies = [ + "autocfg", + "cfg-if", + "lazy_static", +] + +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "float-cmp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1267f4ac4f343772758f7b1bdcbe767c218bbab93bb432acbf5162bbf85a6c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "ganarchy" +version = "0.1.0" +dependencies = [ + "assert_fs", + "impl_trait", + "predicates", + "testserver", +] + +[[package]] +name = "getrandom" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "globset" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c152169ef1e421390738366d2f796655fec62621dabbd0fd476f905934061e4a" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "ignore" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b287fb45c60bb826a0dc68ff08742b9d88a2fea13d6e0c286b3172065aaf878c" +dependencies = [ + "crossbeam-utils", + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + +[[package]] +name = "impl_trait" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b644c423a283d855eefd08f9bcc97f8d2c4a4b8ed90d08a82b9b733cf6abd1c" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41" + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" + +[[package]] +name = "memchr" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "ppv-lite86" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" + +[[package]] +name = "predicates" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb433456c1a57cc93554dea3ce40b4c19c4057e41c55d4a0f3d84ea71c325aa" +dependencies = [ + "difference", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e35a3326b75e49aa85f5dc6ec15b41108cf5aee58eabb1f274dd18b73c2451" + +[[package]] +name = "predicates-tree" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f553275e5721409451eb85e15fd9a860a6e5ab4496eb215987502b5f5391f2" +dependencies = [ + "predicates-core", + "treeline", +] + +[[package]] +name = "proc-macro2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "syn" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9505f307c872bab8eb46f77ae357c8eba1fdacead58ee5a850116b1d7f82883" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "tempfile" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +dependencies = [ + "cfg-if", + "libc", + "rand", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "testserver" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1606ddcf7486efa0968089c2e6fa3416d4a5c768a3987e440d3a4747eafbc4b" +dependencies = [ + "ar", + "base64", + "percent-encoding", + "tiny_http", +] + +[[package]] +name = "thread_local" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tiny_http" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce51b50006056f590c9b7c3808c3bd70f0d1101666629713866c227d6e58d39" +dependencies = [ + "ascii", + "chrono", + "chunked_transfer", + "log", + "url", +] + +[[package]] +name = "tinyvec" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "treeline" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" + +[[package]] +name = "unicode-bidi" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9dd31ea --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ganarchy" +version = "0.1.0" +authors = ["SoniEx2 <endermoneymod@gmail.com>"] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +impl_trait = "0.1.3" + +[dev-dependencies] +assert_fs = "1.0.1" +predicates = "1.0" +testserver = "0.1.3" diff --git a/HACKING.md b/HACKING.md index cb64604..3372ea1 100644 --- a/HACKING.md +++ b/HACKING.md @@ -7,6 +7,8 @@ Project Structure Dependencies ------------ +<!-- --><!-- + `requirements.txt` lists known-good, frozen dependencies. if needed or desired, install dependencies listed in setup.py directly. @@ -30,6 +32,8 @@ install_requires=[ note however that not all forks are compatible with the project. requirements.txt provides known-good versions. +--><!-- --> + Input Validation ---------------- diff --git a/ganarchy/__init__.py b/ganarchy/__init__.py deleted file mode 100644 index fdb6788..0000000 --- a/ganarchy/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# GAnarchy - decentralized project hub -# Copyright (C) 2019, 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - diff --git a/ganarchy/__main__.py b/ganarchy/__main__.py deleted file mode 100644 index 63e55ed..0000000 --- a/ganarchy/__main__.py +++ /dev/null @@ -1,29 +0,0 @@ -# GAnarchy - decentralized project hub -# Copyright (C) 2019, 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -# The base CLI -import ganarchy.cli - -# FIXME this shouldn't be here -import ganarchy - -# Additional CLI commands -import ganarchy.cli.db -import ganarchy.cli.debug -import ganarchy.cli.merge_configs -import ganarchy.cli.run_targets - -ganarchy.cli.main(prog_name='ganarchy') diff --git a/ganarchy/cli/__init__.py b/ganarchy/cli/__init__.py deleted file mode 100644 index 727c48d..0000000 --- a/ganarchy/cli/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2019 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""The GAnarchy CLI. - -This module just defines the main command group. Submodules define -actual commands. -""" - -import click - -@click.group() -def main(): - pass diff --git a/ganarchy/cli/db.py b/ganarchy/cli/db.py deleted file mode 100644 index e754c34..0000000 --- a/ganarchy/cli/db.py +++ /dev/null @@ -1,71 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Database-related CLI commands. - -""" - -import os - -import click - -import ganarchy.cli -import ganarchy.data -import ganarchy.db -import ganarchy.dirs - -@ganarchy.cli.main.command() -def initdb(): - """Initializes the ganarchy database.""" - # TODO: makedirs in a separate command? - os.makedirs(ganarchy.dirs.DATA_HOME, exist_ok=True) - db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default()) - db.initialize() - db.close() - -@ganarchy.cli.main.group() -def migrations(): - """Modifies the DB to work with a newer/older version. - - WARNING: THIS COMMAND CAN BE EXTREMELY DESTRUCTIVE!""" - -@migrations.command() -@click.argument('migration') -def apply(migration): - """Applies the migration with the given name.""" - db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default()) - click.echo(ganarchy.db.MIGRATIONS[migration][0]) - db.apply_migration(migration) - db.close() - -@click.argument('migration') -@migrations.command() -def revert(migration): - """Reverts the migration with the given name.""" - db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default()) - click.echo(ganarchy.db.MIGRATIONS[migration][1]) - db.revert_migration(migration) - db.close() - -@click.argument('migration', required=False) -@migrations.command() -def info(migration): - """Shows information about the migration with the given name.""" - if not migration: - # TODO could be improved - click.echo(ganarchy.db.MIGRATIONS.keys()) - else: - click.echo(ganarchy.db.MIGRATIONS[migration][2]) diff --git a/ganarchy/cli/debug.py b/ganarchy/cli/debug.py deleted file mode 100644 index 95ad045..0000000 --- a/ganarchy/cli/debug.py +++ /dev/null @@ -1,112 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2019 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -import click -import qtoml - -import ganarchy -import ganarchy.cli -import ganarchy.data - -@ganarchy.cli.main.group() -def debug(): - pass - -@debug.command() -def paths(): - click.echo('Config home: {}'.format(ganarchy.config_home)) - click.echo('Additional config search path: {}'.format(ganarchy.config_dirs)) - click.echo('Cache home: {}'.format(ganarchy.cache_home)) - click.echo('Data home: {}'.format(ganarchy.data_home)) - -def print_data_source(data_source): - if ganarchy.data.DataProperty.INSTANCE_TITLE in data_source.get_supported_properties(): - try: - title = data_source.get_property_value(ganarchy.data.DataProperty.INSTANCE_TITLE) - except LookupError: - title = None - click.echo("\tTitle: {}".format(title)) - - if ganarchy.data.DataProperty.INSTANCE_BASE_URL in data_source.get_supported_properties(): - try: - base_url = data_source.get_property_value(ganarchy.data.DataProperty.INSTANCE_BASE_URL) - except LookupError: - base_url = None - click.echo("\tBase URL: {}".format(base_url)) - - if ganarchy.data.DataProperty.REPO_LIST_SOURCES in data_source.get_supported_properties(): - click.echo("\tRepo list sources:") - try: - iterator = data_source.get_property_values(ganarchy.data.DataProperty.REPO_LIST_SOURCES) - except LookupError: - click.echo("\t\tNone") - else: - for i, rls in enumerate(iterator, 1): - click.echo("\t\t{}.".format(i)) - click.echo("\t\t\tURI: {}".format(rls.uri)) - click.echo("\t\t\tOptions: {}".format(rls.options)) - click.echo("\t\t\tActive: {}".format(rls.active)) - - if ganarchy.data.DataProperty.VCS_REPOS in data_source.get_supported_properties(): - click.echo("\tRepos:") - try: - iterator = data_source.get_property_values(ganarchy.data.DataProperty.VCS_REPOS) - except LookupError: - click.echo("\t\tNone") - else: - for i, pctp in enumerate(iterator, 1): - click.echo("\t\t{}.".format(i)) - click.echo("\t\t\tProject: {}".format(pctp.project_commit)) - click.echo("\t\t\tURI: {}".format(pctp.uri)) - click.echo("\t\t\tBranch: {}".format(pctp.branch)) - click.echo("\t\t\tOptions: {}".format(pctp.options)) - click.echo("\t\t\tActive: {}".format(pctp.active)) - -@debug.command() -def configs(): - confs = ganarchy.data.ConfigManager.new_default() - click.echo("Configs (raw): {}".format(confs.sources)) - click.echo("Breaking down the configs.") - update_excs = confs.update() - for conf, exc in zip(reversed(confs.sources), reversed(update_excs)): - click.echo("Config: {}".format(conf)) - if exc is not None: - click.echo("\tError(s): {}".format(exc)) - if conf.exists(): - print_data_source(conf) - click.echo("ConfigManager (raw):") - print_data_source(confs) - click.echo("ConfigManager (effective):") - print_data_source(ganarchy.data.EffectiveSource(confs)) - -@debug.command() -def repo_lists(): - confs = ganarchy.data.ConfigManager.new_default() - repo_lists = ganarchy.data.RepoListManager(confs) - update_excs = repo_lists.update() - click.echo("Repo lists (raw): {}".format(repo_lists.sources)) - click.echo("Breaking down the repo lists.") - for repo_list, exc in zip(reversed(repo_lists.sources), reversed(update_excs)): - click.echo("Repo list: {}".format(repo_list)) - if exc is not None: - click.echo("\tError(s): {}".format(exc)) - if repo_list.exists(): - print_data_source(repo_list) - click.echo("RepoListManager (raw):") - print_data_source(repo_lists) - click.echo("RepoListManager (effective):") - print_data_source(ganarchy.data.EffectiveSource(repo_lists)) - diff --git a/ganarchy/cli/merge_configs.py b/ganarchy/cli/merge_configs.py deleted file mode 100644 index d8e12e6..0000000 --- a/ganarchy/cli/merge_configs.py +++ /dev/null @@ -1,25 +0,0 @@ -import pathlib - -import click - -import ganarchy -import ganarchy.cli -import ganarchy.data - -@ganarchy.cli.main.command() -@click.option('--skip-errors/--no-skip-errors', default=False) -@click.argument('files', type=click.Path(exists=True, dir_okay=False, resolve_path=True), nargs=-1) -def merge_configs(skip_errors, files): - """Merges config files.""" - configs = [ganarchy.data.LocalDataSource(filename) for filename in files] - rlm = ganarchy.data.RepoListManager(ganarchy.data.ObjectDataSource({})) - rlm.sources += configs - res = [] - for src in rlm.sources: - res.append(src.update()) - effective = ganarchy.data.EffectiveSource(rlm) - if any(x is None for x in res): - click.echo("# This is DEPRECATED and will be REMOVED at some point!") - for pctp in effective.get_property_values(ganarchy.data.DataProperty.VCS_REPOS): - if pctp.active: - click.echo(f"""projects."{ganarchy.tomlescape(pctp.project_commit)}"."{ganarchy.tomlescape(pctp.uri)}"."{ganarchy.tomlescape(pctp.branch)}" = {{ active=true }}""") diff --git a/ganarchy/cli/run_targets.py b/ganarchy/cli/run_targets.py deleted file mode 100644 index 24497b9..0000000 --- a/ganarchy/cli/run_targets.py +++ /dev/null @@ -1,231 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""This module contains the CLI Run Targets. -""" - -import os -import shutil - -import click - -from ganarchy import cli -from ganarchy import core -from ganarchy import data -from ganarchy import db -from ganarchy import dirs -from ganarchy.templating import environment - -@cli.main.command() -@click.option('--keep-stale-projects/--no-keep-stale-projects', default=True) -@click.argument('out', required=True, type=click.Path(file_okay=False, resolve_path=True)) -def run_once(out, keep_stale_projects): - """Runs GAnarchy once. - - Processes any necessary updates and updates the output directory to match. - """ -# """Runs ganarchy standalone. -# -# This will run ganarchy so it regularly updates the output directory given -# by OUT. Additionally, it'll also search for the following hooks in its -# config dirs: -# -# - post_object_update_hook - executed after an object is updated. -# -# - post_update_cycle_hook - executed after all objects in an update -# cycle are updated. -# """ - # create config objects - conf = data.ConfigManager.new_default() - effective_conf = data.EffectiveSource(conf) - repos = data.RepoListManager(effective_conf) - effective_repos = data.EffectiveSource(repos) - - # create dir if it doesn't exist - os.makedirs(out, exist_ok=True) - - # load template environment - env = environment.get_env() - - # make sure the cache dir exists - os.makedirs(dirs.CACHE_HOME, exist_ok=True) - - # make sure it is a git repo - core.GIT.create() - - if True: - # reload config and repo data - effective_repos.update() - database = db.connect_database(effective_conf) - database.load_repos(effective_repos) - - instance = core.GAnarchy(database, effective_conf) - - if not instance.base_url: - click.echo("No base URL specified", err=True) - return - - instance.load_projects() - - # update and render projects - if not keep_stale_projects: - shutil.rmtree(out + "/project") - - os.makedirs(out + "/project", exist_ok=True) - - template_project = env.get_template('project.html') - for p in instance.projects: - p.load_repos() - - generate_html = [] - results = p.update() - #if not p.exists: - # ... - for (repo, count) in results: - if count is not None: - generate_html.append( - (repo.url, repo.message, count, repo.branch) - ) - else: - click.echo(repo.errormsg, err=True) - html_entries = [] - for (url, msg, count, branch) in generate_html: - history = database.list_repobranch_activity(p.commit, url, branch) - # TODO process history into SVG - # TODO move this into a separate system - # (e.g. ``if project.startswith("svg-"):``) - html_entries.append((url, msg, "", branch)) - - os.makedirs(out + "/project/" + p.commit, exist_ok=True) - - with open(out + "/project/" + p.commit + "/index.html", "w") as f: - template_project.stream( - project_title = p.title, - project_desc = p.description, - project_body = p.commit_body, - project_commit = p.commit, - repos = html_entries, - base_url = instance.base_url, - # I don't think this thing supports deprecating the above? - project = p, - ganarchy = instance - ).dump(f) - - # render the config - template = env.get_template('index.toml') - with open(out + "/index.toml", "w") as f: - template.stream(database=database).dump(f) - - # render the index - # but reload projects first to pick up sorting order - # (new projects don't get sorted until their repos get fetched for the - # first time, because that's where the metadata is stored) - # FIXME .sort_projects()? - instance.load_projects() - template = env.get_template('index.html') - with open(out + "/index.html", "w") as f: - template.stream(ganarchy=instance).dump(f) - - -@cli.main.command() -@click.option('--dry-run/--no-dry-run', '--no-update/--update', default=False) -@click.argument('project', required=False) -def cron_target(dry_run, project): - """Runs ganarchy as a cron target. - - "Deprecated". Useful if you want full control over how GAnarchy - generates the pages. - """ - # create config objects - conf = data.ConfigManager.new_default() - effective_conf = data.EffectiveSource(conf) - repos = data.RepoListManager(effective_conf) - effective_repos = data.EffectiveSource(repos) - - # load config and repo data - effective_repos.update() - database = db.connect_database(effective_conf) - database.load_repos(effective_repos) - - # load template environment - env = environment.get_env() - - # handle config and project list - if project == "config": - # render the config - template = env.get_template('index.toml') - click.echo(template.render(database=database), nl=False) - return - if project == "project-list": - # could be done with a template but eh w/e, this is probably better - for project in database.list_projects(): - click.echo(project) - return - - # make sure the cache dir exists - os.makedirs(dirs.CACHE_HOME, exist_ok=True) - - # make sure it is a git repo - core.GIT.create() - - instance = core.GAnarchy(database, effective_conf) - - if not instance.base_url or not project: - click.echo("No base URL or project commit specified", err=True) - return - - if project == "index": - instance.load_projects() - # render the index - template = env.get_template('index.html') - click.echo(template.render(ganarchy=instance), nl=False) - return - - p = core.Project(database, project) - p.load_repos() - - generate_html = [] - results = p.update(dry_run=dry_run) - #if not p.exists: - # ... - for (repo, count) in results: - if count is not None: - generate_html.append((repo.url, repo.message, count, repo.branch)) - else: - click.echo(repo.errormsg, err=True) - html_entries = [] - for (url, msg, count, branch) in generate_html: - history = database.list_repobranch_activity(project, url, branch) - # TODO process history into SVG - # TODO move this into a separate system - # (e.g. ``if project.startswith("svg-"):``) - html_entries.append((url, msg, "", branch)) - - template = env.get_template('project.html') - click.echo( - template.render( - project_title = p.title, - project_desc = p.description, - project_body = p.commit_body, - project_commit = p.commit, - repos = html_entries, - base_url = instance.base_url, - # I don't think this thing supports deprecating the above? - project = p, - ganarchy = instance - ), - nl=False - ) diff --git a/ganarchy/core.py b/ganarchy/core.py deleted file mode 100644 index b1025d1..0000000 --- a/ganarchy/core.py +++ /dev/null @@ -1,286 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Core logic of GAnarchy. -""" - -import hashlib -import hmac -import re -from urllib import parse - -import ganarchy.git -import ganarchy.dirs -import ganarchy.data - -# Currently we only use one git repo, at CACHE_HOME -# TODO optimize -GIT = ganarchy.git.Git(ganarchy.dirs.CACHE_HOME) - -class Repo: - """A GAnarchy repo. - - Args: - dbconn (ganarchy.db.Database): The database connection. - project_commit (str): The project commit. - url (str): The git URL. - branch (str): The branch. - head_commit (str): The last known head commit. - - Attributes: - branch (str or None): The remote git branch. - branchname (str): The local git branch. - """ - # TODO fill in Attributes. - - def __init__(self, dbconn, project_commit, url, branch, head_commit): - self.url = url - self.branch = branch - self.project_commit = project_commit - self.errormsg = None - self.erroring = False - self.message = None - self.hash = None - self.branchname = None - self.head = None - - if not self._check_branch(): - return - - if not branch: - self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest() - self.head = "HEAD" - else: - self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest() - self.head = "refs/heads/" + branch - - if head_commit: - self.hash = head_commit - else: - try: # FIXME should we even do this? - self.hash = GIT.get_hash(self.branchname) - except ganarchy.git.GitError: - self.erroring = True - - self.refresh_metadata() - - def _check_branch(self): - """Checks if ``self.branch`` is a valid git branch name, or None. Sets - ``self.errormsg`` and ``self.erroring`` accordingly. - - Returns: - bool: True if valid, False otherwise. - """ - if not self.branch: - return True - try: - GIT.check_branchname(self.branch) - return True - except ganarchy.git.GitError as e: - self.erroring = True - self.errormsg = e - return False - - def refresh_metadata(self): - """Refreshes repo metadata. - """ - if not self._check_branch(): - return - try: - self.message = GIT.get_commit_message(self.branchname) - except ganarchy.git.GitError as e: - self.erroring = True - self.errormsg = e - - # FIXME maybe this shouldn't be "public"? - # reasoning: this update() isn't reflected in the db. - # but this might be handy for dry runs. - # alternatively: change the return to be the new head commit, - # and update things accordingly. - def update(self, *, dry_run=False): - """Updates the git repo, returning a commit count. - - Args: - dry_run (bool): To simulate an update without doing anything. - In particular, without fetching commits. - """ - if not self._check_branch(): - return None - if not dry_run: - try: - GIT.force_fetch(self.url, self.head, self.branchname) - except ganarchy.git.GitError as e: - # This may error for various reasons, but some - # are important: dead links, etc - self.erroring = True - self.errormsg = e - return None - pre_hash = self.hash - try: - post_hash = GIT.get_hash(self.branchname) - except ganarchy.git.GitError as e: - # This should never happen, but maybe there's some edge cases? - # TODO check - self.erroring = True - self.errormsg = e - return None - self.hash = post_hash - if not pre_hash: - pre_hash = post_hash - count = GIT.get_count(pre_hash, post_hash) - try: - GIT.check_history(self.branchname, self.project_commit) - self.refresh_metadata() - return count - except ganarchy.git.GitError as e: - self.erroring = True - self.errormsg = e - return None - -class Project: - """A GAnarchy project. - - Args: - dbconn (ganarchy.db.Database): The database connection. - project_commit (str): The project commit. - - Attributes: - commit (str): The project commit. - repos (list, optional): Repos associated with this project. - title (str, optional): Title of the project. - description (str, optional): Description of the project. - commit_body (str, optional): Raw commit message for title and - description. - exists (bool): Whether the project exists in our git cache. - """ - - def __init__(self, dbconn, project_commit): - self.commit = project_commit - self.refresh_metadata() - self.repos = None - self._dbconn = dbconn - - def load_repos(self): - """Loads the repos into this project. - - If repos have already been loaded, re-loads them. - """ - repos = [] - for url, branch, head_commit in self._dbconn.list_repobranches(self.commit): - repos.append( - Repo(self._dbconn, self.commit, url, branch, head_commit) - ) - self.repos = repos - - def refresh_metadata(self): - """Refreshes project metadata. - """ - try: - project = GIT.get_commit_message(self.commit) - project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE)) - if not project_title.strip(): # FIXME - project_title, project_desc = ("Error parsing project commit",)*2 - # if project_desc: # FIXME - # project_desc = project_desc.strip() - self.exists = True - self.commit_body = project - self.title = project_title - self.description = project_desc - except ganarchy.git.GitError: - self.exists = False - self.commit_body = None - self.title = None - self.description = None - - def update(self, *, dry_run=False): - """Updates the project and its repos. - """ - # TODO? check if working correctly - results = [] - if self.repos is not None: - for repo in self.repos: - results.append((repo, repo.update(dry_run=dry_run))) - self.refresh_metadata() - if self.repos is not None: - results.sort(key=lambda x: x[1] or -1, reverse=True) - if not dry_run: - entries = [] - for (repo, count) in results: - if count is not None: - entries.append(( - self.commit, - repo.url, - repo.branch, - repo.hash, - count - )) - self._dbconn.insert_activities(entries) - return results - -class GAnarchy: - """A GAnarchy instance. - - Args: - dbconn (ganarchy.db.Database): The database connection. - config (ganarchy.data.DataSource): The (effective) config. - - Attributes: - base_url (str): Instance base URL. - title (str): Instance title. - projects (list, optional): Projects associated with this instance. - """ - - def __init__(self, dbconn, config): - self.title = None - self.base_url = None - self.projects = None - self._dbconn = dbconn - self._config = config - self.load_metadata() - - def load_metadata(self): - """Loads instance metadata from config. - - If instance metadata has already been loaded, re-loads it. - """ - try: - base_url = self._config.get_property_value( - ganarchy.data.DataProperty.INSTANCE_BASE_URL - ) - except LookupError: - # FIXME use a more appropriate error type - raise ValueError - - try: - title = self._config.get_property_value( - ganarchy.data.DataProperty.INSTANCE_TITLE - ) - except LookupError: - title = "GAnarchy on " + parse.urlparse(base_url).hostname - - self.title = title - self.base_url = base_url - - def load_projects(self): - """Loads the projects into this GAnarchy instance. - - If projects have already been loaded, re-loads them. - """ - projects = [] - for project in self._dbconn.list_projects(): - projects.append(Project(self._dbconn, project)) - projects.sort(key=lambda p: p.title or "") # sort projects by title - self.projects = projects diff --git a/ganarchy/data.py b/ganarchy/data.py deleted file mode 100644 index 36c32d9..0000000 --- a/ganarchy/data.py +++ /dev/null @@ -1,565 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2019, 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""This module handles GAnarchy's data and config sources. - -A data source can be either a config source or a repo list source, but be -careful: they use identical syntax, but have different semantics! Mistaking -a repo list source for a config source is a recipe for security bugs! -""" - -import abc -import itertools -import os -import re -import time - -import abdl -import abdl.exceptions -import qtoml -import requests - -from enum import Enum -from urllib.parse import urlparse - -import ganarchy.dirs - -# TODO move elsewhere -class URIPredicate(abdl.predicates.Predicate): - def __init__(self, ports=range(1,65536), schemes=('https',)): - self.ports = ports - self.schemes = schemes - - def accept(self, obj): - try: - u = urlparse(obj) - if not u: - return False - # also raises for invalid ports, see https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlparse - # "Reading the port attribute will raise a ValueError if an invalid port is specified in the URL. [...]" - if u.port is not None and u.port not in self.ports: - return False - if u.scheme not in self.schemes: - return False - except ValueError: - return False - return True - -class CommitPredicate(abdl.predicates.Predicate): - def __init__(self, sha256ready=True): - if sha256ready: - self.re = re.compile(r"^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$") - else: - self.re = re.compile(r"^[0-9a-fA-F]{40}$") - - def accept(self, obj): - return self.re.match(obj) - -# sanitize = skip invalid entries -# validate = error on invalid entries -# LEGACY. DO NOT USE. -# TODO remove -CONFIG_REPOS_SANITIZE = abdl.compile("""->'projects'?:?$dict - ->commit[:?$str:?$commit]:?$dict - ->url[:?$str:?$uri]:?$dict - ->branch:?$dict(->'active'?:?$bool)""", - dict(bool=bool, dict=dict, str=str, uri=URIPredicate(), commit=CommitPredicate())) - -CONFIG_TITLE_SANITIZE = abdl.compile("""->title'title'?:?$str""", dict(str=str)) -CONFIG_BASE_URL_SANITIZE = abdl.compile("""->base_url'base_url'?:?$str:?$uri""", dict(str=str, uri=URIPredicate())) - -# modern matchers, raise ValidationError if the data doesn't exist. -# they still skip "bad" entries, just like the old matchers. - -_MATCHER_REPOS = abdl.compile("""->'projects':$dict - ->commit[:?$str:?$commit]:?$dict - ->url[:?$str:?$uri]:?$dict - ->branch:?$dict - (->active'active'?:?$bool) - (->federate'federate'?:?$bool)?""", - dict(bool=bool, dict=dict, str=str, uri=URIPredicate(), commit=CommitPredicate())) -_MATCHER_REPO_LIST_SRCS = abdl.compile("""->'repo_list_srcs':$dict - ->src[:?$str:?$uri]:?$dict - (->'active'?:?$bool)""", - dict(bool=bool, list=list, dict=dict, str=str, uri=URIPredicate(schemes=('https','file',)))) -# TODO -#_MATCHER_ALIASES = abdl.compile("""->'project_settings':$dict -# ->commit/[0-9a-fA-F]{40}|[0-9a-fA-F]{64}/?:?$dict -# """, {'dict': dict}) # FIXME check for aliases, might require changes to abdl - -# TODO -#_MATCHER_URI_FILTERS = abdl.compile("""->'uri_filters':$dict -# ->filter[:?$str]:?$dict -# (->'active'?:?$bool)""", -# dict(dict=dict, str=str, bool=bool)) - -_MATCHER_TITLE = abdl.compile("""->title'title':$str""", dict(str=str)) -_MATCHER_BASE_URL = abdl.compile("""->base_url'base_url':$str:$uri""", dict(str=str, uri=URIPredicate())) - -class OverridableProperty(abc.ABC): - """An overridable property, with options. - - Attributes: - options (dict): Options. - """ - - @abc.abstractmethod - def as_key(self): - """Returns an opaque representation of this OverridablePRoperty - suitable for use as a dict key. - - The returned object is not suitable for other purposes. - """ - return () - - @property - def active(self): - """Whether this property is active. - """ - return self.options.get('active', False) - -class PCTP(OverridableProperty): - """A Project Commit-Tree Path. - - Attributes: - project_commit (str): The project commit. - uri (str): The URI of a fork of the project. - branch (str): The branch name, or None for the default branch. - options (dict): A dict of fork-specific options. - """ - - def __init__(self, project_commit, uri, branch, options): - self.project_commit = project_commit - self.uri = uri - if branch == "HEAD": - self.branch = None - else: - self.branch = branch or None - self.options = options - - def as_key(self): - return (self.project_commit, self.uri, self.branch, ) - - @property - def federate(self): - return self.options.get('federate', True) - -class RepoListSource(OverridableProperty): - """A source for a repo list. - - Attributes: - uri (str): The URI of the repo list. - options (dict): A dict of repo list-specific options. - """ - - def __init__(self, uri, options): - self.uri = uri - self.options = options - - def as_key(self): - return (self.uri, ) - -class DataProperty(Enum): - """Represents values that can be returned by a data source. - - See documentation for DataSource get_property_value and - DataSource get_property_values for more details. - """ - INSTANCE_TITLE = (1, str) - INSTANCE_BASE_URL = (2, str) - VCS_REPOS = (3, PCTP) - REPO_LIST_SOURCES = (4, RepoListSource) - - def get_type(self): - """Returns the expected type for values from this DataProperty. - """ - return self.value[1] - -class PropertyError(LookupError): - """Raised to indicate improper use of a DataProperty. - """ - pass - -class DataSource(abc.ABC): - @abc.abstractmethod - def update(self): - """Refreshes the data associated with this source, if necessary. - """ - pass - - @abc.abstractmethod - def exists(self): - """Returns whether this source has usable data. - """ - pass - - @abc.abstractmethod - def get_supported_properties(self): - """Returns an iterable of properties supported by this data source. - - Returns: - Iterable of DataProperty: Supported properties. - - """ - return () - - def get_property_value(self, prop): - """Returns the value associated with the given property. - - If duplicated, an earlier value should override a later value. - - Args: - prop (DataProperty): The property. - - Returns: - The value associated with the given property. - - Raises: - PropertyError: If the property is not supported by this data - source. - LookupError: If the property is supported, but isn't available. - ValueError: If the property doesn't have exactly one value. - """ - iterator = self.get_property_values(prop) - try: - # note: unpacking - ret, = iterator - except LookupError as exc: raise RuntimeError from exc # don't accidentally swallow bugs in the iterator - return ret - - @abc.abstractmethod - def get_property_values(self, prop): - """Yields the values associated with the given property. - - If duplicated, earlier values should override later values. - - Args: - prop (DataProperty): The property. - - Yields: - The values associated with the given property. - - Raises: - PropertyError: If the property is not supported by this data - source. - LookupError: If the property is supported, but isn't available. - - """ - raise PropertyError - -class DummyDataSource(DataSource): - """A DataSource that provides nothing. - """ - -class ObjectDataSource(DataSource): - """A DataSource backed by a Python object. - - Updates to the backing object will be immediately reflected in this - DataSource. - """ - _SUPPORTED_PROPERTIES = { - DataProperty.INSTANCE_TITLE: lambda obj: (d['title'][1] for d in _MATCHER_TITLE.match(obj)), - DataProperty.INSTANCE_BASE_URL: lambda obj: (d['base_url'][1] for d in _MATCHER_BASE_URL.match(obj)), - DataProperty.VCS_REPOS: lambda obj: (PCTP(r['commit'][0], r['url'][0], r['branch'][0], {k: v[1] for k, v in r.items() if k in {'active', 'federate'}}) for r in _MATCHER_REPOS.match(obj)), - DataProperty.REPO_LIST_SOURCES: lambda obj: (RepoListSource(d['src'][0], d['src'][1]) for d in _MATCHER_REPO_LIST_SRCS.match(obj)), - } - - def __init__(self, obj): - self._obj = obj - - def update(self): - pass - - def exists(self): - return True - - def get_property_values(self, prop): - try: - factory = self.get_supported_properties()[prop] - except KeyError as exc: raise PropertyError from exc - iterator = factory(self._obj) - try: - first = next(iterator) - except StopIteration: return (x for x in ()) - except abdl.exceptions.ValidationError as exc: raise LookupError from exc - except LookupError as exc: raise RuntimeError from exc # don't accidentally swallow bugs in the iterator - return itertools.chain([first], iterator) - - @classmethod - def get_supported_properties(cls): - return cls._SUPPORTED_PROPERTIES - -class LocalDataSource(ObjectDataSource): - def __init__(self, filename): - super().__init__({}) - self.file_exists = False - self.last_updated = None - self.filename = filename - - def update(self): - try: - updtime = self.last_updated - self.last_updated = os.stat(self.filename).st_mtime - if not self.file_exists or updtime != self.last_updated: - with open(self.filename, 'r', encoding='utf-8', newline='') as f: - self._obj = qtoml.load(f) - self.file_exists = True - except (OSError, UnicodeDecodeError, qtoml.decoder.TOMLDecodeError) as e: - self.file_exists = False - self.last_updated = None - self._obj = {} - return e - - def exists(self): - return self.file_exists - - def __repr__(self): - return "LocalDataSource({!r})".format(self.filename) - -class RemoteDataSource(ObjectDataSource): - def __init__(self, uri): - super().__init__({}) - self.uri = uri - self.remote_exists = False - self.next_update = 0 - - def update(self): - if self.next_update > time.time(): - return - # I long for the day when toml has a registered media type - response = requests.get(self.uri, headers={'user-agent': 'ganarchy/0.0.0', 'accept': '*/*'}) - self.remote_exists = response.status_code == 200 - seconds = 3600 - if (refresh := response.headers.get('Refresh', None)) is not None: - try: - seconds = int(refresh) - except ValueError: - refresh = refresh.split(';', 1) - try: - seconds = int(refresh[0]) - except ValueError: - pass - self.next_update = time.time() + seconds - if self.remote_exists: - response.encoding = 'utf-8' - try: - self._obj = qtoml.loads(response.text) - except (UnicodeDecodeError, qtoml.decoder.TOMLDecodeError) as e: - self._obj = {} - return e - else: - return response - - def exists(self): - return self.remote_exists - - def __repr__(self): - return "RemoteDataSource({!r})".format(self.uri) - -class DefaultsDataSource(ObjectDataSource): - """Provides a way for contributors to define/encourage some default - settings. - - In particular, enables contributors to have a say in default domain - blocks. - """ - DEFAULTS = {} - - def __init__(self): - super().__init__(self.DEFAULTS) - - def exists(self): - return True - - def update(self): - return - - def __repr__(self): - return "DefaultsDataSource()" - - -class ConfigManager(DataSource): - """A ConfigManager takes care of managing config sources and - collecting their details. - - Args: - sources (list of DataSource): The config sources to be managed. - """ - def __init__(self, sources): - self.sources = sources - - @classmethod - def new_default(cls): - srcs = [LocalDataSource(d + "/config.toml") for d in [ganarchy.dirs.CONFIG_HOME] + ganarchy.dirs.CONFIG_DIRS] - return cls(srcs + [DefaultsDataSource()]) - - def exists(self): - return True - - def update(self): - excs = [] - for source in self.sources: - excs.append(source.update()) - return excs - - def get_supported_properties(self): - return DataProperty - - def get_property_values(self, prop): - if prop not in self.get_supported_properties(): - raise PropertyError - elif prop == DataProperty.VCS_REPOS: - return self._get_vcs_repos() - elif prop == DataProperty.REPO_LIST_SOURCES: - return self._get_repo_list_sources() - else: - # short-circuiting, as these are only supposed to return a single value - for source in self.sources: - try: - return source.get_property_values(prop) - except PropertyError: - pass - except LookupError: - pass - raise LookupError - - def _get_vcs_repos(self): - for source in self.sources: - if DataProperty.VCS_REPOS in source.get_supported_properties(): - try: - iterator = source.get_property_values(DataProperty.VCS_REPOS) - except LookupError: - pass - else: - yield from iterator - - def _get_repo_list_sources(self): - for source in self.sources: - if DataProperty.REPO_LIST_SOURCES in source.get_supported_properties(): - try: - iterator = source.get_property_values(DataProperty.REPO_LIST_SOURCES) - except LookupError: - pass - else: - yield from iterator - -class RepoListManager(DataSource): - """A RepoListManager takes care of managing repo lists. - - Args: - config_manager (DataSource): The config manager from which the repo - lists come. - """ - def __init__(self, config_manager): - self.config_manager = EffectiveSource(config_manager) - self.sources = [self.config_manager] - - def exists(self): - return True - - def update(self): - excs = [self.config_manager.update()] - if DataProperty.REPO_LIST_SOURCES in self.config_manager.get_supported_properties(): - self.sources = [self.config_manager] - try: - it = self.config_manager.get_property_values(DataProperty.REPO_LIST_SOURCES) - except LookupError: - pass - else: - self.sources.extend(RemoteDataSource(rls.uri) for rls in it if rls.active) - for source in self.sources[1:]: - excs.append(source.update()) - return excs - - def get_supported_properties(self): - return {DataProperty.VCS_REPOS} - - def get_property_values(self, prop): - if prop not in self.get_supported_properties(): - raise PropertyError - assert prop == DataProperty.VCS_REPOS - return self._get_vcs_repos() - - def _get_vcs_repos(self): - assert self.config_manager == self.sources[0] - try: - # config manager may override repo lists - iterator = self.config_manager.get_property_values(DataProperty.VCS_REPOS) - except (PropertyError, LookupError): - pass - else: - yield from iterator - for source in self.sources: - if DataProperty.VCS_REPOS in source.get_supported_properties(): - try: - iterator = source.get_property_values(DataProperty.VCS_REPOS) - except LookupError: - pass - else: - for pctp in iterator: - # but repo lists aren't allowed to override anything - try: - del pctp.options['federate'] - except KeyError: - pass - if pctp.active: - yield pctp - -class EffectiveSource(DataSource): - """Wraps another ``DataSource`` and yields "unique" results suitable - for general use. - - Methods on this class, in particular ``get_property_values``, handle - ``OverridableProperty`` overrides both to avoid code duplication and - so the user doesn't have to. - - Args: - raw_source (DataSource): The raw backing source. - """ - def __init__(self, raw_source): - self.raw_source = raw_source - - def exists(self): - return self.raw_source.exists() - - def update(self): - return self.raw_source.update() - - def get_property_value(self, prop): - return self.raw_source.get_property_value(prop) - - def get_supported_properties(self): - return self.raw_source.get_supported_properties() - - def get_property_values(self, prop): - # must raise exceptions *now* - # not when the generator runs - return self._wrap_values(prop, self.raw_source.get_property_values(prop)) - - def _wrap_values(self, prop, it): - if issubclass(prop.get_type(), OverridableProperty): - seen = {} - for v in it: - k = v.as_key() - if k in seen: - continue - seen[k] = v - yield v - else: - yield from it - - def __repr__(self): - return "EffectiveSource({!r})".format(self.raw_source) diff --git a/ganarchy/db.py b/ganarchy/db.py deleted file mode 100644 index b7aa29b..0000000 --- a/ganarchy/db.py +++ /dev/null @@ -1,369 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""This module handles GAnarchy's database. - -Attributes: - MIGRATIONS: Migrations. -""" - -import sqlite3 - -import ganarchy.dirs -import ganarchy.data - -# FIXME this should not be used directly because it's a pain. -MIGRATIONS = { - "toml-config": ( - ( - '''UPDATE "repo_history" - SET "project" = (SELECT "git_commit" FROM "config") - WHERE "project" IS NULL''', - - '''ALTER TABLE "repos" - RENAME TO "repos_old"''', - ), - ( - '''UPDATE "repo_history" - SET "project" = NULL - WHERE "project" = (SELECT "git_commit" FROM "config")''', - - '''ALTER TABLE "repos_old" - RENAME TO "repos"''', - ), - "switches to toml config format. the old 'repos' " #cont - "table is preserved as 'repos_old'" - ), - "better-project-management": ( - ( - '''ALTER TABLE "repos" - ADD COLUMN "branch" TEXT''', - - '''ALTER TABLE "repos" - ADD COLUMN "project" TEXT''', - - '''CREATE UNIQUE INDEX "repos_url_branch_project" - ON "repos" ("url", "branch", "project")''', - - '''CREATE INDEX "repos_project" - ON "repos" ("project")''', - - '''ALTER TABLE "repo_history" - ADD COLUMN "branch" TEXT''', - - '''ALTER TABLE "repo_history" - ADD COLUMN "project" TEXT''', - - '''CREATE INDEX "repo_history_url_branch_project" - ON "repo_history" ("url", "branch", "project")''', - ), - ( - '''DELETE FROM "repos" - WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''', - '''DELETE FROM "repo_history" - WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''', - ), - "supports multiple projects, and allows choosing " #cont - "non-default branches" - ), - "test": ( - ( - '''-- apply''', - ), - ( - '''-- revert''', - ), - "does nothing" - ) - } - -class Database: - """A database connection/session, returned by ``connect_database``. - - Some methods may require repos to be loaded. - """ - - def __init__(self, conn): - self.conn = conn - - def initialize(self): - """Initializes the database tables as expected by GAnarchy. - """ - c = self.conn.cursor() - c.execute(''' - CREATE TABLE "repo_history" ( - "entry" INTEGER PRIMARY KEY ASC AUTOINCREMENT, - "url" TEXT, - "count" INTEGER, - "head_commit" TEXT, - "branch" TEXT, - "project" TEXT - ) - ''') - c.execute(''' - CREATE INDEX "repo_history_url_branch_project" - ON "repo_history" ("url", "branch", "project") - ''') - self.conn.commit() - c.close() - - def apply_migration(self, migration): - """Applies a migration, by name. - - WARNING: Destructive operation. - - Args: - migration (str): The name of the migration. - """ - c = self.conn.cursor() - for migration in MIGRATIONS[migration][0]: - c.execute(migration) - self.conn.commit() - c.close() - - def revert_migration(self, migration): - """Reverts a previously-applied migration, by name. - - WARNING: Destructive operation. - - Args: - migration (str): The name of the migration. - """ - c = self.conn.cursor() - for migration in MIGRATIONS[migration][1]: - c.execute(migration) - self.conn.commit() - c.close() - - def load_repos(self, effective_repo_list): - """Loads repos from repo list. - - Must be done once for each instance of Database. - - Args: - effective_repo_list (ganarchy.data.DataSource): The data - source for the repo list. - """ - c = self.conn.cursor() - c.execute(''' - CREATE TEMPORARY TABLE "repos" ( - "url" TEXT, - "active" INT, - "branch" TEXT, - "project" TEXT, - "federate" INT - ) - ''') - c.execute(''' - CREATE UNIQUE INDEX "temp"."repos_url_branch_project" - ON "repos" ("url", "branch", "project") - ''') - c.execute(''' - CREATE INDEX "temp"."repos_project" - ON "repos" ("project") - ''') - c.execute(''' - CREATE INDEX "temp"."repos_active" - ON "repos" ("active") - ''') - for repo in effective_repo_list.get_property_values( - ganarchy.data.DataProperty.VCS_REPOS - ): - if repo.active: - c.execute( - '''INSERT INTO "repos" VALUES (?, ?, ?, ?, ?)''', - (repo.uri, 1, repo.branch, repo.project_commit, int(repo.federate)) - ) - self.conn.commit() - c.close() - - def insert_activity(self, project_commit, uri, branch, head, count): - """Inserts activity of a repo-branch. - - Args: - project_commit: The project commit. - uri: The repo uri. - branch: The branch. - head: The latest known head commit. - count: The number of new commits. - """ - self.insert_activities([(project_commit, uri, branch, head, count)]) - - def insert_activities(self, activities): - """Inserts activities of repo-branches. - - Args: - activities: List of tuple. The tuple must match up with the - argument order specified by ``insert_activity``. - """ - c = self.conn.cursor() - c.executemany( - ''' - INSERT INTO "repo_history" ( - "project", - "url", - "branch", - "head_commit", - "count" - ) - VALUES (?, ?, ?, ?, ?) - ''', - activities - ) - self.conn.commit() - c.close() - - def list_projects(self): - """Lists loaded projects. - - Repos must be loaded first. - - Yields: - str: Project commit of each project. - """ - c = self.conn.cursor() - try: - for (project,) in c.execute( - '''SELECT DISTINCT "project" FROM "repos" ''' - ): - yield project - finally: - c.close() - - def list_repobranches(self, project_commit): - """Lists repo-branches of a project. - - Repos must be loaded first. - - Results are sorted by recent activity. - - Args: - project_commit: The project commit. - - Yields: - A 3-tuple holding the URI, branch name, and last known head - commit. - """ - c = self.conn.cursor() - try: - for (e, url, branch, head_commit) in c.execute( - ''' - SELECT "max"("e"), "url", "branch", "head_commit" - FROM ( - SELECT - "max"("T1"."entry") "e", - "T1"."url", - "T1"."branch", - "T1"."head_commit" - FROM "repo_history" "T1" - WHERE ( - SELECT "active" - FROM "repos" "T2" - WHERE - "url" = "T1"."url" - AND "branch" IS "T1"."branch" - AND "project" IS ?1 - ) - GROUP BY "T1"."url", "T1"."branch" - UNION - SELECT null, "T3"."url", "T3"."branch", null - FROM "repos" "T3" - WHERE "active" AND "project" IS ?1 - ) - GROUP BY "url", "branch" - ORDER BY "e" - ''', - (project_commit,) - ): - yield url, branch, head_commit - finally: - c.close() - - def list_repobranch_activity(self, project_commit, uri, branch): - """Lists activity of a repo-branch. - - Args: - project_commit: The project commit. - uri: The repo uri. - branch: The branch. - - Returns: - list of int: Number of commits between updates. - """ - c = self.conn.cursor() - history = c.execute( - ''' - SELECT "count" - FROM "repo_history" - WHERE - "url" = ? - AND "branch" IS ? - AND "project" IS ? - ORDER BY "entry" ASC - ''', - (uri, branch, project_commit) - ).fetchall() - history = [x for [x] in history] - c.close() - return history - - def should_repo_federate(self, project_commit, uri, branch): - """Returns whether a repo should federate. - - Args: - project_commit: The project commit. - uri: The repo uri. - branch: The branch. - - Returns: - bool, optional: Whether the repo should federate, or None if it - doesn't exist. - """ - c = self.conn.cursor() - federate = c.execute( - ''' - SELECT "federate" - FROM "repos" - WHERE - "url" = ? - AND "branch" IS ? - AND "project" IS ? - ''', - (uri, branch, project_commit) - ).fetchall() - try: - ((federate,),) = federate - federate = bool(federate) - except ValueError: - federate = None - c.close() - return federate - - def close(self): - """Closes the database. - """ - self.conn.close() - -def connect_database(effective_config): - """Opens the database specified by the given config. - - Args: - effective_config (ganarchy.data.DataSource): The data source - for the config. - """ - del effective_config # currently unused, intended for the future - conn = sqlite3.connect(ganarchy.dirs.DATA_HOME + "/ganarchy.db") - return Database(conn) diff --git a/ganarchy/dirs.py b/ganarchy/dirs.py deleted file mode 100644 index 7973126..0000000 --- a/ganarchy/dirs.py +++ /dev/null @@ -1,53 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""This module handles GAnarchy's config, data and cache directories. - -These are not XDG dirs. They're GAnarchy dirs. They're based on XDG -dirs but they're not XDG dirs. - -Attributes: - DATA_HOME (str): GAnarchy data home. - CACHE_HOME (str): GAnarchy cache home. - CONFIG_HOME (str): GAnarchy config home. - CONFIG_DIRS (list of str): GAnarchy config dirs. -""" - -import os - -# need to check for unset or empty, ``.get`` only handles unset. - -DATA_HOME = os.environ.get('XDG_DATA_HOME', '') -if not DATA_HOME: - DATA_HOME = os.environ['HOME'] + '/.local/share' -DATA_HOME = DATA_HOME + "/ganarchy" - -CACHE_HOME = os.environ.get('XDG_CACHE_HOME', '') -if not CACHE_HOME: - CACHE_HOME = os.environ['HOME'] + '/.cache' -CACHE_HOME = CACHE_HOME + "/ganarchy" - -CONFIG_HOME = os.environ.get('XDG_CONFIG_HOME', '') -if not CONFIG_HOME: - CONFIG_HOME = os.environ['HOME'] + '/.config' -CONFIG_HOME = CONFIG_HOME + "/ganarchy" - -CONFIG_DIRS = os.environ.get('XDG_CONFIG_DIRS', '') -if not CONFIG_DIRS: - CONFIG_DIRS = '/etc/xdg' -# TODO check if this is correct -CONFIG_DIRS = [config_dir + "/ganarchy" for config_dir in CONFIG_DIRS.split(':')] - diff --git a/ganarchy/git.py b/ganarchy/git.py deleted file mode 100644 index f8ccfcd..0000000 --- a/ganarchy/git.py +++ /dev/null @@ -1,173 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Git abstraction. -""" - -# Errors are raised when we can't provide an otherwise valid result. -# For example, we return 0 for counts instead of raising, but raise -# instead of returning empty strings for commit hashes and messages. - -import subprocess - -class GitError(Exception): - """Raised when a git operation fails, generally due to a - missing commit or branch, or network connection issues. - """ - pass - -class Git: - def __init__(self, path): - self.path = path - self.base = ("git", "-C", path) - - def create(self): - """Creates the local repo. - - Can safely be called on an existing repo. - """ - subprocess.call(self.base + ("init", "-q")) - - - def check_history(self, local_head, commit): - """Checks if the local head contains commit in its history. - Raises if it doesn't. - - Args: - local_head (str): Name of local head. - commit (str): Commit hash. - - Raises: - GitError: If an error occurs. - """ - try: - subprocess.run( - self.base + ("merge-base", "--is-ancestor", commit, local_head), - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - except subprocess.CalledProcessError as e: - raise GitError("check history") from e - - def check_branchname(self, branchname): - """Checks if the given branchname is a valid branch name. - Raises if it isn't. - - Args: - branchname (str): Name of branch. - - Raises: - GitError: If an error occurs. - """ - try: - # TODO check that this rstrip is safe - out = subprocess.run( - self.base + ("check-ref-format", "--branch", branchname), - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ).stdout.decode("utf-8").rstrip('\r\n') - # protect against @{-1}/@{-n} ("previous checkout operation") - # is also fairly future-proofed, I hope? - if out != branchname: - raise GitError("check branchname", out, branchname) - except subprocess.CalledProcessError as e: - raise GitError("check branchname") from e - - def force_fetch(self, url, remote_head, local_head): - """Fetches a remote head into a local head. - - If the local head already exists, it is replaced. - - Args: - url (str): Remote url. - remote_head (str): Name of remote head. - local_head (str): Name of local head. - - Raises: - GitError: If an error occurs. - """ - try: - subprocess.run( - self.base + ("fetch", "-q", url, "+" + remote_head + ":" + local_head), - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - except subprocess.CalledProcessError as e: - raise GitError(e.output) from e - - def get_count(self, first_hash, last_hash): - """Returns a count of the commits added since ``first_hash`` - up to ``last_hash``. - - Args: - first_hash (str): A commit. - last_hash (str): Another commit. - - Returns: - int: A count of commits added between the hashes, or 0 - if an error occurs. - """ - try: - res = subprocess.run( - self.base + ("rev-list", "--count", first_hash + ".." + last_hash, "--"), - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ).stdout.decode("utf-8").strip() - return int(res) - except subprocess.CalledProcessError as e: - return 0 - - def get_hash(self, target): - """Returns the commit hash for a given target. - - Args: - target (str): a refspec. - - Raises: - GitError: If an error occurs. - """ - try: - return subprocess.run( - self.base + ("show", target, "-s", "--format=format:%H", "--"), - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ).stdout.decode("utf-8") - except subprocess.CalledProcessError as e: - raise GitError("") from e - - def get_commit_message(self, target): - """Returns the commit message for a given target. - - Args: - target (str): a refspec. - - Raises: - GitError: If an error occurs. - """ - try: - return subprocess.run( - self.base + ("show", target, "-s", "--format=format:%B", "--"), - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ).stdout.decode("utf-8", "replace") - except subprocess.CalledProcessError as e: - raise GitError("") from e diff --git a/ganarchy/templating/__init__.py b/ganarchy/templating/__init__.py deleted file mode 100644 index d6f6d0c..0000000 --- a/ganarchy/templating/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Templates. - -""" - -# TODO write me diff --git a/ganarchy/templating/environment.py b/ganarchy/templating/environment.py deleted file mode 100644 index 0258f4d..0000000 --- a/ganarchy/templating/environment.py +++ /dev/null @@ -1,31 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -import jinja2 - -import ganarchy.templating.templates -import ganarchy.templating.toml - -def get_env(): - env = jinja2.Environment( - loader=ganarchy.templating.templates.get_template_loader(), - autoescape=False, - # aka please_stop_mangling_my_templates=True - keep_trailing_newline=True - ) - env.filters['tomlescape'] = ganarchy.templating.toml.tomlescape - env.filters['tomle'] = env.filters['tomlescape'] - return env diff --git a/ganarchy/templating/templates.py b/ganarchy/templating/templates.py deleted file mode 100644 index 1e9c074..0000000 --- a/ganarchy/templating/templates.py +++ /dev/null @@ -1,128 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -import jinja2 - -import ganarchy.dirs - -def get_template_loader(): - return jinja2.ChoiceLoader([ - jinja2.FileSystemLoader([ganarchy.dirs.CONFIG_HOME + "/templates"] + [config_dir + "/templates" for config_dir in ganarchy.dirs.CONFIG_DIRS]), - jinja2.DictLoader({ - ## index.html - 'index.html': """<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <!-- - GAnarchy - project homepage generator - Copyright (C) 2019 Soni L. - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. - --> - <title>{{ ganarchy.title|e }}</title> - <meta name="description" content="{{ ganarchy.title|e }}" /> - <!--if your browser doesn't like the following, use a different browser.--> - <script type="application/javascript" src="/index.js"></script> - </head> - <body> - <h1>{{ ganarchy.title|e }}</h1> - <p>This is {{ ganarchy.title|e }}. Currently tracking the following projects:</p> - <ul> - {% for project in ganarchy.projects -%}{% if project.exists -%} - <li><a href="{{ (ganarchy.base_url[:-1] + ganarchy.base_url[-1:].rsplit('/',1)[0])|e }}/project/{{ project.commit|e }}">{{ project.title|e }}</a>: {{ project.description|e }}</li> - {% endif -%}{% endfor -%} - </ul> - <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p> - <p> - <a href="{{ ganarchy.base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a> - (Makes navigating between GAnarchy instances easier). - </p> - </body> -</html> -""", - ## index.toml - 'index.toml': """# Generated by GAnarchy - -{%- for project in database.list_projects() %} -[projects.{{project}}] -{%- for repo_url, branch, _head_commit in database.list_repobranches(project) %} -{%- if database.should_repo_federate(project, repo_url, branch) %} -"{{repo_url|tomle}}".{% if not branch %}HEAD{% else %}"{{branch|tomle}}"{% endif %} = { active=true } -{%- endif %} -{%- endfor %} -{% endfor -%} -""", - ## project.html - # FIXME convert to project.title/etc instead of project_title/etc. - 'project.html': """<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <!-- - GAnarchy - project homepage generator - Copyright (C) 2019 Soni L. - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. - --> - <title>{{ project_title|e }}</title> - {% if project_desc %}<meta name="description" content="{{ project_desc|e }}" />{% endif %} - <style type="text/css">.branchname { color: #808080; font-style: italic; }</style> - </head> - <body> - <h1>{{ project_title|e }}</h1> - <p>Tracking <span id="project_commit"><a href="web+ganarchy:{{ project_commit }}">{{ project_commit }}</a></span></p> - <div id="project_body"><p>{{ project_body|e|replace("\n\n", "</p><p>") }}</p></div> - <ul> - {% for url, msg, img, branch in repos -%} - <li><a href="{{ url|e }}">{{ url|e }}</a>{% if branch %} <span class="branchname">[{{ branch|e }}]</span>{% endif %}: {{ msg|e }}</li> - {% endfor -%} - </ul> - <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p> - <p> - <a href="{{ base_url|e }}">Main page</a>. - <a href="{{ base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a> - (Makes navigating between GAnarchy instances easier). - </p> - </body> -</html> -""", - ## history.svg FIXME - 'history.svg': """""", - }) - ]) diff --git a/ganarchy/templating/toml.py b/ganarchy/templating/toml.py deleted file mode 100644 index 431125d..0000000 --- a/ganarchy/templating/toml.py +++ /dev/null @@ -1,33 +0,0 @@ -# This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2020 Soni L. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -_tomletrans = str.maketrans({ - 0: '\\u0000', 1: '\\u0001', 2: '\\u0002', 3: '\\u0003', 4: '\\u0004', - 5: '\\u0005', 6: '\\u0006', 7: '\\u0007', 8: '\\b', 9: '\\t', 10: '\\n', - 11: '\\u000B', 12: '\\f', 13: '\\r', 14: '\\u000E', 15: '\\u000F', - 16: '\\u0010', 17: '\\u0011', 18: '\\u0012', 19: '\\u0013', 20: '\\u0014', - 21: '\\u0015', 22: '\\u0016', 23: '\\u0017', 24: '\\u0018', 25: '\\u0019', - 26: '\\u001A', 27: '\\u001B', 28: '\\u001C', 29: '\\u001D', 30: '\\u001E', - 31: '\\u001F', '"': '\\"', '\\': '\\\\' - }) - -def tomlescape(value): - """Escapes a string for use in a TOML string. - - Returns: - str: The escaped string. - """ - return value.translate(_tomletrans) diff --git a/index.js b/index.js deleted file mode 100644 index bab2c5e..0000000 --- a/index.js +++ /dev/null @@ -1,29 +0,0 @@ -// GAnarchy - project homepage generator -// Copyright (C) 2019 Soni L. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see <https://www.gnu.org/licenses/>. - -(function() { - var url = new URL(document.location.href); - var target = url.searchParams.get("url"); - if (target !== null) { - var project = target.match(/^web\+ganarchy\:([a-fA-F0-9]+)$/); - if (project !== null) { - // some browsers don't like it when you set this directly - url.search = ""; - url.pathname = "/project/" + project[1]; - document.location = url.href; - } - } -})(); diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3d7e7f6..0000000 --- a/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ --e git+https://soniex2.autistic.space/git-repos/abdl.git@ff3628c36eab5ec19ab850e9126a951f5c203568#egg=gan0f74bd87a23b515b45da7e6f5d9cc82380443dab -Click==7.0 -Jinja2==2.11.1 -qtoml==0.2.4 -requests==2.22.0 - -certifi==2019.11.28 -chardet==3.0.4 -idna==2.8 -MarkupSafe==1.1.1 -pyparsing==2.4.2 -urllib3==1.25.7 diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index ebe51db..0000000 --- a/requirements_test.txt +++ /dev/null @@ -1,21 +0,0 @@ -astroid==2.3.3 -atomicwrites==1.3.0 -attrs==19.3.0 -decompyle3==3.3.2 -hypothesis==4.42.7 -isort==4.3.21 -lazy-object-proxy==1.4.3 -mccabe==0.6.1 -more-itertools==7.2.0 -packaging==19.2 -pluggy==0.13.1 -py==1.8.0 -pylint==2.4.4 -pytest==5.2.2 -pytest-sphinx==0.2.2 -six==1.13.0 -spark-parser==1.8.9 -uncompyle6==3.6.2 -wcwidth==0.1.7 -wrapt==1.11.2 -xdis==4.2.2 diff --git a/setup.py b/setup.py deleted file mode 100644 index 690c7ef..0000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -import setuptools - -setuptools.setup(name="gan385e734a52e13949a7a5c71827f6de920dbfea43", packages=setuptools.find_packages(include=["ganarchy", "ganarchy.*"]), install_requires=[ - "gan0f74bd87a23b515b45da7e6f5d9cc82380443dab", # a boneless datastructure library - "Click", "Jinja2", "qtoml", "requests"]) diff --git a/setup_env.bash b/setup_env.bash deleted file mode 100644 index d25baa9..0000000 --- a/setup_env.bash +++ /dev/null @@ -1,3 +0,0 @@ -python -m venv venv && -. venv/bin/activate && -pip install -r requirements.txt diff --git a/setup_testenv.bash b/setup_testenv.bash deleted file mode 100644 index baf9414..0000000 --- a/setup_testenv.bash +++ /dev/null @@ -1,4 +0,0 @@ -python -m venv vtestenv && -. vtestenv/bin/activate && -pip install -r requirements.txt && -pip install -r requirements_test.txt diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..df9614a --- /dev/null +++ b/src/git.rs @@ -0,0 +1,728 @@ +// This file is part of GAnarchy - decentralized development hub +// Copyright (C) 2021 Soni L. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! This module provides some abstractions over git. +//! +//! Having this module allows easily(-ish) replacing the git backend, for +//! example from calling the git CLI directly to using a git library. + +use std::collections::BTreeSet; +use std::error; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; +//use std::process; +use std::process::{Command, Output}; + +use impl_trait::impl_trait; + +use crate::util::NamePurpose; +use crate::marker::Initializer; + +#[cfg(test)] +mod tests; + +/// Represents a local git repo. +pub struct Git { + path: PathBuf, + pending_branches: Option<BTreeSet<String>>, + sha256: bool, +} + +/// Error returned by operations on a git repo. +#[derive(Debug)] +pub struct GitError { + inner: GitErrorInner, + command: Vec<OsString>, +} + +#[derive(Debug)] +enum GitErrorInner { + IoError(io::Error), + Output(Output), +} + +/// Helper for tracking args to a Command. +struct Args { + inner: Command, + args: Vec<OsString>, +} + +impl_trait! { + impl Args { + /// Creates a new Args for the given command. + pub fn new_cmd<S: AsRef<OsStr>>(cmd: S) -> Self { + let cmd = cmd.as_ref(); + Self { + inner: Command::new(cmd), + args: vec![cmd.into()], + } + } + + /// Adds a single arg to the Command. + pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self { + let arg = arg.as_ref(); + self.inner.arg(arg); + self.args.push(arg.into()); + self + } + + // /// Adds multiple args to the Command. + // pub fn args<I, S>(&mut self, args: I) -> &mut Self + // where I: IntoIterator<Item=S>, S: AsRef<OsStr> { + // for arg in args { + // self.arg(arg); + // } + // self + // } + + // impl trait Into<Result<Output, GitError>> { + // fn into(self) -> Result<Output, GitError> { + // todo!() + // } + // } + } +} + +/// RAII transaction guard for merging forked repos in with_work_repos. +struct Merger<'a>(&'a mut Git, Vec<Git>); + +impl From<io::Error> for GitErrorInner { + fn from(e: io::Error) -> Self { + Self::IoError(e) + } +} + +impl From<Output> for GitErrorInner { + fn from(e: Output) -> Self { + Self::Output(e) + } +} + +impl_trait! { + impl GitError { + /// Creates a new GitError for the given command. + fn new(inner: impl Into<GitErrorInner>, cmd: Vec<OsString>) -> Self { + Self { + inner: inner.into(), + command: cmd, + } + } + + impl trait fmt::Display { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error running")?; + for part in &self.command { + let part = part.to_str().unwrap_or("[not UTF-8]"); + write!(f, " {}", part)?; + } + match &self.inner { + GitErrorInner::IoError(e) => { + write!(f, ", caused by: {}", e) + }, + GitErrorInner::Output(e) => { + let out = std::str::from_utf8(&e.stdout); + let out = out.unwrap_or("[not UTF-8]"); + let err = std::str::from_utf8(&e.stderr); + let err = err.unwrap_or("[not UTF-8]"); + write!(f, "\nstdout:\n{}", out)?; + write!(f, "\nstderr:\n{}", err) + }, + } + } + } + + impl trait error::Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self.inner { + GitErrorInner::IoError(ref error) => { + Some(error) + } + GitErrorInner::Output(_) => None, + } + } + } + } +} + +impl_trait! { + impl<'a> Merger<'a> { + /// Returns a shared, immutable reference to the main repo. + fn main(&self) -> &Git { + &*self.0 + } + + /// Merges the work repos back into the main repo. + /// + /// # Panics + /// + /// Panics if there are branches in conflict. + fn merge(mut self) -> Result<(), GitError> { + // check for conflicts first! + let mut branches = BTreeSet::<&String>::new(); + for work in &*self { + for branch in work.pending_branches.as_ref().unwrap() { + if !branches.insert(branch) { + panic!("Branch {} is in conflict!", branch); + } + } + } + drop(branches); + + for mut repo in std::mem::take(&mut self.1) { + // TODO clean up + let repo_id = repo.path.file_name().unwrap().to_str().unwrap() + .strip_prefix("ganarchy-fetch-").unwrap() + .strip_suffix(".git").unwrap() + .to_owned(); + let pending = repo.pending_branches.take().unwrap(); + for branch in pending { + let len = branch.len(); + let fetch_head = branch + "-" + &repo_id; + let branch = &fetch_head[..len]; + // First collect the work branch into a fetch head + self.0.fetch_work(&repo, &fetch_head, branch)?; + // If that succeeds, delete the work branch to free up disk + repo.rm_branch(branch)?; + // We have all the objects in the main repo and we probably + // have enough disk, so just replace the fetch head into + // the main branch and hope nothing errors. + self.0.replace(&fetch_head, branch)?; + } + repo.delete()?; + } + Ok(()) + } + + /// Accesses the work repos. + impl trait std::ops::Deref { + type Target = Vec<Git>; + + fn deref(&self) -> &Vec<Git> { + &self.1 + } + } + + /// Accesses the work repos. + impl trait std::ops::DerefMut { + fn deref_mut(&mut self) -> &mut Vec<Git> { + &mut self.1 + } + } + + /// Cleans up (deletes) the work repos, if not panicking. + impl trait Drop { + fn drop(&mut self) { + if !std::thread::panicking() { + for repo in std::mem::take(&mut self.1) { + repo.delete().unwrap(); + } + } + } + } + } +} + +/// Initializer operations on the `Git` struct. +impl Git { + /// Creates a new instance of the `Git` struct, with the path as given. + pub fn at_path<T: AsRef<Path>>(_: Initializer, path: T) -> Option<Git> { + let path = path.as_ref(); + let filename = path.file_name()?.to_str()?; + // TODO SHA-2 + NamePurpose::CacheRepo.is_fit(filename).then(|| Git { + path: path.into(), + pending_branches: None, + sha256: false, + }) + } +} + +/// Operations on a git repo. +/// +/// # Race conditions +/// +/// These operate on the filesystem. Calling them from multiple threads +/// can result in data corruption. +impl Git { + /// Creates the given number of work repos, and calls the closure to run + /// operations on them. + /// + /// The operations can be done on the individual repos, and they'll be + /// merged into the main repo as this function returns. + /// + /// If the callback fails, the work repos will be deleted. If the function + /// succeeds, the work repos will be merged back into the main repo. + /// + /// # Panics + /// + /// Panics if a merge conflict is detected. Specifically, if two work repos + /// modify the same work branch. Also panics if this isn't a cache repo. + /// + /// # "Poisoning" + /// + /// If this method unwinds, the underlying git repos, if any, will not be + /// deleted. Instead, future calls to this method will return a GitError. + pub fn with_work_repos<F, R>(&mut self, count: usize, f: F) + -> Result<R, GitError> + where F: FnOnce(&mut [Git]) -> Result<R, GitError> { + assert!(self.is_cache_repo()); + // create some Git structs + let mut work_repos = Vec::with_capacity(count); + for id in 0..count { + let mut new_path = self.path.clone(); + new_path.set_file_name(format!("ganarchy-fetch-{}.git", id)); + let git = Git { + path: new_path, + pending_branches: Some(Default::default()), + sha256: self.sha256, + }; + assert!(git.is_work_repo()); + work_repos.push(git); + } + // create the on-disk stuff + let merger = Merger(self, Vec::new()); + let mut merger = work_repos.into_iter() + .try_fold(merger, |mut m, mut r| { + m.main().fork(&mut r)?; + m.push(r); + Ok(m) + })?; + let result = f(&mut *merger)?; + // merge the on-disk stuff + merger.merge().and(Ok(result)) + } + + /// Fetches branch `from_ref` from source `from` into branch `branch`. + /// + /// The fetch used is a force-fetch. + /// + /// # Panics + /// + /// Panics if called on a non-work repo, if `from` starts with `-`, if + /// `branch` isn't a cache branch, or if `from_ref` starts with `-`. + pub fn fetch_source(&mut self, from: &str, branch: &str, from_ref: &str) + -> Result<(), GitError> + { + assert!(self.is_work_repo()); + assert!(!from.starts_with("-")); + assert!(!from_ref.starts_with("-")); + assert!(NamePurpose::WorkBranch.is_fit(branch)); + let _output = self.cmd(|args| { + args.arg("fetch"); + args.arg(from); + args.arg(format!("+{}:{}", from_ref, branch)); + })?; + self.pending_branches.as_mut().unwrap().insert(branch.into()); + Ok(()) + } + + /// Initializes this repo. + /// + /// # Panics + /// + /// Panics if called on a non-cache repo. + pub fn ensure_exists(&mut self) -> Result<(), GitError> { + assert!(self.is_cache_repo()); + let _output = self.cmd_init(|_| {})?; + Ok(()) + } + + /// Checks if a given commit is present in the given branch's history. + /// + /// # Panics + /// + /// Panics if this isn't a cache branch on a cache repo or if commit isn't + /// a commit. + pub fn check_history(&self, branch: &str, commit: &str) + -> Result<(), GitError> + { + assert!(self.is_cache_repo()); + assert!(NamePurpose::CacheBranch.is_fit(branch)); + assert!(self.is_commit_hash(commit)); + let _output = self.cmd(|args| { + args.arg("merge-base"); + args.arg("--is-ancestor"); + args.arg(commit); + args.arg(format!("refs/heads/{}", branch)); + })?; + Ok(()) + } + + /// Checks if the given branch is a valid branch. + /// + /// Note: "HEAD" is **not** a branch. + /// + /// # Panics + /// + /// Panics if `branch` starts with `-`. + pub fn check_branch(&self, branch: &str) -> Result<(), GitError> { + assert!(!branch.starts_with("-")); + let mut output = self.cmd(|args| { + args.arg("check-ref-format"); + args.arg("--branch"); + args.arg(branch); + })?; + // perf: Vec::default doesn't allocate. + let stdout = std::mem::take(&mut output.stdout); + let stdout = String::from_utf8(stdout); + match stdout.as_ref().map(|x| x.strip_prefix(branch)) { + Ok(Some("")) | Ok(Some("\n")) | Ok(Some("\r\n")) => { + return Ok(()) + }, + _ => (), + } + output.stdout = match stdout { + Ok(e) => e.into_bytes(), + Err(e) => e.into_bytes(), + }; + let v = vec![ + OsString::from("git"), + "check-ref-format".into(), + "--branch".into(), + branch.into(), + ]; + Err(GitError::new(output, v)) + } + + /// Returns the number of commits removed and the number of added between + /// from and to, respectively. + /// + /// # Panics + /// + /// Panics if called on a non-work repo. + pub fn get_counts(&self, from: &str, to: &str) + -> Result<(u64, u64), GitError> + { + // if called on a cache repo, `from` may no longer exist. + // this check makes sure `from` has not been garbage-collected. + assert!(self.is_work_repo()); + assert!(self.is_commit_hash(from)); + assert!(self.is_commit_hash(to)); + let mut output = self.cmd(|args| { + args.arg("rev-list"); + args.arg("--left-right"); + args.arg("--count"); + args.arg(format!("{}...{}", from, to)); + args.arg("--"); + })?; + // perf: Vec::default doesn't allocate. + let stdout = std::mem::take(&mut output.stdout); + let stdout = String::from_utf8(stdout); + match stdout.as_ref().ok().map(|x| x.trim()).filter(|x| { + x.trim_start_matches(|x| { + char::is_ascii_digit(&x) + }).trim_end_matches(|x| { + char::is_ascii_digit(&x) + }) == "\t" + }).and_then(|x| { + let (y, z) = x.split_once("\t")?; + Some((y.parse::<u64>().ok()?, z.parse::<u64>().ok()?)) + }) { + Some(v) => return Ok(v), + None => (), + } + output.stdout = match stdout { + Ok(e) => e.into_bytes(), + Err(e) => e.into_bytes(), + }; + let v = vec![ + OsString::from("git"), + "rev-list".into(), + "--left-right".into(), + "--count".into(), + format!("{}...{}", from, to).into(), + "--".into(), + ]; + Err(GitError::new(output, v)) + } + + /// Returns the commit hash at the given target. + /// + /// # Panics + /// + /// Panics if `target` starts with `-`. + pub fn get_hash(&self, target: &str) + -> Result<String, GitError> + { + assert!(!target.starts_with("-")); + let mut output = self.cmd(|args| { + args.arg("show"); + args.arg(target); + args.arg("-s"); + args.arg("--format=format:%H"); + args.arg("--"); + })?; + // perf: Vec::default doesn't allocate. + let stdout = std::mem::take(&mut output.stdout); + let stdout = String::from_utf8(stdout); + output.stdout = match stdout { + Ok(mut h) if self.is_commit_hash(h.trim()) => { + h.truncate(h.trim().len()); + return Ok(h) + }, + Ok(e) => e.into_bytes(), + Err(e) => e.into_bytes(), + }; + let v = vec![ + OsString::from("git"), + "show".into(), + target.into(), + "-s".into(), + "--format=format:%H".into(), + "--".into(), + ]; + Err(GitError::new(output, v)) + } + + /// Returns the commit message for the given target. + /// + /// # Panics + /// + /// Panics if `target` starts with `-`. + pub fn get_message(&self, target: &str) + -> Result<String, GitError> + { + assert!(!target.starts_with("-")); + let mut output = self.cmd(|args| { + args.arg("show"); + args.arg(target); + args.arg("-s"); + args.arg("--format=format:%B"); + args.arg("--"); + })?; + // perf: Vec::default doesn't allocate. + let stdout = std::mem::take(&mut output.stdout); + let stdout = String::from_utf8(stdout); + output.stdout = match stdout { + Ok(e) => return Ok(e), + Err(e) => e.into_bytes(), + }; + let v = vec![ + OsString::from("git"), + "show".into(), + target.into(), + "-s".into(), + "--format=format:%B".into(), + "--".into(), + ]; + Err(GitError::new(output, v)) + } +} + +/// Private operations on a git repo. +impl Git { + /// Fetches branch `from_branch` from work repo `from` into branch `branch`. + /// + /// The fetch used is a force-fetch. + /// + /// # Panics + /// + /// Panics if this isn't a cache repo, if `from` isn't a work repo, if + /// `branch` isn't a fetch head or if `from_branch` isn't a cache branch. + fn fetch_work(&mut self, from: &Git, branch: &str, from_branch: &str) + -> Result<(), GitError> + { + assert_eq!(self.sha256, from.sha256); + assert!(self.is_cache_repo()); + assert!(from.is_work_repo()); + assert!(NamePurpose::CacheBranch.is_fit(from_branch)); + assert!(NamePurpose::CacheFetchHead.is_fit(branch)); + let _output = self.cmd(|args| { + args.arg("fetch"); + args.arg(&from.path); + args.arg(format!("+{}:{}", from_branch, branch)); + })?; + Ok(()) + } + + /// Replaces branch `new_name` with branch `old_name`. + /// + /// # Panics + /// + /// Panics if this isn't a cache repo, if `old_name` isn't a fetch head, + /// or if `new_name` isn't a cache branch. + fn replace(&mut self, old_name: &str, new_name: &str) + -> Result<(), GitError> + { + assert!(self.is_cache_repo()); + assert!(NamePurpose::CacheBranch.is_fit(new_name)); + assert!(NamePurpose::CacheFetchHead.is_fit(old_name)); + let _output = self.cmd(|args| { + args.arg("branch"); + args.arg("-M"); + args.arg(old_name).arg(new_name); + })?; + Ok(()) + } + + /// Deletes work branch `branch`. + /// + /// # Panics + /// + /// Panics if the branch isn't a work branch or if this isn't a work + /// repo. + fn rm_branch(&mut self, branch: &str) -> Result<(), GitError> { + assert!(self.is_work_repo()); + assert!(NamePurpose::WorkBranch.is_fit(branch)); + let _output = self.cmd(|args| { + args.arg("branch"); + args.arg("-D").arg(branch); + })?; + Ok(()) + } + + /// Makes a shared clone of this lcoal repo into the given work repo. + /// + /// Equivalent to `git clone --bare --shared`, which is very dangerous! + /// + /// # Panics + /// + /// Panics if this repo isn't a cache repo, and/or if the given repo isn't + /// a work repo. + fn fork(&self, into: &mut Git) -> Result<(), GitError> { + // check that this is a cache repo + assert_eq!(self.sha256, into.sha256); + assert!(self.is_cache_repo()); + assert!(into.is_work_repo()); + let _output = into.cmd_clone_from(&self.path, |args| { + args.arg("--shared"); + })?; + Ok(()) + } + + /// Deletes this repo. + /// + /// # Panics + /// + /// Panics if called on a non-work repo. + fn delete(self) -> Result<(), GitError> { + assert!(self.is_work_repo()); + fs::remove_dir_all(&self.path).map_err(|e| { + let args = vec![ + "(synthetic)".into(), + "rm".into(), + "-rf".into(), + OsString::from(&self.path) + ]; + GitError::new(e, args) + }) + } +} + +/// Helpers. +impl Git { + /// Returns true if this is a cache repo. + fn is_cache_repo(&self) -> bool { + let filename = self.path.file_name().unwrap().to_str(); + if self.sha256 { + NamePurpose::CacheRepo64.is_fit(filename.unwrap()) + } else { + NamePurpose::CacheRepo.is_fit(filename.unwrap()) + } + } + + /// Returns true if this is a work repo. + fn is_work_repo(&self) -> bool { + let filename = self.path.file_name().unwrap().to_str(); + if self.sha256 { + NamePurpose::WorkRepo64.is_fit(filename.unwrap()) + } else { + NamePurpose::WorkRepo.is_fit(filename.unwrap()) + } + } + + /// Returns true if the string is a commit hash. + /// + /// Does not check if the commit exists. + fn is_commit_hash(&self, commit: &str) -> bool { + if self.sha256 { + NamePurpose::Commit64.is_fit(commit) + } else { + NamePurpose::Commit.is_fit(commit) + } + } +} + +/// Raw commands on a git repo. +impl Git { + /// Runs a command for initializing this git repo. + /// + /// Always uses `--bare`. + fn cmd_init(&self, f: impl FnOnce(&mut Args)) -> Result<Output, GitError> { + self.cmd_common(|cmd| { + cmd.arg("init").arg("--bare"); + f(&mut *cmd); + cmd.arg(&self.path); + }) + } + + /// Runs a command for cloning into this git repo. + /// + /// Always uses `--bare`. + fn cmd_clone_from( + &self, + from: impl AsRef<OsStr>, + f: impl FnOnce(&mut Args) + ) -> Result<Output, GitError> { + self.cmd_common(|cmd| { + cmd.arg("clone").arg("--bare"); + f(&mut *cmd); + cmd.arg(from).arg(&self.path); + }) + } + + /// Runs a command for operating on this git repo. + /// + /// Note: Doesn't work for git init and git clone operations. Use + /// [`cmd_init`] and [`cmd_clone_from`] instead. + /// + /// Always uses `--bare`. + fn cmd(&self, f: impl FnOnce(&mut Args)) -> Result<Output, GitError> { + self.cmd_common(|cmd| { + cmd.arg("-C").arg(&self.path); + cmd.arg("--bare"); + f(&mut *cmd); + }) + } + + /// Common handling of raw commands. + /// + /// `"git" + f()` and error handling. + fn cmd_common( + &self, + f: impl FnOnce(&mut Args) + ) -> Result<Output, GitError> { + let mut cmd = Args::new_cmd("git"); + f(&mut cmd); + // run the command and make nicer Error + let Args { inner: mut cmd, args } = cmd; + let mut args = Some(args); + cmd.output().map_err(|e| { + GitError::new(e, args.take().unwrap()) + }).and_then(|output| { + if output.status.success() { + Ok(output) + } else { + Err(GitError::new(output, args.take().unwrap())) + } + }) + } + +} diff --git a/src/git/tests.rs b/src/git/tests.rs new file mode 100644 index 0000000..ef6c763 --- /dev/null +++ b/src/git/tests.rs @@ -0,0 +1,389 @@ +// This file is part of GAnarchy - decentralized development hub +// Copyright (C) 2021 Soni L. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Unit tests for the git module. + +use super::*; + +use std::panic::AssertUnwindSafe; + +use assert_fs::assert::PathAssert; +use assert_fs::fixture::{TempDir, PathChild}; +use predicates; +use testserver::serve; + +const MAGIC_BRANCH: &'static str = "gan\ + 0000000000000000000000000000000000000000000000000000000000000000"; + +const MAGIC_COMMIT: &'static str = "9d7224353c34ad675ee5e43fb3115aaaf98832e9"; + +// Oh yes. +static REPO: &'static str = +r#####"!<arch> +branches.a/ 0 0 0 644 8 ` +!<arch> +config.t/ 0 0 0 644 66 ` +[core] + repositoryformatversion = 0 + filemode = true + bare = true +description.t/ 0 0 0 644 73 ` +Unnamed repository; edit this file 'description' to name the repository. + +HEAD.t/ 0 0 0 644 24 ` +ref: refs/heads/default +hooks.a/ 0 0 0 644 8 ` +!<arch> +info.a/ 0 0 0 644 428 ` +!<arch> +exclude.t/ 0 0 0 644 240 ` +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ +refs.t/ 0 0 0 644 60 ` +9d7224353c34ad675ee5e43fb3115aaaf98832e9 refs/heads/default +objects.a/ 0 0 0 644 916 ` +!<arch> +4b.a/ 0 0 0 644 192 ` +!<arch> +// 42 ` +825dc642cb6eb9a060e54bf8d69288fbee4904.b/ +/0 0 0 0 644 21 ` +eAErKUpNVTBgAAAKLAIB + +9d.a/ 0 0 0 644 398 ` +!<arch> +// 42 ` +7224353c34ad675ee5e43fb3115aaaf98832e9.b/ +/0 0 0 0 644 227 ` +eAGdjUsKwjAURR1nFW8DlpikIQURHRSngs7EQT6vNtIkJUZod2/BrkC4o3M5HJtC8AUYp5uSEUEY +xWpnpWDWSDSNppJiLUynnGyYUp1BFA0VRH9KnzJcU/TtxGCP0WEOKeIckjs+g/ZDZVM4wE4yXnMm +dw1sKaeULHRJFvxLJvdLTi+05QHtpMM4IKyAkFvv37BMR8D1O5+izrafYfxZFfkCj/1MqQ== + +info.a/ 0 0 0 644 70 ` +!<arch> +packs.t/ 0 0 0 644 1 ` + + +pack.a/ 0 0 0 644 8 ` +!<arch> +refs.a/ 0 0 0 644 246 ` +!<arch> +heads.a/ 0 0 0 644 110 ` +!<arch> +default.t/ 0 0 0 644 41 ` +9d7224353c34ad675ee5e43fb3115aaaf98832e9 + +tags.a/ 0 0 0 644 8 ` +!<arch> +"#####; + +/// Tests the low-level (raw) commands. +mod low_level { + use super::*; + + /// Tests that cmd_init works. + #[test] + fn test_cmd_git_init() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let git = Git::at_path(Initializer, repo.path()).unwrap(); + git.cmd_init(|args| { + // templates actually override the -b flag, but at least + // this tests that passing args to git init actually works. + // also note that the -b flag requires a fairly recent version + // of git but ah well. .-. + args.arg("-b").arg("hello"); + }).unwrap(); + // git init *always* creates HEAD, even for bare repos! + // we don't actually care about what HEAD contains tho. + repo.child("HEAD").assert(predicates::path::exists()); + } + + /// Tests that cmd_clone works. + #[test] + fn test_cmd_git_clone() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let git = Git::at_path(Initializer, repo.path()).unwrap(); + git.cmd_init(|_| {}).unwrap(); + // check that a HEAD exists + repo.child("HEAD").assert(predicates::path::exists()); + let clone = dir.child("ganarchy-fetch-0.git"); + // have to create a Git manually for this one. ah well. :( + let git_clone = Git { + path: clone.path().into(), + pending_branches: None, + sha256: false, + }; + git_clone.cmd_clone_from(repo.path(), |args| { + // also test if git clone args work. + args.arg("--shared"); + }).unwrap(); + // git clone should carry over the HEAD + let pred = predicates::path::eq_file(repo.child("HEAD").path()); + clone.child("HEAD").assert(pred); + } + + /// Tests that cmd works and that we can get output from it. + #[test] + fn test_cmd() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let git = Git::at_path(Initializer, repo.path()).unwrap(); + // we do need to init this because git attempts to cd to it. + git.cmd_init(|_| {}).unwrap(); + let output = git.cmd(|args| { + args.arg("check-ref-format"); + args.arg("--normalize"); + args.arg("//refs/heads/headache"); + }).unwrap(); + assert_eq!(output.stdout, b"refs/heads/headache\n"); + } +} + +/// Tests private operations. +mod privates { + use super::*; + + /// Tests that fork works. + #[test] + fn test_fork() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let clone = dir.child("ganarchy-fetch-0.git"); + // have to create a Git manually for this one. ah well. :( + let mut git_clone = Git { + path: clone.path().into(), + pending_branches: Some(Default::default()), + sha256: false, + }; + git.fork(&mut git_clone).unwrap(); + } + + /// Tests that delete works. + #[test] + fn test_delete() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let clone = dir.child("ganarchy-fetch-0.git"); + // have to create a Git manually for this one. ah well. :( + let mut git_clone = Git { + path: clone.path().into(), + pending_branches: Some(Default::default()), + sha256: false, + }; + git.fork(&mut git_clone).unwrap(); + git_clone.delete().unwrap(); + } + + // TODO rm_branch, replace, fetch_work, etc! + // (or we can rely on with_work_repos as an integration test) +} + +/// Tests public operations. +mod publics { + use super::*; + + /// Tests that ensure_exists works. + #[test] + fn test_ensure_exists() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + } + + /// Tests that with_work_repos works. + #[test] + fn test_with_work_repos() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD") + }).unwrap(); + git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + // This is basically check_history, but we skip cache repo check. + repo.cmd(|args| { + args.arg("merge-base"); + args.arg("--is-ancestor"); + args.arg(MAGIC_COMMIT); + args.arg(format!("refs/heads/{}", MAGIC_BRANCH)); + }) + }).unwrap(); + } + + /// Tests that fetch_source works. + #[test] + fn test_fetch_source() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD") + }).unwrap(); + } + + /// Tests that with_work_repos works properly on failure. + #[test] + fn test_with_work_repos_failure() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + let addr2 = format!("{}{}", &addr, "nonexistent"); + let res = git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD").unwrap(); + repo.fetch_source(&addr2, MAGIC_BRANCH, "HEAD") + }); + assert!(res.is_err()); + // it should not merge the successful one. + assert!(git.check_history(MAGIC_BRANCH, MAGIC_COMMIT).is_err()); + } + + /// Tests that panicking through with_work_repos leaves the disk "dirty". + /// Also tests that a future call to with_work_repos fails gracefully. + #[test] + fn test_with_work_repos_panic() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + let res = std::panic::catch_unwind(AssertUnwindSafe(|| { + // we DO NOT want to call unwrap() on ANY of these. + git.with_work_repos::<_, ()>(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?; + panic!() + }) + })); + // check that it panicked. + assert!(res.is_err()); + // now check that future calls return an error, without calling the + // closure. + let res: Result<(), _> = git.with_work_repos(1, |_| panic!()); + assert!(res.is_err()); + } + + /// Tests that check_branch is correct. + #[test] + fn test_check_branch() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + git.check_branch("default").unwrap(); + assert!(git.check_branch("HEAD").is_err()); + } + + /// Tests that check_history is correct. + #[test] + fn test_check_history() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD") + }).unwrap(); + git.check_history(MAGIC_BRANCH, MAGIC_COMMIT).unwrap(); + } + + /// Tests that get_counts is correct. (non-exhaustive) + #[test] + fn test_get_counts() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + let counts = git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?; + repo.get_counts(MAGIC_COMMIT, MAGIC_COMMIT) + }).unwrap(); + // Unfortunately we can only check MAGIC_COMMIT...MAGIC_COMMIT, + // so this is always gonna be (0, 0). + assert_eq!(counts, (0, 0)); + } + + /// Tests that get_hash is correct. + #[test] + fn test_get_hash() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + let hash = git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?; + repo.get_hash(MAGIC_BRANCH) + }).unwrap(); + let hashtoo = git.get_hash(MAGIC_BRANCH).unwrap(); + assert_eq!(hash, MAGIC_COMMIT); + assert_eq!(hash, hashtoo); + } + + /// Tests that get_message is correct. + #[test] + fn test_get_message() { + let dir = TempDir::new().unwrap(); + let repo = dir.child("ganarchy-cache.git"); + let mut git = Git::at_path(Initializer, repo.path()).unwrap(); + git.ensure_exists().unwrap(); + let server = serve(REPO); + let addr = format!("http://{}:{}/", testserver::IP, server.port()); + let msg = git.with_work_repos(1, |repos| { + let repo = &mut repos[0]; + repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?; + repo.get_message(MAGIC_COMMIT) + }).unwrap(); + let msgtoo = git.get_message(MAGIC_COMMIT).unwrap(); + let expect = "[Project] Example Project\n\ + \n\ + This is an example GAnarchy project.\n"; + assert_eq!(msg, expect); + assert_eq!(msg, msgtoo); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5291931 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +// GAnarchy - decentralized development hub +// Copyright (C) 2021 Soni L. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +mod git; +mod util; +//mod system; +mod marker; + +#[cfg(test)] +mod test_util; diff --git a/src/marker.rs b/src/marker.rs new file mode 100644 index 0000000..3537f59 --- /dev/null +++ b/src/marker.rs @@ -0,0 +1,23 @@ +// This file is part of GAnarchy - decentralized development hub +// Copyright (C) 2021 Soni L. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +/// A marker struct for initialization operations. +/// +/// This is like a "custom unsafe". It's to mark things that shouldn't be used +/// outside of initialization code. It serves as a lint to identify code that +/// shouldn't be used inside application logic. +#[derive(Copy, Clone)] +pub struct Initializer; diff --git a/src/test_util.rs b/src/test_util.rs new file mode 100644 index 0000000..b2fa29f --- /dev/null +++ b/src/test_util.rs @@ -0,0 +1,19 @@ +// This file is part of GAnarchy - decentralized development hub +// Copyright (C) 2021 Soni L. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Collection of utilities that are useful for tests. + +// uh yeah there's nothing here currently. diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..0b96bde --- /dev/null +++ b/src/util.rs @@ -0,0 +1,108 @@ +// This file is part of GAnarchy - decentralized development hub +// Copyright (C) 2021 Soni L. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +//! Some utilities. + +/// A helper for checking if a name is fit for a certain purpose. +pub enum NamePurpose { + /// A `CacheRepo` is the main SHA-1 git repo, used as a cache. + CacheRepo, + /// A `CacheRepo64` is the main SHA-2 git repo, used as a cache. + CacheRepo64, + /// A `WorkRepo` is a SHA-1 Git repo that isn't the main repo, used for + /// fetching from remote branches. + WorkRepo, + /// A `WorkRepo64` is a SHA-1 Git repo that isn't the main repo, used for + /// fetching from remote branches. + WorkRepo64, + /// A `CacheBranch` is a main branch in the main repo, used as a cache. + CacheBranch, + /// A `CacheFetchHead` is a work branch in the main repo, used for fetching + /// from a work repo. + CacheFetchHead, + /// A `WorkBranch` is a branch in a work repo, used for fetching from + /// remote branches. + WorkBranch, + /// A `Commit` is a 160-bit SHA-1 hash, 40 characters long. + Commit, + /// A `Commit64` is a 256-bit SHA-2 hash, 64 characters long. + Commit64, +} + +impl NamePurpose { + /// Checks if the given name is fit for this purpose. + pub fn is_fit(&self, name: &str) -> bool { + use NamePurpose::*; + /// Checks if the value is within the range '0'..='9'. + fn is_digit(c: char) -> bool { + c.is_ascii_digit() + } + /// Checks if the value is within the ranges '0'..='9' or 'a'..='f'. + fn is_hex(c: char) -> bool { + // numbers aren't lowercase, unfortunately. + c.is_ascii_hexdigit() && !c.is_ascii_uppercase() + } + match self { + CacheRepo => name == "ganarchy-cache.git", + CacheRepo64 => name == "ganarchy-cache64.git", + WorkRepo => { + // "ganarchy-fetch-[0-9][0-9]*\.git" + Some(name) + .and_then(|x| x.strip_prefix("ganarchy-fetch-")) + .and_then(|x| x.strip_suffix(".git")) + .and_then(|x| x.strip_prefix(is_digit)) + .map(|x| x.trim_matches(is_digit)) + == Some("") + }, + WorkRepo64 => { + // "ganarchy-fetch-[0-9][0-9]*\.git" + Some(name) + .and_then(|x| x.strip_prefix("ganarchy-fetch64-")) + .and_then(|x| x.strip_suffix(".git")) + .and_then(|x| x.strip_prefix(is_digit)) + .map(|x| x.trim_matches(is_digit)) + == Some("") + }, + CacheBranch => { + // "gan[0-9a-f]{64}" + (0..64) + .try_fold(name, |x, _| x.strip_suffix(is_hex)) + == Some("gan") + }, + CacheFetchHead => { + // CacheBranch + "-[0-9]*[0-9]" + name.strip_suffix(is_digit) + .map(|x| x.trim_end_matches(is_digit)) + .and_then(|x| x.strip_suffix("-")) + .filter(|x| CacheBranch.is_fit(x)) + .is_some() + }, + WorkBranch => CacheBranch.is_fit(name), // same as CacheBranch + Commit => { + // "[0-9a-f]{40}" + (0..40) + .try_fold(name, |x, _| x.strip_suffix(is_hex)) + == Some("") + }, + Commit64 => { + // "[0-9a-f]{64}" + (0..64) + .try_fold(name, |x, _| x.strip_suffix(is_hex)) + == Some("") + }, + } + } +} |