summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--Cargo.lock590
-rw-r--r--Cargo.toml15
-rw-r--r--HACKING.md4
-rw-r--r--ganarchy/__init__.py16
-rw-r--r--ganarchy/__main__.py29
-rw-r--r--ganarchy/cli/__init__.py27
-rw-r--r--ganarchy/cli/db.py71
-rw-r--r--ganarchy/cli/debug.py112
-rw-r--r--ganarchy/cli/merge_configs.py25
-rw-r--r--ganarchy/cli/run_targets.py231
-rw-r--r--ganarchy/core.py286
-rw-r--r--ganarchy/data.py565
-rw-r--r--ganarchy/db.py369
-rw-r--r--ganarchy/dirs.py53
-rw-r--r--ganarchy/git.py173
-rw-r--r--ganarchy/templating/__init__.py21
-rw-r--r--ganarchy/templating/environment.py31
-rw-r--r--ganarchy/templating/templates.py128
-rw-r--r--ganarchy/templating/toml.py33
-rw-r--r--index.js29
-rw-r--r--requirements.txt12
-rw-r--r--requirements_test.txt21
-rw-r--r--setup.py5
-rw-r--r--setup_env.bash3
-rw-r--r--setup_testenv.bash4
-rw-r--r--src/git.rs728
-rw-r--r--src/git/tests.rs389
-rw-r--r--src/lib.rs23
-rw-r--r--src/marker.rs23
-rw-r--r--src/test_util.rs19
-rw-r--r--src/util.rs108
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("")
+            },
+        }
+    }
+}