diff --git a/spikes/spike7-git-push/.env.example b/spikes/spike7-git-push/.env.example new file mode 100644 index 0000000..6fe458e --- /dev/null +++ b/spikes/spike7-git-push/.env.example @@ -0,0 +1,19 @@ +# Spike 7 (desktop) config. Copy to `.env` and `set -a; . ./.env; set +a` before +# running against a real GitHub repo. `.env` is gitignored. +# +# The working copy to publish. On device this is /sd/repo; on desktop, any +# clone. Pass as argv[1] or here. +TW_REPO_PATH=/tmp/spike7-work + +# HTTPS + PAT auth (ADR-005). Leave both empty for a local file:// remote — +# no credentials are needed there. Never logged. +TW_GH_USER=your-github-username +TW_PAT=ghp_xxxxxxxxxxxxxxxxxxxx + +# Commit author (ADR: author from config). Defaults applied if unset. +TW_AUTHOR_NAME=Typoena +TW_AUTHOR_EMAIL=typoena@example.com + +# Optional: force origin's URL (e.g. the HTTPS remote baked in at build time). +# If unset, the working copy's existing `origin` is used as-is. +# TW_REMOTE_URL=https://github.com/you/typoena-test.git diff --git a/spikes/spike7-git-push/.gitignore b/spikes/spike7-git-push/.gitignore new file mode 100644 index 0000000..fedaa2b --- /dev/null +++ b/spikes/spike7-git-push/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/spikes/spike7-git-push/Cargo.lock b/spikes/spike7-git-push/Cargo.lock new file mode 100644 index 0000000..74db600 --- /dev/null +++ b/spikes/spike7-git-push/Cargo.lock @@ -0,0 +1,770 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spike7-git-push" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "git2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/spikes/spike7-git-push/Cargo.toml b/spikes/spike7-git-push/Cargo.toml new file mode 100644 index 0000000..2ceb511 --- /dev/null +++ b/spikes/spike7-git-push/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "spike7-git-push" +version = "0.1.0" +edition = "2021" +publish = false +description = "Spike 7 (desktop half) — libgit2 add/commit/push over HTTPS+PAT" + +[dependencies] +# libgit2 via the vendored C build — this is the ADR-004 kill-switch path. +# gix was the first choice but cannot push over HTTPS yet (see README), so +# Spike 7 proves the fallback the risk table names: libgit2-sys / git2. +git2 = "0.20" +anyhow = "1" +# Only for the ISO-8601 commit-message timestamp (the message *is* the time, +# per the git module spec). On device this comes from SNTP instead. +chrono = { version = "0.4", default-features = false, features = ["clock"] } diff --git a/spikes/spike7-git-push/README.md b/spikes/spike7-git-push/README.md new file mode 100644 index 0000000..9345e31 --- /dev/null +++ b/spikes/spike7-git-push/README.md @@ -0,0 +1,92 @@ +# Spike 7 — git push (desktop half) + +> Bring-up spike 7 in +> [`../../docs/v0.1-mvp-technical.md`](../../docs/v0.1-mvp-technical.md#hardware-bring-up-order). +> Decision context: [ADR-004](../../docs/adr.md#adr-004-git-implementation--gitoxide-gix) +> (git impl) and [ADR-005](../../docs/adr.md#adr-005-auth--https--github-personal-access-token) +> (auth). Full write-up: +> [`../../docs/postmortems/2026-07-05-spike7-gix-https-push.md`](../../docs/postmortems/2026-07-05-spike7-gix-https-push.md). + +Spike 7 proves the `add → commit → push` sequence the on-device `git` module +will run. Per the technical doc it's **desktop-Rust first, then on device** — +this crate is the desktop half. It is a **host** program (plain `stable` +toolchain), deliberately kept out of the xtensa-pinned `firmware/` crate. + +## Headline finding: the ADR-004 kill-switch fired + +Spike 7 is the documented kill-switch for [ADR-004](../../docs/adr.md): *"if +[gix smart-HTTP push] fails on the device, we fall back to `libgit2-sys`."* +It fires at the **library level**, before any device work: gitoxide's own +crate-status doc states `gix` supports push only over `file://` and `ssh://` — +**push over HTTP(S) is not implemented** (only clone/fetch are). Since +[ADR-005](../../docs/adr.md) fixes auth as HTTPS + PAT, `gix` cannot satisfy the +push path today. So this spike uses the fallback the risk table names: +`libgit2` via the [`git2`](https://docs.rs/git2) crate. + +## What it does + +Mirrors the v0.1 `git` module contract: + +1. open the working copy +2. stage with `git add --all` semantics (**deletions propagate** — needed for + v0.5 file-delete) +3. short-circuit when nothing is staged → "nothing to publish" +4. commit; author from config, message = an ISO-8601 timestamp (the time *is* + the message) +5. push `HEAD` to `origin/` over HTTPS, PAT in the credential callback + (**never logged**) +6. on push rejection (remote moved): fetch + `pull --no-edit` (fast-forward or a + clean merge), then retry the push once; merge conflicts are fatal (surfaced, + never auto-resolved) + +## Verified (2026-07-05) + +Run live against a local `file://` bare remote (no credentials): + +- **first commit + push** from an unborn `HEAD` → lands in origin ✅ +- **nothing to publish** short-circuits when the index matches `HEAD` ✅ +- **divergence**: a second clone advances origin → push rejected → `pull + --no-edit` merges cleanly → retry push succeeds, origin gets a two-parent + merge commit ✅ + +- **real HTTPS + PAT push to github.com** — confirmed 2026-07-05 against + `jcalixte/typoena-test`: `committed → push accepted by remote`, the commit + landed on GitHub. The `git2` build links `openssl-sys` for the TLS transport. + +Still not exercised: a **non-fast-forward rejection over HTTPS** (the +`push_update_reference` callback path) — the local `file://` transport surfaced +rejection as a `push()` error instead, and the GitHub push above was a clean +fast-forward. The callback path is coded for but unproven live. + +## Not proven here (the next gate) + +The risk moved *with* the kill-switch: **can `libgit2` cross-compile to +`xtensa-esp32s3-espidf` against esp-idf's mbedtls?** — the exact C cross-compile +pain gix was chosen to avoid. That is the next on-device spike, and it also +needs PSRAM (`CONFIG_SPIRAM`) enabled and a working SD card (Spike 3) for the +`/sd/repo` working copy. This desktop pass de-risks the API + git mechanics +only. + +## Run + +Local remote — proves the mechanics, no secrets: + +```sh +mkdir -p /tmp/s7 && cd /tmp/s7 +git init -q --bare origin.git && git clone -q origin.git work +echo hi > work/notes.md +cargo run --manifest-path /Cargo.toml -- "$PWD/work" +``` + +Real GitHub repo — proves HTTPS + PAT (use a throwaway repo + a `repo`-scoped +fine-grained PAT): + +```sh +cp .env.example .env # fill TW_GH_USER / TW_PAT / TW_REPO_PATH +set -a; . ./.env; set +a +cargo run -- "$TW_REPO_PATH" +``` + +`TW_REPO_PATH` must be a clone whose `origin` is the HTTPS URL (or set +`TW_REMOTE_URL` to point `origin` there). The PAT is passed to libgit2's +credential callback and never printed. diff --git a/spikes/spike7-git-push/src/main.rs b/spikes/spike7-git-push/src/main.rs new file mode 100644 index 0000000..09bf950 --- /dev/null +++ b/spikes/spike7-git-push/src/main.rs @@ -0,0 +1,328 @@ +//! Spike 7 (desktop half) — `libgit2` add → commit → push over HTTPS + PAT. +//! +//! ## Why libgit2 and not gitoxide +//! +//! [ADR-004] chose `gix` and named Spike 7 its kill-switch: "if [gix smart-HTTP +//! push] fails on the device, we fall back to `libgit2-sys`." Before writing a +//! line of gix code, gitoxide's own crate-status doc settles it — gix has +//! send-pack *plumbing* but supports push only over `file://` and `ssh://`; +//! **push over HTTP(S) is not implemented**. Since [ADR-005] fixes auth as +//! HTTPS + PAT, the kill-switch condition is met at the library level, so this +//! spike proves the fallback: `libgit2` via the `git2` crate. +//! +//! ## What this proves (desktop) +//! +//! The exact sequence the on-device `git` module will run (v0.1 technical → +//! `git` module), against a real repository: +//! +//! 1. open the working copy +//! 2. stage everything — `git add --all` semantics, so deletions propagate +//! too (matters for v0.5 file-delete → next Publish's staged set) +//! 3. short-circuit when nothing is staged ("nothing to publish") +//! 4. commit with the configured author, message = an ISO-8601 timestamp +//! 5. push HEAD to `origin/` over HTTPS with a PAT in the credential +//! callback (never logged) +//! 6. on push rejection (remote moved): fetch + `pull --no-edit` +//! (fast-forward or a clean merge), then retry the push once +//! +//! ## What it does NOT prove +//! +//! The real remaining risk moved with the kill-switch: **can libgit2 +//! cross-compile to `xtensa-esp32s3-espidf` against esp-idf's mbedtls?** That +//! is the next on-device gate — the very cross-compile pain gix was picked to +//! avoid. This desktop pass de-risks the *API and the git mechanics*; the +//! device build is a separate spike. +//! +//! ## Run +//! +//! Local remote (no credentials — proves the mechanics end to end): +//! cargo run -- /path/to/working-copy # origin is a file:// bare repo +//! +//! Real GitHub repo (proves HTTPS + PAT): +//! set -a; . ./.env; set +a +//! cargo run -- "$TW_REPO_PATH" +//! +//! [ADR-004]: ../../docs/adr.md +//! [ADR-005]: ../../docs/adr.md + +use std::cell::RefCell; +use std::rc::Rc; + +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use git2::{ + build::CheckoutBuilder, Commit, Cred, CredentialType, FetchOptions, IndexAddOption, + PushOptions, RemoteCallbacks, Repository, Signature, +}; + +/// Runtime config, mirroring the build-time `TW_*` env the firmware bakes in. +struct Config { + repo_path: String, + /// GitHub username + PAT for HTTPS basic auth. Both `None` → a local + /// (`file://`) remote that needs no credentials. + gh_user: Option, + pat: Option, + author_name: String, + author_email: String, + /// Optional override for `origin`'s URL (the HTTPS remote baked in at build + /// time). Unset → use the working copy's existing `origin` as-is. + remote_url: Option, +} + +impl Config { + fn from_env_and_args() -> Result { + let repo_path = std::env::args() + .nth(1) + .or_else(|| non_empty("TW_REPO_PATH")) + .context("no working copy: pass a path as argv[1] or set TW_REPO_PATH")?; + Ok(Self { + repo_path, + gh_user: non_empty("TW_GH_USER"), + pat: non_empty("TW_PAT"), + author_name: non_empty("TW_AUTHOR_NAME").unwrap_or_else(|| "Typoena".into()), + author_email: non_empty("TW_AUTHOR_EMAIL") + .unwrap_or_else(|| "typoena@example.com".into()), + remote_url: non_empty("TW_REMOTE_URL"), + }) + } + + fn signature(&self) -> Result> { + Signature::now(&self.author_name, &self.author_email).context("building commit signature") + } +} + +/// An env var, treated as absent when empty (build.rs emits "" for unset vars, +/// so the firmware uses the same convention). +fn non_empty(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + +fn main() { + match run() { + Ok(summary) => println!("✅ Spike 7 (desktop) complete — {summary}"), + Err(e) => { + eprintln!("❌ Spike 7 (desktop) failed: {e:?}"); + std::process::exit(1); + } + } +} + +fn run() -> Result { + let cfg = Config::from_env_and_args()?; + let repo = Repository::open(&cfg.repo_path) + .with_context(|| format!("opening working copy at {}", cfg.repo_path))?; + log(&format!("opened {}", cfg.repo_path)); + + if let Some(url) = &cfg.remote_url { + ensure_origin(&repo, url)?; + } + + // Stage + commit, or short-circuit. + let branch = match commit_if_changes(&repo, &cfg)? { + Some(b) => b, + None => return Ok("nothing to publish (index matches HEAD)".into()), + }; + let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); + + // Push, with one pull-and-retry on rejection (the remote-moved case). + match push_once(&repo, &cfg, &refspec) { + Ok(()) => Ok(format!("pushed {branch} to origin")), + Err(first) => { + log(&format!( + "push rejected ({first}); pull --no-edit then retry once" + )); + pull_no_edit(&repo, &cfg, &branch).context("pull --no-edit after rejected push")?; + push_once(&repo, &cfg, &refspec).context("retry push after pull")?; + Ok(format!("pushed {branch} to origin (after pull + merge)")) + } + } +} + +/// Point `origin` at `url`, creating the remote if the working copy has none. +fn ensure_origin(repo: &Repository, url: &str) -> Result<()> { + match repo.find_remote("origin") { + Ok(_) => repo + .remote_set_url("origin", url) + .context("remote_set_url origin")?, + Err(_) => { + repo.remote("origin", url) + .context("creating origin remote")?; + } + } + log(&format!("origin → {url}")); + Ok(()) +} + +/// Stage everything and commit, or return `None` if the index already matches +/// HEAD (nothing to publish). Returns the branch shorthand on commit. +fn commit_if_changes(repo: &Repository, cfg: &Config) -> Result> { + // `add_all(["*"], …)` is libgit2's `git add --all `: it stages + // new + modified + **deleted** paths, which plain `git add .` would miss on + // removals. The git module needs this for v0.5 file-delete. + let mut index = repo.index().context("opening index")?; + index + .add_all(["*"], IndexAddOption::DEFAULT, None) + .context("staging (add --all)")?; + index.write().context("writing index")?; + let tree_oid = index.write_tree().context("writing tree from index")?; + + // Parent = current HEAD, or None on an unborn branch (fresh repo). + let parent: Option = match repo.head() { + Ok(h) => Some(h.peel_to_commit().context("resolving HEAD to a commit")?), + Err(_) => None, + }; + + // Nothing to publish: index tree == HEAD tree (or empty tree, unborn repo). + match &parent { + Some(p) if p.tree_id() == tree_oid => return Ok(None), + None if repo.find_tree(tree_oid)?.is_empty() => return Ok(None), + _ => {} + } + + let tree = repo.find_tree(tree_oid)?; + let sig = cfg.signature()?; + let message = Utc::now().to_rfc3339(); // the ISO-8601 timestamp *is* the message + let parents: Vec<&Commit> = parent.iter().collect(); + repo.commit( + Some("HEAD"), + &sig, + &sig, + &message, + &tree, + parents.as_slice(), + ) + .context("committing")?; + + let branch = repo + .head()? + .shorthand() + .context("HEAD has no branch shorthand (detached?)")? + .to_string(); + log(&format!("committed to {branch}: \"{message}\"")); + Ok(Some(branch)) +} + +/// Push `refspec` to `origin`. Errors both on transport failure and on a +/// server-side ref rejection (non-fast-forward) — the caller pulls and retries. +fn push_once(repo: &Repository, cfg: &Config, refspec: &str) -> Result<()> { + let mut remote = repo + .find_remote("origin") + .context("remote 'origin' not found — is the working copy a clone?")?; + + // Server-side per-ref status arrives via this callback, NOT as a push() + // error, so record any non-None status and treat it as a failure below. + let rejections = Rc::new(RefCell::new(Vec::::new())); + let mut cbs = RemoteCallbacks::new(); + bind_credentials(&mut cbs, cfg); + { + let rejections = rejections.clone(); + cbs.push_update_reference(move |refname, status| { + if let Some(msg) = status { + rejections.borrow_mut().push(format!("{refname}: {msg}")); + } + Ok(()) + }); + } + + let mut opts = PushOptions::new(); + opts.remote_callbacks(cbs); + remote + .push(&[refspec], Some(&mut opts)) + .context("push transport")?; + + let rej = rejections.borrow(); + if !rej.is_empty() { + bail!("remote rejected: {}", rej.join("; ")); + } + log("push accepted by remote"); + Ok(()) +} + +/// `git pull --no-edit`: fetch `origin/`, then fast-forward or make a +/// merge commit. Bails on conflicts — the device surfaces that, never auto- +/// resolves. +fn pull_no_edit(repo: &Repository, cfg: &Config, branch: &str) -> Result<()> { + let mut remote = repo.find_remote("origin")?; + let mut cbs = RemoteCallbacks::new(); + bind_credentials(&mut cbs, cfg); + let mut fo = FetchOptions::new(); + fo.remote_callbacks(cbs); + remote + .fetch(&[branch], Some(&mut fo), None) + .context("fetch origin")?; + + let fetch_head = repo + .find_reference("FETCH_HEAD") + .context("no FETCH_HEAD after fetch")?; + let fetched = repo.reference_to_annotated_commit(&fetch_head)?; + let (analysis, _) = repo.merge_analysis(&[&fetched])?; + + if analysis.is_up_to_date() { + log("already up to date with origin"); + return Ok(()); + } + + if analysis.is_fast_forward() { + let refname = format!("refs/heads/{branch}"); + repo.find_reference(&refname)? + .set_target(fetched.id(), "spike7: fast-forward")?; + repo.set_head(&refname)?; + repo.checkout_head(Some(CheckoutBuilder::new().force()))?; + log("fast-forwarded to origin"); + return Ok(()); + } + + // True divergence → merge. Conflicts are fatal here (no auto-resolve). + repo.merge(&[&fetched], None, None).context("merge")?; + let mut idx = repo.index()?; + if idx.has_conflicts() { + repo.cleanup_state().ok(); + bail!("merge conflicts on pull — resolve manually; Publish will not auto-resolve"); + } + let tree = repo.find_tree(idx.write_tree()?)?; + let sig = cfg.signature()?; + let head_commit = repo.head()?.peel_to_commit()?; + let their_commit = repo.find_commit(fetched.id())?; + repo.commit( + Some("HEAD"), + &sig, + &sig, + &format!("Merge origin/{branch}"), + &tree, + &[&head_commit, &their_commit], + ) + .context("creating merge commit")?; + repo.cleanup_state()?; + log("merged origin (no conflicts)"); + Ok(()) +} + +/// Wire the credential callback: HTTPS → PAT (user/pass plaintext), local +/// remotes → default. The PAT is handed to libgit2 but never printed. +fn bind_credentials<'a>(cbs: &mut RemoteCallbacks<'a>, cfg: &'a Config) { + cbs.credentials(move |_url, username_from_url, allowed| { + // GitHub over HTTPS asks for USER_PASS_PLAINTEXT; the PAT is the + // password (any non-empty username works, we send the configured one). + if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) { + if let (Some(user), Some(pat)) = (cfg.gh_user.as_deref(), cfg.pat.as_deref()) { + return Cred::userpass_plaintext(user, pat); + } + } + // file:// / already-authenticated transports need nothing. + if allowed.contains(CredentialType::DEFAULT) { + return Cred::default(); + } + if let Some(user) = username_from_url { + return Cred::username(user); + } + Err(git2::Error::from_str( + "no usable credentials — set TW_GH_USER and TW_PAT for an HTTPS remote", + )) + }); +} + +/// Step log to stdout. Deliberately plain (this is a bench tool, not firmware); +/// it never touches `cfg.pat`. +fn log(msg: &str) { + println!("spike7: {msg}"); +}