diff --git a/.github/workflows/theseus-engine.yml b/.github/workflows/theseus-engine.yml index d8050b7..3b64227 100644 --- a/.github/workflows/theseus-engine.yml +++ b/.github/workflows/theseus-engine.yml @@ -4,6 +4,21 @@ on: schedule: - cron: "0 0 1 * *" workflow_dispatch: + inputs: + reprocess: + description: 'Reprocess state. Type "all" to wipe and rebuild completely, or "last" to re-calculate the most recent period.' + required: false + type: choice + options: + - "" + - "last" + - "all" + default: "" + full_fossil_scan: + description: 'Run a full deep-scan for fossils (uncheck to only do a fast survivor update)' + required: false + type: boolean + default: false concurrency: group: theseus-data-engine @@ -43,12 +58,30 @@ jobs: - name: Install dependencies run: poetry install --no-interaction --no-root + - name: Rust Cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: "engine -> target" + + - name: Build engine + working-directory: engine + run: cargo build --release + - name: Run pipeline for ${{ matrix.repo }} id: pipeline continue-on-error: true env: REPO_NAME: ${{ matrix.repo }} - run: poetry run python -m scripts.run_pipeline --repo "$REPO_NAME" --update-survivor + REPROCESS_INPUT: ${{ inputs.reprocess }} + run: | + ARGS=() + if [ -n "$REPROCESS_INPUT" ]; then + ARGS+=(--reprocess "$REPROCESS_INPUT") + fi + if [ "${{ inputs.full_fossil_scan }}" != "true" ]; then + ARGS+=(--update-survivor) + fi + poetry run python -m scripts.run_pipeline --repo "$REPO_NAME" "${ARGS[@]}" timeout-minutes: 120 - name: Save status @@ -64,7 +97,9 @@ jobs: with: name: data-${{ matrix.repo }} path: | - data/raw/${{ matrix.repo }}_data.json + data/raw/${{ matrix.repo }}_history.jsonl + data/raw/${{ matrix.repo }}_fossils.json + data/state/${{ matrix.repo }}_state.json data/processed/${{ matrix.repo }}_graph.json data/.status/${{ matrix.repo }}.json retention-days: 1 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ad34ec5..e917cd6 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -28,9 +28,25 @@ jobs: - name: Install dependencies run: poetry install --with dev - - name: Run linter + - name: Run Python linter run: poetry run pylint scripts/ --output-format=colorized continue-on-error: true - - name: Run tests + - name: Run Python tests run: poetry run pytest tests/ -v --tb=short + + - name: Rust Cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: "engine -> target" + + - name: Run Rust Lints & Formatting + working-directory: engine + run: | + cargo fmt --check + cargo clippy -- -D warnings + continue-on-error: true + + - name: Run Rust tests + working-directory: engine + run: cargo test diff --git a/build_engine.bat b/build_engine.bat new file mode 100644 index 0000000..8a9d3bf --- /dev/null +++ b/build_engine.bat @@ -0,0 +1,19 @@ +@echo off +set CARGO_CMD=cargo +where cargo >nul 2>nul +if %errorlevel% neq 0 ( + set CARGO_CMD="%USERPROFILE%\.cargo\bin\cargo.exe" +) + +echo Building Theseus Engine (Release mode)... +pushd "%~dp0engine" || exit /b 1 +%CARGO_CMD% build --release +set "EXIT_CODE=%errorlevel%" +popd +if %EXIT_CODE% neq 0 ( + echo Build failed! Make sure Rust is installed and in your PATH. + pause + exit /b %EXIT_CODE% +) +echo Build successful! The binary is located at engine\target\release\engine.exe +pause diff --git a/engine/Cargo.lock b/engine/Cargo.lock new file mode 100644 index 0000000..9dee29b --- /dev/null +++ b/engine/Cargo.lock @@ -0,0 +1,838 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +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", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "engine" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "env_logger", + "git2", + "log", + "rayon", + "serde", + "serde_json", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "git2" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" +dependencies = [ + "bitflags 2.13.1", + "libc", + "libgit2-sys", + "log", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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 = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libgit2-sys" +version = "0.18.7+1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[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 = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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 = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "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 2.0.119", + "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 2.0.119", +] + +[[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 2.0.119", +] + +[[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 = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/engine/Cargo.toml b/engine/Cargo.toml new file mode 100644 index 0000000..b5bae8f --- /dev/null +++ b/engine/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "engine" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = "0.4.45" +env_logger = "0.11.11" +git2 = "0.21.0" +log = "0.4.33" +rayon = "1.12.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +clap = { version = "4.5", features = ["derive"] } diff --git a/engine/src/main.rs b/engine/src/main.rs new file mode 100644 index 0000000..cf2227e --- /dev/null +++ b/engine/src/main.rs @@ -0,0 +1,326 @@ +use clap::Parser; +use log::{error, info, warn}; +use rayon::prelude::*; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::exit; +use std::time::Instant; + +use chrono::{TimeZone, Utc}; +use git2::{BlameOptions, ObjectType, Oid, Repository, Tree}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[arg(short, long)] + repo_path: PathBuf, + + #[arg(short, long)] + output: PathBuf, + + #[arg(short, long)] + state: PathBuf, + + #[arg(long)] + reprocess: Option, +} + +#[derive(Serialize, serde::Deserialize)] +struct SnapshotData { + snapshot_date: String, + commit_hash: String, + composition: HashMap, +} + +type FileCompositions = HashMap>; + +fn get_snapshot_periods(repo_path: &Path) -> Result, git2::Error> { + let repo = Repository::open(repo_path)?; + let mut revwalk = repo.revwalk()?; + revwalk.push_head()?; + revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?; + + let mut periods: HashMap = HashMap::new(); + + for oid_res in revwalk { + let oid = oid_res?; + let commit = repo.find_commit(oid)?; + let time = commit.committer().when(); + let datetime = Utc.timestamp_opt(time.seconds(), 0).unwrap(); + let period = datetime.format("%Y-%m").to_string(); + + // Overwrites so the LAST commit in chronological order for that month is kept + periods.insert(period, oid); + } + + let mut filtered = Vec::new(); + let quarterly_months = ["03", "06", "09", "12"]; + + for (period, oid) in periods { + let year: i32 = period[..4].parse().unwrap_or(0); + let month = &period[5..7]; + + if year >= 2025 { + filtered.push((period, oid)); + } else if quarterly_months.contains(&month) { + filtered.push((period, oid)); + } + } + + filtered.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(filtered) +} + +fn get_tracked_files(repo: &Repository, tree: &Tree) -> Result, git2::Error> { + let mut files = HashSet::new(); + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + if entry.kind() == Some(ObjectType::Blob) { + let path = format!("{}{}", root, entry.name().unwrap_or("")); + + // Check if binary or empty + if let Ok(blob) = repo.find_blob(entry.id()) { + if !blob.is_binary() && blob.size() > 0 { + files.insert(path); + } + } + } + git2::TreeWalkResult::Ok + })?; + Ok(files) +} + +fn get_changed_files( + repo: &Repository, + old_tree: &Tree, + new_tree: &Tree, +) -> Result, git2::Error> { + let mut diffopts = git2::DiffOptions::new(); + let diff = repo.diff_tree_to_tree(Some(old_tree), Some(new_tree), Some(&mut diffopts))?; + + let mut changed_files = HashSet::new(); + diff.print(git2::DiffFormat::NameOnly, |delta, _, _| { + if let Some(path) = delta.new_file().path() { + if let Some(path_str) = path.to_str() { + changed_files.insert(path_str.to_string()); + } + } + true + })?; + Ok(changed_files) +} + +fn process_blame( + repo_path: &Path, + commit_oid: Oid, + files_to_blame: Vec, +) -> FileCompositions { + let results: Vec<(String, HashMap)> = files_to_blame + .into_par_iter() + .map_init( + || Repository::open(repo_path).ok(), + |repo_opt, path| { + let repo = repo_opt.as_ref()?; + let mut opts = BlameOptions::new(); + opts.newest_commit(commit_oid); + + let blame = repo.blame_file(Path::new(&path), Some(&mut opts)).ok()?; + + let mut year_counts = HashMap::new(); + for hunk in blame.iter() { + let time = hunk.final_signature().when(); + let datetime = Utc.timestamp_opt(time.seconds(), 0).unwrap(); + let year = datetime.format("%Y").to_string(); + let lines = hunk.lines_in_hunk() as u32; + + *year_counts.entry(year).or_insert(0) += lines; + } + + Some((path, year_counts)) + }, + ) + .filter_map(|x| x) + .collect(); + + results.into_iter().collect() +} + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let args = Args::parse(); + + if !args.repo_path.exists() { + error!("Repository path does not exist: {:?}", args.repo_path); + exit(1); + } + + info!("Starting Theseus Engine for {:?}", args.repo_path); + + let repo_path = args.repo_path.clone(); + let output_path = args.output.clone(); + + let periods = get_snapshot_periods(&repo_path).unwrap_or_else(|e| { + error!("Failed to get snapshot periods: {}", e); + exit(1); + }); + + info!("Found {} snapshot periods to process.", periods.len()); + + let mut prev_tree: Option = None; + let mut file_compositions: FileCompositions = HashMap::new(); + + // Make sure output directory exists + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + + let reprocess_val = args.reprocess.as_deref().unwrap_or(""); + let mut is_append = false; + let mut processed_periods = HashSet::new(); + + if reprocess_val != "all" && output_path.exists() { + is_append = true; + use std::io::{BufRead, BufReader}; + if let Ok(file) = std::fs::File::open(&output_path) { + let reader = BufReader::new(file); + for line in reader.lines().flatten() { + if let Ok(snap) = serde_json::from_str::(&line) { + processed_periods.insert(snap.snapshot_date); + } + } + } + } + + if reprocess_val == "last" && !processed_periods.is_empty() { + if let Some(max_period) = processed_periods.iter().max().cloned() { + processed_periods.remove(&max_period); + } + } else if reprocess_val != "all" && !reprocess_val.is_empty() { + processed_periods.remove(reprocess_val); + } + + if is_append && args.state.exists() { + if let Ok(file) = std::fs::File::open(&args.state) { + if let Ok(state) = serde_json::from_reader(file) { + file_compositions = state; + } + } + } + + // Truncate if we are starting fresh (or reprocess=all) + if !is_append && output_path.exists() { + let _ = std::fs::remove_file(&output_path); + } else if is_append && output_path.exists() { + // Rewrite output file to remove lines that we are reprocessing + use std::io::{BufRead, BufReader}; + let mut valid_lines = Vec::new(); + if let Ok(file) = std::fs::File::open(&output_path) { + let reader = BufReader::new(file); + for line in reader.lines().flatten() { + if let Ok(snap) = serde_json::from_str::(&line) { + if processed_periods.contains(&snap.snapshot_date) { + valid_lines.push(line); + } + } + } + } + if let Ok(mut temp_out) = std::fs::File::create(&output_path) { + for line in valid_lines { + let _ = writeln!(temp_out, "{}", line); + } + } + } + + let mut out_file = OpenOptions::new() + .create(true) + .write(true) + .append(true) + .open(&output_path) + .unwrap(); + + let start_overall = Instant::now(); + + for (idx, (period, commit_oid)) in periods.iter().enumerate() { + let repo = Repository::open(&repo_path).unwrap(); + let curr_commit = repo.find_commit(*commit_oid).unwrap(); + let curr_tree = curr_commit.tree().unwrap(); + + if processed_periods.contains(period) { + info!( + "[{}/{}] Period {} already processed. Skipping.", + idx + 1, + periods.len(), + period + ); + prev_tree = Some(curr_tree); + continue; + } + + let tracked_files = get_tracked_files(&repo, &curr_tree).unwrap(); + + let files_to_blame = if let Some(old_tree) = &prev_tree { + let changed = get_changed_files(&repo, old_tree, &curr_tree).unwrap(); + // Retain compositions only for files still tracked and not changed + file_compositions.retain(|f, _| tracked_files.contains(f) && !changed.contains(f)); + + // Files to blame are changed files that are still tracked + changed + .into_iter() + .filter(|f| tracked_files.contains(f)) + .collect::>() + } else { + // No previous tree, blame all tracked files + tracked_files.into_iter().collect::>() + }; + + info!( + "[{}/{}] Period {}: blaming {} files...", + idx + 1, + periods.len(), + period, + files_to_blame.len() + ); + + let start_blame = Instant::now(); + let new_compositions = process_blame(&repo_path, *commit_oid, files_to_blame); + file_compositions.extend(new_compositions); + + // Aggregate + let mut age_distribution: HashMap = HashMap::new(); + for f_comp in file_compositions.values() { + for (year, count) in f_comp { + *age_distribution.entry(year.clone()).or_insert(0) += count; + } + } + + let total_lines: u32 = age_distribution.values().sum(); + info!( + "Period {} done in {:.2?}. Total lines: {}", + period, + start_blame.elapsed(), + total_lines + ); + + let snapshot = SnapshotData { + snapshot_date: period.clone(), + commit_hash: commit_oid.to_string(), + composition: age_distribution, + }; + + let json = serde_json::to_string(&snapshot).unwrap(); + writeln!(out_file, "{}", json).unwrap(); + + if let Some(parent) = args.state.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(state_file) = std::fs::File::create(&args.state) { + let _ = serde_json::to_writer(state_file, &file_compositions); + } + + prev_tree = Some(curr_tree); + } + + info!("Engine finished in {:.2?}", start_overall.elapsed()); +} diff --git a/journal.md b/journal.md index 906ac58..31600f6 100644 --- a/journal.md +++ b/journal.md @@ -218,6 +218,32 @@ Executing the planned refactoring resolves the core issues of data bloat and fro **Why did we choose to do that:** The user strictly preferred the simpler monolithic frontend architecture (`app.js`), so we reverted the modularization to align with their workflow. The data layer improvements were necessary because storing absolute snapshots of file compositions in a single JSON array was causing massive I/O overhead and pipeline brittleness. Switching to `JSONL` makes incremental appends extremely fast and memory-efficient. Splitting out fossils into their own files prevents unnecessary re-saving of the same historical data. We also addressed opaque error handling by replacing all bare exceptions. +## Goal: High-Performance Rust Rewrite (Data Pipeline) +**Timestamp:** 2026-07-26T15:25:00+05:30 +**What did we do:** +- **Halted Python Pipeline:** Killed the Python background pipeline because even with the `JSONL` I/O optimizations, traversing and parsing massive repositories (like `langchain` or `react`) using a `subprocess.run(["git", "blame"])` loop takes 20+ hours. The Python implementation hit a fundamental execution bottleneck. +- **Architectural Shift:** Formulated a plan to rewrite the CPU-bound data pipeline as a standalone Rust CLI tool (`theseus_engine`), following the core advice from `python-to-rust.md`. +- **Ecosystem Swap Strategy:** + - `argparse` -> `clap` (Command-line arguments). + - `json` -> `serde` / `serde_json` (Blazing fast, statically-typed JSON serialization). + - `subprocess.run(["git", "blame"])` -> `git2` (Running git traverse and blame in-memory using C-bindings for libgit2). + - Single-threaded Python -> `rayon` (Trivially parallelize file-level blames across all CPU cores). +- **Migration Plan:** We will build this in a new `engine/` directory, focusing exclusively on the heavy snapshot analysis first (the most expensive operation). +**Why did we choose to do that:** +Following the `python-to-rust.md` guide, we identified this as the perfect candidate for a Rust migration: it is a pure CPU-bound task, it can be entirely self-contained as a CLI tool (avoiding complex PyO3 interop), and the performance gains from switching from subprocess execution to in-memory native bindings will be astronomical (minutes instead of days). Documenting this here ensures we have a clear, traceable transition path from Python prototyping to Rust production scale. + +## Goal: Rust Engine Scaffolding & Developer Experience (DX) +**Timestamp:** 2026-07-26T15:32:00+05:30 +**What did we do:** +- **Installed Rust:** Executed `rustup-init` locally to get the `cargo` and `rustc` toolchain online. +- **Engine Scaffolding:** Created a new `engine/` Cargo project and added dependencies for `serde`, `serde_json`, `clap`, `rayon`, `git2`, `log`, and `env_logger`. +- **Main Skeleton:** Set up the basic `src/main.rs` to use `clap` for parsing the identical CLI arguments expected by `analyse_repository.py` (`--repo-path`, `--output`, `--reprocess`). +- **Bat Scripts for DX:** Since the user isn't deeply familiar with Rust toolchains, we created two helper batch scripts in the project root: + - `build_engine.bat`: Automates building the Rust binary in `--release` mode. + - `run_engine.bat`: A wrapper script that lets the user execute the compiled engine exactly as they would a Python script, entirely hiding the underlying `cargo` complexities. +**Why did we choose to do that:** +While we are making a heavy architectural shift to Rust for raw performance, we cannot alienate the end-user or make the project harder to run. By creating simple, double-clickable or easy-to-invoke `.bat` wrappers (`build_engine.bat` / `run_engine.bat`), the user maintains the same ergonomic workflow they had with Python while transparently reaping the massive speed benefits of compiled Rust. + ## Goal: Data Pipeline Robustness and Over-engineering Cleanup **Timestamp:** 2026-07-26T15:10:00+05:30 **What did we do:** @@ -227,3 +253,65 @@ The user strictly preferred the simpler monolithic frontend architecture (`app.j - **Unit Testing**: Replaced the outdated tests in `test_analyse_repository.py` with comprehensive unit tests for `_data_io.py` that create mock file state data structures and strictly assert the output composition exactly matches the input state. **Why did we choose to do that:** The user requested a fix for a discrepancy in the `wc -l` guard caused by `git blame` skipping non-text or empty files, which lead to cache invalidation errors. By switching to an exact file-count comparison based on precisely filtered traceable files, we dramatically increase pipeline reliability. Additionally, over-engineered code and verbose docstrings were cleaned up to keep the codebase lean and readable, while unit tests ensure we don't regress the newly decoupled JSON storage mechanics. + +## Comprehensive Python to Rust Architecture Mapping + +To facilitate learning and guarantee that the reasoning behind the transition to Rust is clear, here is a verbose, 1-to-1 breakdown mapping the old Python data pipeline (`analyse_repository.py`) to the new Rust engine (`engine/src/main.rs`). We outline what Rust offers that Python lacks, and why these features yield a significantly better and faster architecture. + +### 1. `get_snapshot_periods(repo_path)` +**Python approach:** +We previously spawned a subshell using `subprocess.run(["git", "log", "--pretty=format:%H|%cI"])`. Python would capture the massive string output, split it line by line, parse the dates into year-month buckets, and store the final commit hash. +**Rust approach (`get_snapshot_periods`):** +We use `git2::Repository::open()` and the `revwalk` iterator to walk the commit tree natively in memory. We sort it chronologically and group it by month right on the commit objects using the `chrono` crate. +**New Rust Concepts Introduced:** +- **In-Memory C Bindings (`git2`)**: Instead of shelling out to `git.exe` and incurring OS overhead for every command, `libgit2` links directly to our binary. We read the git internal data structures instantly from memory. +- **`Result` & the `?` Operator**: In Python, functions implicitly raise arbitrary exceptions or return None. In Rust, any function that can fail returns a `Result`. The `?` syntax (`let oid = oid_res?;`) safely propagates errors up the chain without needing messy `try/except` blocks. +- **Strongly Typed Iterators**: `revwalk` is a lazy iterator. We don't load the entire git log into RAM; we yield one commit at a time, making it incredibly memory efficient. + +### 2. `get_tracked_files(repo_path)` +**Python approach:** +We ran `subprocess.run(["git", "ls-files"])`, looped through them, and then used `git check-attr -a` or parsed extensions to figure out if files were binary or empty. +**Rust approach (`get_tracked_files`):** +We grab the snapshot's `Tree` object and call `tree.walk(git2::TreeWalkMode::PreOrder)`. For each file, we check `blob.is_binary()` and `blob.size() > 0`. +**New Rust Concepts Introduced:** +- **Pattern Matching (`if let`)**: Rust forces you to handle states correctly. `if let Ok(blob) = repo.find_blob(...)` cleanly extracts the blob from the Result *only* if it succeeded, elegantly skipping missing files without throwing exceptions. +- **Enums & Sum Types**: The `entry.kind() == Some(ObjectType::Blob)` checks against a strict Enum. You literally cannot compare a Tree to a Blob by accident; the compiler prevents logic bugs. + +### 3. `get_changed_files(repo_path, prev_commit, commit)` +**Python approach:** +We used `subprocess.run(["git", "diff-tree", ...])` and parsed the file paths out of the terminal output to implement incremental cache diffing. +**Rust approach (`get_changed_files`):** +We use `repo.diff_tree_to_tree()` and iterate over the generated deltas. +**New Rust Concepts Introduced:** +- **Borrowing and References (`&Tree`, `&mut DiffOptions`)**: Python passes everything by reference natively but relies on the Garbage Collector to clean it up. Rust uses "Lifetimes". `diff_tree_to_tree` borrows the old and new trees via `&Tree`. The memory is strictly managed by the compiler at compile-time, completely eliminating the need for a Garbage Collector, resulting in flat and tiny memory usage charts. + +### 4. `_blame_full_snapshot` vs `process_blame` +**Python approach:** +We used `concurrent.futures.ThreadPoolExecutor` to run `git blame --line-porcelain` in multiple sub-processes. This was our biggest bottleneck. Spawning thousands of `git blame` processes took upwards of 20+ hours for large repositories because of Python's Global Interpreter Lock (GIL) and process spawning overhead. +**Rust approach (`process_blame`):** +We use the `rayon` crate. We take a vector of file paths, call `files_to_blame.into_par_iter()`, and suddenly the workload is perfectly distributed across every core on your CPU. We open a `Repository` per thread and use `repo.blame_file()`. +**New Rust Concepts Introduced:** +- **Data Parallelism & Zero-Cost Abstractions (`rayon`)**: In Rust, turning a single-threaded loop into a multi-threaded parallel execution is literally as simple as changing `.iter()` to `.par_iter()`. +- **Fearless Concurrency (`Send` and `Sync`)**: Python’s GIL exists because threading is hard to do safely. In Rust, the compiler checks if objects are thread-safe (`Sync` trait). `git2::Repository` is *not* entirely thread-safe, so if we tried to share one instance across all threads, the code *literally would not compile*. Rust forces us to open a `Repository` handle *inside* the thread closure, preventing race conditions entirely. +- **Hunk Walking vs String Parsing**: Instead of parsing massive blocks of text from standard out, `repo.blame_file()` returns memory structures called "Hunks". We simply read `hunk.final_signature().when()` to get the timestamp, instantly bypassing all text parsing overhead. + +### 5. Data Structuring and Output +**Python approach:** +We constructed Python dictionaries (e.g., `defaultdict(int)`) on the fly, relying on duck typing. +**Rust approach:** +We define a concrete `struct SnapshotData` and use the `serde` crate with `#[derive(Serialize)]`. +**New Rust Concepts Introduced:** +- **Macros (`#[derive(Serialize)]`)**: This attribute tells the Rust compiler to automatically generate lightning-fast JSON serialization code for our struct at compile-time. +- **Type Guarantee**: `HashMap` strictly enforces that years are strings and line counts are 32-bit unsigned integers (cannot be negative!). If we attempt to insert a float or None, it won't compile. Python would just let it happen and crash at runtime during the JSON dump. + +## Goal: Rust Engine Refinement & Pipeline Stabilization +**Timestamp:** 2026-07-26T16:32:47+05:30 +**What did we do:** +- **Engine Concurrency Fix:** Refactored the Rayon parallel iterator in `engine/src/main.rs` to use `map_init`, initializing the `git2::Repository` handle once per thread. +- **Incremental State Safety:** Moved the `file_compositions` JSON state persistence inside the main processing loop to save progress after every snapshot period. +- **JSONL Deduplication:** Added logic to rewrite the output JSONL file before appending when `--reprocess` is used, explicitly stripping out old lines for the reprocessed periods to prevent duplicated snapshot rows. +- **Workflow Security & Caching:** Hard-pinned the `Swatinem/rust-cache` action to a specific commit SHA (`e18b497796c12c097a38f9edb9d0641fb99eee32`) in both `theseus-engine.yml` and `unit-tests.yml`. Refactored the pipeline arguments using secure bash arrays (`ARGS=()`) and environment variables instead of string interpolation. +- **Binary Pre-building:** Added a `cargo build --release` step to the CI workflow and updated `analyse_repository.py` to invoke the pre-built `./engine/target/release/engine` binary directly on Linux, mirroring the batch script behavior on Windows. +- **Batch Script Resilience:** Updated `build_engine.bat` and `run_engine.bat` to use checked directory traversal (`pushd "%~dp0engine" || exit /b 1`) and preserve the exact exit codes from Cargo. +**Why did we choose to do that:** +We identified thread-safety constraints with `libgit2` (specifically, sharing a `git2::Repository` across threads safely), which `map_init` gracefully resolves without performance penalties. Pushing state saves into the loop ensures we don't lose hours of computation if a massive repository crashes mid-way. Deduplicating the JSONL on reprocess maintains data integrity for the downstream React frontend. Pinning GitHub Actions and sanitizing bash inputs follows strict CI security best practices. Using `pushd`/`popd` in the batch scripts prevents unpredictable path resolution bugs when scripts are invoked from arbitrary working directories. diff --git a/run_engine.bat b/run_engine.bat new file mode 100644 index 0000000..697e224 --- /dev/null +++ b/run_engine.bat @@ -0,0 +1,17 @@ +@echo off +set CARGO_CMD=cargo +where cargo >nul 2>nul +if %errorlevel% neq 0 ( + set CARGO_CMD="%USERPROFILE%\.cargo\bin\cargo.exe" +) + +echo Running Theseus Engine... +pushd "%~dp0engine" || exit /b 1 +%CARGO_CMD% run --release -- %* +set "EXIT_CODE=%errorlevel%" +popd +if %EXIT_CODE% neq 0 ( + echo Engine execution failed! + pause + exit /b %EXIT_CODE% +) diff --git a/scripts/analyse_repository.py b/scripts/analyse_repository.py index aa1878d..5498018 100644 --- a/scripts/analyse_repository.py +++ b/scripts/analyse_repository.py @@ -415,30 +415,27 @@ def process_repository( try: ensure_repo_ready(repo_slug, repo_name, temp_repo_path) - historical_snapshots = load_history(output_json_path) - processed_periods = set(item["snapshot_date"] for item in historical_snapshots) - - all_periods = get_snapshot_periods(temp_repo_path) - new_snapshots = _filter_snapshots(all_periods, processed_periods, reprocess) - - if not new_snapshots: - logger.info( - "[%s] No new periods to process. Data is already up to date!", - repo_name, - ) - return - - logger.info( - "[%s] Processing %d new snapshots (quarterly pre-2025, monthly 2025+)", - repo_name, - len(new_snapshots), - ) - - _process_snapshots_by_year( - repo_name, temp_repo_path, new_snapshots, - historical_snapshots, output_json_path, - state_json_path, - ) + import platform + if platform.system() == "Windows": + cmd = [ + "cmd.exe", "/c", "run_engine.bat", + "--repo-path", temp_repo_path, + "--output", output_json_path, + "--state", state_json_path, + ] + else: + cmd = [ + "./engine/target/release/engine", + "--repo-path", temp_repo_path, + "--output", output_json_path, + "--state", state_json_path, + ] + + if reprocess: + cmd.extend(["--reprocess", reprocess]) + + logger.info("[%s] Delegating snapshot analysis to Rust engine...", repo_name) + run_command(cmd) finally: remove_path(temp_repo_path)