diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..43dbcd49 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,40 @@ +--- +name: Publish + +"on": + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + environment: crates-io + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Install Breezy + run: | + pip install 'breezy>=3.3.6' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Verify package builds + run: cargo package --verbose + + - name: Authenticate to crates.io + uses: rust-lang/crates-io-auth-action@v1 + id: auth + + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a10c4b22..c21379ec 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,31 +3,36 @@ name: Test "on": push: - branches: [master] + branches: [master, rust] pull_request: - branches: [master] + branches: [master, rust] jobs: test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install Breezy + run: | + pip install 'breezy>=3.3.6' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable with: - python-version: ${{ matrix.python-version }} + components: rustfmt, clippy - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install ".[dev]" + - uses: Swatinem/rust-cache@v2 - - name: Run unit tests - run: | - export BRZ_PLUGINS_AT=loggerhead@$(pwd) - brz selftest -s bp.loggerhead + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build --all-targets --verbose + + - name: Run tests + run: cargo test --all-features --verbose diff --git a/.gitignore b/.gitignore index ef99b155..7fb65225 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,6 @@ -./dist -./loggerhead.egg-info -./loggerhead.pid +/target ./logs -build *.log -_trial_temp -loggerhead-memprofile -./docs/_build/ +/docs/book/ tags -.project -.pydevproject -.testrepository -MANIFEST -.tox -__pycache__ -loggerhead.egg-info/ *~ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..3845b406 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2491 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[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 = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "breezyshim" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3758bdbf07c417a35b2b3864dd79ba59f6f083815da3e6666beb15786bdb0b8" +dependencies = [ + "chrono", + "ctor", + "lazy-regex", + "lazy_static", + "log", + "patchkit", + "percent-encoding", + "pyo3", + "pyo3-filelike", + "regex", + "serde", + "tempfile", + "url", + "whoami", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[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-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[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 = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy-regex" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loggerhead" +version = "3.0.0-dev" +dependencies = [ + "anyhow", + "askama", + "axum", + "breezyshim", + "chrono", + "clap", + "httpdate", + "mime_guess", + "moka", + "num_cpus", + "percent-encoding", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "similar", + "syntect", + "tempfile", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "patchkit" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5364f5b47b05204e0eb3e5427657769dd1924f4ec3e2b823c0575546049439a" +dependencies = [ + "chrono", + "lazy-regex", + "lazy_static", + "once_cell", + "proc-macro2", + "regex", + "rowan", +] + +[[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 = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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 = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "chrono", + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "serde", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-filelike" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8cb6cd0231ea816b4452c0cd37b5215f9ec45b66ed3e748fad8eb39cfd4997" +dependencies = [ + "pyo3", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash", + "text-size", +] + +[[package]] +name = "rusqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[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 = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror", + "walkdir", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[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 = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +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", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..4fd6e38e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "loggerhead" +version = "3.0.0-dev" +edition = "2021" +rust-version = "1.83" +license = "GPL-2.0-or-later" +description = "Web viewer for Bazaar/Breezy branches" +homepage = "https://www.breezy-vcs.org/" +repository = "https://code.launchpad.net/loggerhead" +authors = ["Jelmer Vernooij "] + +[[bin]] +name = "loggerhead-serve" +path = "src/main.rs" + +[lib] +name = "loggerhead" +path = "src/lib.rs" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "compression-br", "compression-gzip", "fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +askama = "0.12" +clap = { version = "4", features = ["derive"] } +breezyshim = "0.7.16" +url = "2" +thiserror = "2" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +moka = { version = "0.12", features = ["sync"] } +num_cpus = "1" +chrono = "0.4" +rusqlite = { version = "0.33", features = ["bundled"] } +similar = "2" +percent-encoding = "2" +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "parsing", "regex-fancy"] } +mime_guess = "2" +httpdate = "1" + +[dev-dependencies] +tempfile = "3" +reqwest = { version = "0.12", default-features = false, features = ["blocking"] } diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 616d1eb4..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,12 +0,0 @@ -include COPYING.txt -include HACKING -include NEWS -include README.txt -include apache-loggerhead.conf -include breezy.conf -include loggerhead.conf.example -include loggerheadd -include Makefile -recursive-include docs * -recursive-include loggerhead/static * -recursive-include loggerhead/tests/ *.py *.pt diff --git a/Makefile b/Makefile deleted file mode 100644 index e1debe85..00000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ - -PYTHON ?= python3 -BRZ ?= brz - -dist: - $(PYTHON) ./setup.py sdist - -clean: - rm -rf dist/ - -check: - BRZ_PLUGINS_AT=loggerhead@$$(pwd) $(BRZ) selftest -s bp.loggerhead diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 9516b047..00000000 --- a/__init__.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright 2009, 2010, 2011 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - - -# This file allows loggerhead to be treated as a plugin for bzr. -# -# XXX: Because loggerhead already contains a loggerhead directory, much of the -# code is going to appear loaded at breezy.plugins.loggerhead.loggerhead. -# This seems like the easiest thing, because breezy wants the top-level plugin -# directory to be the module, but when it's used as a library people expect -# the source directory to contain a directory called loggerhead. -- mbp -# 20090123 - -"""Loggerhead web viewer for Bazaar branches. - -This provides a new option "--http" to the "bzr serve" command, that -starts a web server to browse the contents of a branch. -""" - -try: - import importlib.metadata as importlib_metadata -except ImportError: - import importlib_metadata - -import sys - -from packaging.version import Version - -try: - version_info = Version(importlib_metadata.version("loggerhead")).release -except importlib_metadata.PackageNotFoundError: - # Support running tests from the build tree without installation. - version_info = None - -from breezy import commands -from breezy.transport import transport_server_registry - -DEFAULT_HOST = "0.0.0.0" -DEFAULT_PORT = 8080 -HELP = "Loggerhead, a web-based code viewer and server. (default port: %d)" % ( - DEFAULT_PORT, -) - - -def serve_http(transport, host=None, port=None, inet=None, client_timeout=None): - # TODO: if we supported inet to pass requests in and respond to them, - # then it would be easier to test the full stack, but it probably - # means routing around paste.httpserver.serve which probably - # isn't testing the full stack - from paste.httpexceptions import HTTPExceptionHandler - from paste.httpserver import serve - - try: - from .loggerhead.apps.transport import BranchesFromTransportRoot - from .loggerhead.config import LoggerheadConfig - from .loggerhead.__main__ import setup_logging - except ImportError: - from loggerhead.apps.transport import BranchesFromTransportRoot - from loggerhead.config import LoggerheadConfig - from loggerhead.__main__ import setup_logging - - if host is None: - host = DEFAULT_HOST - if port is None: - port = DEFAULT_PORT - argv = ["--host", host, "--port", str(port), "--", transport.base] - if not transport.is_readonly(): - argv.insert(0, "--allow-writes") - config = LoggerheadConfig(argv) - setup_logging(config, init_logging=False, log_file=sys.stderr) - app = BranchesFromTransportRoot(transport.base, config) - app = HTTPExceptionHandler(app) - serve(app, host=host, port=port) - - -transport_server_registry.register("http", serve_http, help=HELP) - - -class cmd_load_test_loggerhead(commands.Command): - """Run a load test against a live loggerhead instance. - - Pass in the name of a script file to run. See loggerhead/load_test.py - for a description of the file format. - """ - - hidden = True - takes_args = ["filename"] - - def run(self, filename): - try: - from .loggerhead.loggerhead import load_test - except ImportError: - from loggerhead.loggerhead import load_test - script = load_test.run_script(filename) - for thread_id in sorted(script._threads): - worker = script._threads[thread_id][0] - for url, success, time in worker.stats: - self.outf.write(" %5.3fs %s %s\n" % (time, str(success)[0], url)) - - -commands.register_command(cmd_load_test_loggerhead) - - -def load_tests(loader, basic_tests, pattern): - try: - from .loggerhead.tests import test_suite - except ImportError: - from breezy.trace import mutter - - mutter("loggerhead tests not installed, not registering tests") - else: - basic_tests.addTest(test_suite()) - return basic_tests diff --git a/apache-loggerhead.conf b/apache-loggerhead.conf deleted file mode 100644 index 84eb574d..00000000 --- a/apache-loggerhead.conf +++ /dev/null @@ -1,19 +0,0 @@ -### If you receive MemoryError tracebacks setting up loggerhead under mod_wsgi, -### read /usr/share/doc/loggerhead*/README for help -Alias /bzr/static /usr/share/loggerhead/static -RewriteEngine On -RewriteRule ^/bzr$ /bzr/ [R] - -WSGIDaemonProcess loggerhead user=apache group=apache maximum-requests=1000 display-name=loggerhead processes=4 threads=1 -WSGISocketPrefix run/wsgi -WSGIRestrictStdout On -WSGIRestrictSignal Off - -WSGIScriptAlias /bzr /usr/bin/loggerhead.wsgi - - - WSGIProcessGroup loggerhead - Order deny,allow - Allow from all - - diff --git a/breezy.conf b/breezy.conf deleted file mode 100644 index 4aedc7dd..00000000 --- a/breezy.conf +++ /dev/null @@ -1,9 +0,0 @@ -# directory to serve bzr branches from -# Non-bzr directories under this path will also be visible in loggerhead -#http_root_dir = '/var/www/bzr' - -# The url prefix for the bzr branches. -http_user_prefix = '/bzr' - -# Directory to put cache files in -http_sql_dir = '/var/cache/loggerhead' diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 0b6c5a74..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,89 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/loggerhead.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/loggerhead.qhc" - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ - "run these through (pdf)latex." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 00000000..a75b88d3 --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,10 @@ +[book] +title = "Loggerhead" +description = "Web viewer for Bazaar/Breezy branches" +authors = ["Loggerhead contributors"] +language = "en" +src = "src" + +[output.html] +default-theme = "light" +git-repository-url = "https://github.com/breezy-team/loggerhead" diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 2edc4258..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,199 +0,0 @@ -# -*- coding: utf-8 -*- -# -# loggerhead documentation build configuration file, created by -# sphinx-quickstart on Tue Mar 23 10:49:50 2010. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -from loggerhead import __version__ - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.append(os.path.abspath('.')) - -# -- General configuration ----------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = [] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix of source filenames. -source_suffix = ".rst" - -# The encoding of source files. -# source_encoding = 'utf-8' - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = "Loggerhead" -copyright = "2010, Loggerhead team (https://launchpad.net/~loggerhead-team)" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = __version__ -# The full version, including alpha/beta/rc tags. -release = __version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - -# List of documents that shouldn't be included in the build. -# unused_docs = [] - -# List of directories, relative to source directory, that shouldn't be searched -# for source files. -exclude_trees = ["_build"] - -# The reST default role (used for this markup: `text`) to use for all documents. -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - - -# -- Options for HTML output --------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. Major themes that come with -# Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = "default" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -# html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_use_modindex = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = '' - -# Output file base name for HTML help builder. -htmlhelp_basename = "loggerheaddoc" - - -# -- Options for LaTeX output -------------------------------------------------- - -# The paper size ('letter' or 'a4'). -# latex_paper_size = 'letter' - -# The font size ('10pt', '11pt' or '12pt'). -# latex_font_size = '10pt' - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ( - "index", - "loggerhead.tex", - "Loggerhead Documentation", - "Loggerhead team (https://launchpad.net/\\textasciitilde{}loggerhead-team)", - "manual", - ), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# Additional stuff for the LaTeX preamble. -# latex_preamble = '' - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_use_modindex = True diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index bd4734e9..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,263 +0,0 @@ -Loggerhead: A web viewer for ``bzr`` branches -============================================== - -Loggerhead is a web viewer for projects in Breezy. It can be used to navigate -a branch history, annotate files, view patches, perform searches, etc. -Loggerhead is heavily based on `bazaar-webserve -`_, which was, in turn, loosely -based on `hgweb `_. - - -Getting Started ---------------- - -Loggerhead depends on the following Python libraries.: - -- Chameleon for templating. - -- Paste for the server. (You need version 1.2 or newer of Paste). - -- PasteDeploy (optional, needed when proxying through Apache). - -- flup (optional, needed to use FastCGI, SCGI or AJP). - - -Installing Dependencies Using Ubuntu Packages -############################################# - -.. code-block:: sh - - $ sudo apt-get install python-chameleon - $ sudo apt-get install python-paste - $ sudo apt-get install python-pastedeploy - $ sudo apt-get install python-flup - -Installing Dependencies Using :command:`pip` -############################################ - -You should normally create and activate a virtual environment first. - -.. code-block:: sh - - # Basic installation only - $ pip install loggerhead - # Installation for proxying through Apache - $ pip install 'loggerhead[proxied]' - # Installation for FastCGI, SCGI or AJP - $ pip install 'loggerhead[flup]' - - -Running the Standalone Loggerhead Server ----------------------------------------- - -After installing all the dependencies, you should be able to run -:command:`loggerhead-serve` with the branch you want to serve on the -command line: - -.. code-block:: sh - - ./loggerhead-serve ~/path/to/branch - -By default, the script listens on port 8080, so head to -http://localhost:8080/ in your browser to see the branch. - -You can also pass a directory that contains branches to the script, -and it will serve a very simple directory listing at other pages. - -You may update the Bazaar branches being viewed at any time. -Loggerhead will notice and refresh, and Bazaar uses its own branch -locking to prevent corruption. - -See :doc:`loggerhead-serve` for all command line options. - -Running Loggerhead as a Daemon ------------------------------- - -To run Loggerhead as a linux daemon: - -1) Copy the ``loggerheadd`` scipt to ``/etc/init.d`` - -.. code-block:: sh - - $ sudo cp ./loggerheadd /etc/init.d - -2) Edit the file to configure where your Loggerhead is installed, and which - loggerhead-serve options you would like. - -.. code-block:: sh - - $ sudo vim /etc/init.d/loggerheadd - -3) Register the service - -.. code-block:: sh - - # on upstart based systems like Ubuntu run: - $ sudo update-rc.d loggerheadd defaults - - # on Sysvinit based systems like Centos or SuSE run: - $ sudo chkconfig --add loggerheadd - - -Using Loggerhead as a Breezy Plugin ------------------------------------ - -This branch contains experimental support for using Loggerhead as a Breezy -plugin. To use it, place the top-level Loggerhead directory (the one -containing COPYING.txt) at ``~/.config/breezy/plugins/loggerhead``. E.g.: - -.. code-block:: sh - - $ bzr branch lp:loggerhead ~/.config/breezy/plugins/loggerhead - $ cd ~/myproject - $ bzr serve --http - - -Using a Config File -------------------- - -To hide branches from being displayed, add to ``~/.config/breezy/locations.conf``, -under the branch's section: - -.. code-block:: ini - - [/path/to/branch] - http_serve = False - -More configuration options to come soon. - - -Serving Loggerhead behind Apache --------------------------------- - -If you want to view Breezy branches from your existing Apache -installation, you'll need to configure Apache to proxy certain -requests to Loggerhead. Adding lines like this to your Apache -configuration is one way to do this: - -.. code-block:: apache - - - ProxyPass http://127.0.0.1:8080/branches/ - ProxyPassReverse http://127.0.0.1:8080/branches/ - - -If Paste Deploy is installed, the :command:`loggerhead-serve` script can be -run behind a proxy at the root of a site, but if you're running it at -some path into the site, you'll need to specify it using -``--prefix=/some_path``. - -Serving Loggerhead with mod_wsgi --------------------------------- - -A second method for using Loggerhead with apache is to have apache itself -execute Loggerhead via mod_wsgi. You need to add configuration for apache and -for breezy to make this work. Example config files are in the Loggerhead doc -directory as apache-loggerhead.conf and breezy.conf. You can copy them into -place and use them as a starting point following these directions: - -1) Install mod_wsgi. On Ubuntu and other Debian derived distros:: - - sudo apt-get install libapache2-mod-wsgi - - On Fedora-derived distros:: - - su -c yum install mod_wsgi - -2) Copy the breezy.conf file where apache will find it (May be done for you if - you installed Loggerhead from a distribution package):: - - # install -d -o apache -g apache -m 0755 /etc/loggerhead - # cp -p /usr/share/doc/loggerhead*/breezy.conf /etc/loggerhead/ - # mkdir -p /var/www/.config - # ln -s /etc/loggerhead /var/www/.config/breezy - -3) Create the cache directory (May be done for you if you installed Loggerhead - from a distribution package):: - - # install -d -o apache -g apache -m 0700 /var/cache/loggerhead/ - -4) Edit /etc/loggerhead/breezy.conf. You need to set http_root_dir to the filesystem - path that you will find your bzr branches under. Note that normal - directories under that path will also be visible in Loggerhead. - -5) Install the apache conf file:: - - # cp -p /usr/share/doc/loggerhead*/apache-loggerhead.conf /etc/httpd/conf.d/loggerhead.conf - -6) Edit /etc/httpd/conf.d/loggerhead.conf to point to the url you desire to - serve Loggerhead on. This should match with the setting for - http_user_prefix in breezy.conf - -7) Restart apache and you should be able to start browsing - -.. note:: If you have SELinux enabled on your system you may need to allow - apache to execute files in temporary directories. You will get a - MemoryError traceback from python if this is the case. This is because of - the way that python ctypes interacts with libffi. To rectify this, you may - have to do several things, such as mounting tmpdirs so programs can be - executed on them and setting this SELinux boolean:: - - setsebool httpd_tmp_exec on - - This bug has information about how python and/or Linux distros might solve - this issue permanently and links to bugs which diagnose the root cause. - https://bugzilla.redhat.com/show_bug.cgi?id=582009 - -Search ------- - -Search is currently supported by using the bzr-search plugin (available -at: https://launchpad.net/bzr-search ). - -You need to have the plugin installed and each branch indexed to allow -searching on branches. - -Command-Line Reference ----------------------- - -.. toctree:: - :maxdepth: 2 - - loggerhead-serve - - -Support -------- - -Discussion should take place on the bazaar-dev mailing list at -mailto:bazaar@lists.canonical.com. You can join the list at -. You don't need to -subscribe to post, but your first post will be held briefly for manual -moderation. - -Bugs, support questions and merge proposals are tracked on Launchpad, e.g: - - https://bugs.launchpad.net/loggerhead - - -Hacking -------- - -To run Loggerhead tests, you will need to install the package ``python-nose``, -and run its :command:`nosetests` script in the Loggerhead directory: - -.. code-block:: sh - - nosetests - - -License -------- - -GNU GPLv2 or later. - -See Also --------- - -https://launchpad.net/loggerhead - -Index -===== - -- :ref:`genindex` diff --git a/docs/loggerhead-serve.rst b/docs/loggerhead-serve.rst deleted file mode 100644 index bd02790e..00000000 --- a/docs/loggerhead-serve.rst +++ /dev/null @@ -1,116 +0,0 @@ -:command:`loggerhead-serve` -========================= - -The :command:`loggerhead-serve` script runs a standalone Loggerhead server in -the foreground. - -.. program:: loggerhead-serve - -Usage ------ - -.. code-block:: sh - - loggerhead-serve [OPTIONS] - -Options -------- - -.. cmdoption:: --user-dirs - - Serve user directories as ``~user`` (requires ``--trunk-dir``). - - If both options are set, then for requests where the CGI ``PATH_INFO`` - starts with "/~", serve branches under the directory. - -.. cmdoption:: --trunk-dir=DIR - - The directory that contains the trunk branches (requires ``--user-dirs``). - - If both options are set, then for requests where the CGI ``PATH_INFO`` - does not start with "/~", serve branches under DIR. - -.. cmdoption:: --port - - Listen on the given port. - - Defaults to 8080. - -.. cmdoption:: --host - - Listen on the interface corresponding to the given IP. - - Defaults to listening on all interfaces, i.e., "0.0.0.0". - -.. cmdoption:: --protocol - - Serve the application using the specified protocol. - - Can be one of: "http", "scgi", "fcgi", "ajp" (defaults to "http"). - -.. cmdoption:: --prefix - - Set the supplied value as the CGI ``SCRIPT_NAME`` for the application. - - This option is intended for use when serving Loggerhead behind a - reverse proxy, with Loggerhead being "mounted" at a directory below - the root. E.g., if the reverse proxy translates requests for - ``http://example.com/loggerhead`` onto the standalone Loggerhead process, - that process should be run with ``--prefix=/loggerhead``. - -.. cmdoption:: --log-folder=LOG_FOLDER - - The directory in which to place Loggerhead's log files. - - Defaults to the current directory. - -.. cmdoption:: --cache-dir=SQL_CACHE_DIR - - The directory in which to place the SQL cache. - - Defaults to the current directory. - -.. cmdoption:: --use-cdn - - Serve jQuery javascript libraries from Googles CDN. - -.. cmdoption:: --allow-writes - - Allow writing to the Breezy server. - - Setting this option keeps Loggerhead from adding a 'readonly+' prefix - to the base URL of the branch. The only effect of suppressing this prefix - is to make visible the display of instructions for checking out the - 'public_branch' URL for the branch being browsed. - -.. cmdoption:: -h, --help - - Print the help message and exit - -.. cmdoption:: --version - - Print the software version and exit. - -Debugging Options ------------------ - -The following options are only useful when developing / debugging Loggerhead -itself. - -.. cmdoption:: --profile - - Generate per-request callgrind profile data. - - Data for each request is written to a file ``%d-stats.callgrind``, - where ``%d`` is replaced by the sequence number of the request. - -.. cmdoption:: --memory-profile - - Profile the memory usage using the `Dozer - `_ middleware. - -.. cmdoption:: --reload - - Restart the application when any of its python file change. - - This option should only used for development purposes. diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index ed3015d4..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,113 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -set SPHINXBUILD=sphinx-build -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. changes to make an overview over all changed/added/deprecated items - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\loggerhead.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\loggerhead.ghc - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -:end diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md new file mode 100644 index 00000000..48ef0b1a --- /dev/null +++ b/docs/src/SUMMARY.md @@ -0,0 +1,7 @@ +# Summary + +- [Introduction](./introduction.md) +- [Getting started](./getting-started.md) +- [`loggerhead-serve`](./loggerhead-serve.md) +- [Running behind a reverse proxy](./running-behind-a-proxy.md) +- [Searching](./searching.md) diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md new file mode 100644 index 00000000..c1914e91 --- /dev/null +++ b/docs/src/getting-started.md @@ -0,0 +1,47 @@ +# Getting started + +## Building + +Loggerhead is a Rust crate. You need a recent stable Rust toolchain and +a working Python environment (Breezy is invoked through PyO3 via +[breezyshim]). + +```sh +cargo build --release +``` + +The resulting binary is `target/release/loggerhead-serve`. + +## Running + +Point `loggerhead-serve` at a branch or a directory of branches: + +```sh +./target/release/loggerhead-serve ~/path/to/branch +``` + +By default the server listens on port 8080, so browse to + to see the branch. + +If you pass a directory that contains several branches, Loggerhead +presents a simple directory listing at `/`, with each branch mounted +under `//`. + +Loggerhead re-reads the branch data on every request, so you can update +your branches while the server is running and see the changes the next +time you reload. + +See [`loggerhead-serve`](./loggerhead-serve.md) for every command-line +option. + +## Hiding branches + +To hide a branch from Loggerhead, add the following to +`~/.config/breezy/locations.conf` under the branch's section: + +```ini +[/path/to/branch] +http_serve = False +``` + +[breezyshim]: https://github.com/breezy-team/breezyshim diff --git a/docs/src/introduction.md b/docs/src/introduction.md new file mode 100644 index 00000000..5fe699ca --- /dev/null +++ b/docs/src/introduction.md @@ -0,0 +1,17 @@ +# Loggerhead + +Loggerhead is a web viewer for [Bazaar] / [Breezy] branches. It can be +used to navigate a branch's history, annotate files, view patches, +download tarballs, and search commit messages. + +Loggerhead is distantly based on [bazaar-webserve], which was itself +loosely based on [hgweb] for Mercurial. + +This branch of Loggerhead is written in Rust and uses [breezyshim] to +talk to Breezy. + +[Bazaar]: https://bazaar.canonical.com/ +[Breezy]: https://www.breezy-vcs.org/ +[bazaar-webserve]: https://launchpad.net/bzr-webserve +[hgweb]: https://www.mercurial-scm.org/wiki/HgWebDirStepByStep +[breezyshim]: https://github.com/breezy-team/breezyshim diff --git a/docs/src/loggerhead-serve.md b/docs/src/loggerhead-serve.md new file mode 100644 index 00000000..8c12d1fc --- /dev/null +++ b/docs/src/loggerhead-serve.md @@ -0,0 +1,76 @@ +# `loggerhead-serve` + +`loggerhead-serve` runs a standalone Loggerhead HTTP server in the +foreground. + +## Usage + +```sh +loggerhead-serve [OPTIONS] +``` + +`` is either a single branch or a directory of branches +(see `--user-dirs` for the Launchpad-style layout). + +## Options + +### `--port ` + +Port to listen on. Defaults to `8080`. + +### `--host ` + +Host address to bind to. Defaults to `0.0.0.0` (all interfaces). + +### `--prefix ` + +URL prefix, for use when Loggerhead is mounted under a sub-path behind +a reverse proxy. For example, if the proxy forwards +`https://example.com/bzr/` to Loggerhead, pass `--prefix=/bzr`. + +### `--cache-dir ` (alias: `--cachepath`) + +Directory to place the on-disk revision-info cache (SQLite). The cache +is optional — if it's not configured, Loggerhead recomputes history +from the branch on every cold request. + +### `--export-tarballs` + +Allow tarball downloads of revisions. Enabled by default. Pass +`--export-tarballs=false` to disable. + +### `--log-folder ` + +Directory to write log files to. Accepted for CLI compatibility with +the Python implementation; currently logs are still emitted to stderr. + +### `--log-level ` + +Log level. One of `trace`, `debug`, `info`, `warn`, `error`. You can +also set `RUST_LOG` in the environment. + +### `--static-dir ` + +Directory of static CSS/JS/image assets to serve under `/static`. +Defaults to the `static/` directory shipped with the source checkout; +Debian-packaged installs typically point this at +`/usr/share/loggerhead/static`. + +### `--user-dirs` + +Serve the root as a directory of user branches. Each +`//` is exposed at `/~//`. Requires +`--trunk-dir`. + +### `--trunk-dir ` + +When `--user-dirs` is set, the subdirectory under `` whose +branches are served under `/` without the `~user` prefix. + +### `-h`, `--help` + +Print help and exit. + +### `--version` + +Print the software version and exit. diff --git a/docs/src/running-behind-a-proxy.md b/docs/src/running-behind-a-proxy.md new file mode 100644 index 00000000..0d3c505b --- /dev/null +++ b/docs/src/running-behind-a-proxy.md @@ -0,0 +1,52 @@ +# Running behind a reverse proxy + +Loggerhead's preferred deployment is as a long-running HTTP server +behind a reverse proxy such as nginx, Apache (with `mod_proxy`), or +Caddy. The proxy handles TLS, logging, access control, and any other +shared concerns for your site. + +## nginx + +```nginx +location /bzr/ { + proxy_pass http://127.0.0.1:8080/bzr/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +And run Loggerhead with a matching `--prefix`: + +```sh +loggerhead-serve --prefix=/bzr /srv/bzr +``` + +## Apache + +```apache + + ProxyPass http://127.0.0.1:8080/bzr/ + ProxyPassReverse http://127.0.0.1:8080/bzr/ + +``` + +Again, match `--prefix=/bzr` on the Loggerhead side. + +## systemd + +A minimal unit: + +```ini +[Unit] +Description=Loggerhead +After=network.target + +[Service] +ExecStart=/usr/bin/loggerhead-serve --cache-dir=/var/cache/loggerhead /srv/bzr +User=loggerhead +Group=loggerhead +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` diff --git a/docs/src/searching.md b/docs/src/searching.md new file mode 100644 index 00000000..6847f92e --- /dev/null +++ b/docs/src/searching.md @@ -0,0 +1,18 @@ +# Searching + +Loggerhead's `/search` page is backed by the [`bzr-search`] Breezy +plugin. You need to have the plugin installed in the Python environment +that Loggerhead is calling into, and each branch must be indexed before +its contents become searchable. + +Indexing a branch is a Breezy-side operation: + +```sh +brz index /path/to/branch +``` + +If the plugin isn't available or the branch hasn't been indexed, the +`/search` page renders a "search unavailable" notice rather than +erroring out. + +[`bzr-search`]: https://launchpad.net/bzr-search diff --git a/load_test_scripts/multiple_instances.script b/load_test_scripts/multiple_instances.script deleted file mode 100644 index 769f1605..00000000 --- a/load_test_scripts/multiple_instances.script +++ /dev/null @@ -1,19 +0,0 @@ -{ - "comment": "Connect to multiple loggerhead instances and make requests on each. One should be on :8080, one should be on :8081. Multiple threads will place requests on each.", - "parameters": {"base_url": "http://localhost"}, - "requests": [ - {"thread": "1", "relpath": ":8080/changes"}, - {"thread": "2", "relpath": ":8080/files"}, - {"thread": "3", "relpath": ":8081/files"}, - {"thread": "4", "relpath": ":8081/changes"}, - {"thread": "1", "relpath": ":8080/changes"}, - {"thread": "2", "relpath": ":8080/files"}, - {"thread": "3", "relpath": ":8081/files"}, - {"thread": "4", "relpath": ":8081/changes"}, - {"thread": "1", "relpath": ":8080/changes"}, - {"thread": "2", "relpath": ":8080/files"}, - {"thread": "3", "relpath": ":8081/files"}, - {"thread": "4", "relpath": ":8081/changes"} - ] -} - diff --git a/load_test_scripts/simple.script b/load_test_scripts/simple.script deleted file mode 100644 index 10b17b0f..00000000 --- a/load_test_scripts/simple.script +++ /dev/null @@ -1,9 +0,0 @@ -{ - "comment": "A fairly trivial load test script. It just loads the main two pages from a loggerhead install running directly on a branch.", - "parameters": {"base_url": "http://localhost:8080"}, - "requests": [ - {"relpath": "/changes"}, - {"relpath": "/files"} - ] -} - diff --git a/loggerhead-serve b/loggerhead-serve deleted file mode 100755 index e6fcb5c4..00000000 --- a/loggerhead-serve +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2008, 2009 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - -"""Search for branches underneath a directory and serve them all.""" - -import sys - -from loggerhead.__main__ import main - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/loggerhead.wsgi b/loggerhead.wsgi deleted file mode 100644 index eb935309..00000000 --- a/loggerhead.wsgi +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/python3 -tt -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - - -import os -import pwd -import sys - -sys.path.insert(0, os.path.dirname(__file__)) - -from breezy import config as bzrconfig -from breezy.plugin import load_plugins -from paste.deploy.config import PrefixMiddleware -from paste.httpexceptions import HTTPExceptionHandler - -from loggerhead.apps.error import ErrorHandlerApp -from loggerhead.apps.transport import BranchesFromTransportRoot -from loggerhead.config import LoggerheadConfig - - -class NotConfiguredError(Exception): - pass - - -load_plugins() -config = LoggerheadConfig() -prefix = config.get_option('user_prefix') or '' -# Note we could use LoggerheadConfig here if it didn't fail when a -# config option is not also a commandline option -root_dir = os.getenv('LOGGERHEAD_ROOT_DIR') -if not root_dir: - root_dir = bzrconfig.GlobalConfig().get_user_option('http_root_dir') -if not root_dir: - raise NotConfiguredError('You must set LOGGERHEAD_ROOT_DIR or have ' - 'a ~/.config/breezy/breezy.conf file for' - ' %(user)s with http_root_dir set to the base directory you want' - ' to serve bazaar repositories from' % - {'user': pwd.getpwuid(os.geteuid()).pw_name}) -prefix = prefix.encode('utf-8', 'ignore') -root_dir = root_dir.encode('utf-8', 'ignore') -app = BranchesFromTransportRoot(root_dir, config) -app = PrefixMiddleware(app, prefix=prefix) -app = HTTPExceptionHandler(app) -application = ErrorHandlerApp(app) diff --git a/loggerhead/__init__.py b/loggerhead/__init__.py deleted file mode 100644 index dc169aa6..00000000 --- a/loggerhead/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# -# Copyright (C) 2008, 2009 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - -"""A simple container to turn this into a python package.""" - -try: - import importlib.metadata as importlib_metadata -except ImportError: - import importlib_metadata - -try: - __version__ = importlib_metadata.version("loggerhead") -except importlib_metadata.PackageNotFoundError: - # Support running tests from the build tree without installation. - import os - - try: - import tomllib - except ModuleNotFoundError: - import tomli as tomllib - - with open(os.path.join(os.path.dirname(__file__), "..", "pyproject.toml"), "rb") as f: - cfg = tomllib.load(f) - __version__ = cfg["project"]["version"] -__revision__ = None -required_breezy = (3, 1) diff --git a/loggerhead/__main__.py b/loggerhead/__main__.py deleted file mode 100644 index a7e5f25f..00000000 --- a/loggerhead/__main__.py +++ /dev/null @@ -1,198 +0,0 @@ -# -# Copyright (C) 2008, 2009 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - -"""Search for branches underneath a directory and serve them all.""" - -import logging -import os -import sys - -from breezy.location import location_to_url -from breezy.plugin import load_plugins -from paste import httpserver -from paste.httpexceptions import HTTPExceptionHandler, HTTPInternalServerError -from paste.translogger import TransLogger - -from . import __version__ -from .apps.error import ErrorHandlerApp -from .apps.transport import BranchesFromTransportRoot, UserBranchesFromTransportRoot -from .config import LoggerheadConfig -from .util import Reloader - - -def get_config_and_base(args): - config = LoggerheadConfig(args) - - if config.get_option("show_version"): - print("loggerhead %s" % (__version__,)) - sys.exit(0) - - if config.arg_count > 1: - config.print_help() - sys.exit(1) - elif config.arg_count == 1: - base = config.get_arg(0) - else: - base = "." - - base = location_to_url(base) - - if not config.get_option("allow_writes"): - base = "readonly+" + base - - return config, base - - -def setup_logging(config, init_logging=True, log_file=None): - log_level = config.get_log_level() - if init_logging: - logging.basicConfig() - if log_level is not None: - logging.getLogger("").setLevel(log_level) - logger = logging.getLogger("loggerhead") - if log_level is not None: - logger.setLevel(log_level) - if log_file is not None: - handler = logging.StreamHandler(log_file) - else: - if config.get_option("log_folder"): - logfile_path = os.path.join( - config.get_option("log_folder"), "loggerhead-serve.log" - ) - else: - logfile_path = "loggerhead-serve.log" - handler = logging.FileHandler(logfile_path, "a") - formatter = logging.Formatter( - "%(asctime)s %(levelname)-8s %(name)s: %(message)s" - ) - handler.setFormatter(formatter) - # We set the handler to accept all messages, the *logger* won't emit them - # if it is configured to suppress it - handler.setLevel(logging.DEBUG) - logger.addHandler(handler) - return logger - - -def make_app_for_config_and_base(config, base): - if config.get_option("trunk_dir") and not config.get_option("user_dirs"): - print("--trunk-dir is only valid with --user-dirs") - sys.exit(1) - - if config.get_option("reload"): - if Reloader.is_installed(): - Reloader.install() - else: - return Reloader.restart_with_reloader() - - if config.get_option("user_dirs"): - if not config.get_option("trunk_dir"): - print("You didn't specify a directory for the trunk directories.") - sys.exit(1) - app = UserBranchesFromTransportRoot(base, config) - else: - app = BranchesFromTransportRoot(base, config) - - setup_logging(config) - - if config.get_option("profile"): - from loggerhead.middleware.profile import LSProfMiddleware - - app = LSProfMiddleware(app) - if config.get_option("memory_profile"): - from dozer import Dozer - - app = Dozer(app) - - if not config.get_option("user_prefix"): - prefix = "/" - else: - prefix = config.get_option("user_prefix") - if not prefix.startswith("/"): - prefix = "/" + prefix - - try: - from paste.deploy.config import PrefixMiddleware - except ImportError: - cant_proxy_correctly_message = ( - "Unsupported configuration: PasteDeploy not available, but " - "loggerhead appears to be behind a proxy." - ) - - def check_not_proxied(app): - def wrapped(environ, start_response): - if "HTTP_X_FORWARDED_SERVER" in environ: - exc = HTTPInternalServerError() - exc.explanation = cant_proxy_correctly_message - raise exc - return app(environ, start_response) - - return wrapped - - logging.warning( - "PasteDeploy not available; unable to support " - "access through a reverse proxy." - ) - app = check_not_proxied(app) - else: - app = PrefixMiddleware(app, prefix=prefix) - - app = HTTPExceptionHandler(app) - app = ErrorHandlerApp(app) - app = TransLogger(app, logger=logging.getLogger("loggerhead")) - - return app - - -def main(args): - load_plugins() - - config, base = get_config_and_base(args) - - app = make_app_for_config_and_base(config, base) - - if not config.get_option("user_port"): - port = "8080" - else: - port = config.get_option("user_port") - - if not config.get_option("user_host"): - host = "0.0.0.0" - else: - host = config.get_option("user_host") - - if not config.get_option("protocol"): - protocol = "http" - else: - protocol = config.get_option("protocol") - - if protocol == "http": - httpserver.serve(app, host=host, port=port) - else: - if protocol == "fcgi": - from flup.server.fcgi import WSGIServer - elif protocol == "scgi": - from flup.server.scgi import WSGIServer - elif protocol == "ajp": - from flup.server.ajp import WSGIServer - else: - print("Unknown protocol: %s." % (protocol)) - sys.exit(1) - WSGIServer(app, bindAddress=(host, int(port))).run() - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/loggerhead/apps/__init__.py b/loggerhead/apps/__init__.py deleted file mode 100644 index 8dabdf10..00000000 --- a/loggerhead/apps/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""WSGI applications for serving Bazaar branches.""" - -import os - -from paste import fileapp, urlparser - -from ..util import convert_file_errors - -static = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") - -# Static things can be cached for half a day, we could probably make this -# longer, except for just before rollout times. -static_app = urlparser.make_static(None, static, cache_max_age=12 * 60 * 60) - -favicon_app = convert_file_errors( - fileapp.FileApp(os.path.join(static, "images", "favicon.ico")) -) - -robots_app = convert_file_errors(fileapp.FileApp(os.path.join(static, "robots.txt"))) - - -def health_app(environ, start_response): - start_response("200 OK", []) - yield b"ok" diff --git a/loggerhead/apps/branch.py b/loggerhead/apps/branch.py deleted file mode 100644 index 2d1076d3..00000000 --- a/loggerhead/apps/branch.py +++ /dev/null @@ -1,249 +0,0 @@ -# Copyright (C) 2008-2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - -"""The WSGI application for serving a Bazaar branch.""" - -import logging -import sys -import wsgiref.util - -import breezy.branch -import breezy.errors -import breezy.lru_cache -from breezy import urlutils -from breezy.hooks import Hooks -from paste import httpexceptions, request - -from .. import util -from ..apps import health_app, static_app -from ..controllers.annotate_ui import AnnotateUI -from ..controllers.atom_ui import AtomUI -from ..controllers.changelog_ui import ChangeLogUI -from ..controllers.diff_ui import DiffUI -from ..controllers.download_ui import DownloadTarballUI, DownloadUI -from ..controllers.filediff_ui import FileDiffUI -from ..controllers.inventory_ui import InventoryUI -from ..controllers.revision_ui import RevisionUI -from ..controllers.revlog_ui import RevLogUI -from ..controllers.search_ui import SearchUI -from ..controllers.view_ui import ViewUI -from ..history import History - -_DEFAULT = object() - - -class BranchWSGIApp(object): - def __init__( - self, - branch, - friendly_name=None, - config={}, - graph_cache=None, - branch_link=None, - is_root=False, - served_url=_DEFAULT, - use_cdn=False, - private=False, - export_tarballs=True, - ): - """Create branch-publishing WSGI app. - - :param export_tarballs: If true, allow downloading snapshots of revisions - as tarballs. - """ - self.branch = branch - self._config = config - self.friendly_name = friendly_name - self.branch_link = branch_link # Currently only used in Launchpad - self.log = logging.getLogger("loggerhead.%s" % (friendly_name,)) - if graph_cache is None: - graph_cache = breezy.lru_cache.LRUCache(10) - self.graph_cache = graph_cache - self.is_root = is_root - self.served_url = served_url - self.use_cdn = use_cdn - self.private = private - self.export_tarballs = export_tarballs - - def public_private_css(self): - if self.private: - return "private" - else: - return "public" - - def get_history(self): - revinfo_disk_cache = None - cache_path = self._config.get("cachepath", None) - if cache_path is not None: - # Only import the cache if we're going to use it. - # This makes sqlite optional - try: - from ..changecache import RevInfoDiskCache - except ImportError: - self.log.debug( - "Couldn't load python-sqlite, continuing without using a cache" - ) - else: - revinfo_disk_cache = RevInfoDiskCache(cache_path) - return History( - self.branch, - self.graph_cache, - revinfo_disk_cache=revinfo_disk_cache, - cache_key=( - self.friendly_name.encode("utf-8") if self.friendly_name else None - ), - ) - - # Before the addition of this method, clicking to sort by date from - # within a branch caused a jump up to the top of that branch. - def sort_url(self, *args, **kw): - if isinstance(args[0], list): - args = args[0] - qs = [] - for k, v in kw.items(): - if v is not None: - qs.append("%s=%s" % (k, urlutils.quote(v))) - qs = "&".join(qs) - path_info = self._path_info.strip("/").split("?")[0] - path_info += "?" + qs - return self._url_base + "/" + path_info - - def url(self, *args, **kw): - if isinstance(args[0], list): - args = args[0] - qs = [] - for k, v in kw.items(): - if v is not None: - qs.append("%s=%s" % (k, urlutils.quote(v))) - qs = "&".join(qs) - path_info = urlutils.quote("/".join(args), safe="/~:") - if qs: - path_info += "?" + qs - return self._url_base + path_info - - def absolute_url(self, *args, **kw): - rel_url = self.url(*args, **kw) - return request.resolve_relative_url(rel_url, self._environ) - - def context_url(self, *args, **kw): - kw = util.get_context(**kw) - return self.url(*args, **kw) - - def static_url(self, path): - return self._static_url_base + path - - def js_library_url(self, path): - if self.use_cdn: - if path == "jquery.min.js": - return ( - "https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js" - ) - raise KeyError("unknown js library %s" % path) - else: - return self.static_url("/static/javascript/" + path) - - controllers_dict = { - "+filediff": FileDiffUI, - "+revlog": RevLogUI, - "annotate": AnnotateUI, - "atom": AtomUI, - "changes": ChangeLogUI, - "diff": DiffUI, - "download": DownloadUI, - "files": InventoryUI, - "revision": RevisionUI, - "search": SearchUI, - "view": ViewUI, - "tarball": DownloadTarballUI, - } - - def last_updated(self): - h = self.get_history() - change = h.get_changes([h.last_revid])[0] - return change.date - - def public_branch_url(self): - return self.branch.get_public_branch() - - def lookup_app(self, environ): - # Check again if the branch is blocked from being served, this is - # mostly for tests. It's already checked in apps/transport.py - if not self.branch.get_config().get_user_option_as_bool( - "http_serve", default=True - ): - raise httpexceptions.HTTPNotFound() - self._url_base = environ["SCRIPT_NAME"] - self._path_info = environ["PATH_INFO"] - self._static_url_base = environ.get("loggerhead.static.url") - if self._static_url_base is None: - self._static_url_base = self._url_base - self._environ = environ - if self.served_url is _DEFAULT: - public_branch = self.public_branch_url() - if public_branch is not None: - self.served_url = public_branch - else: - self.served_url = wsgiref.util.application_uri(environ) - for hook in self.hooks["controller"]: - controller = hook(self, environ) - if controller is not None: - return controller - path = request.path_info_pop(environ) - if not path: - raise httpexceptions.HTTPMovedPermanently(self.absolute_url("/changes")) - if path == "health": - return health_app - if path == "static": - return static_app - elif path == "+json": - environ["loggerhead.as_json"] = True - path = request.path_info_pop(environ) - cls = self.controllers_dict.get(path) - if cls is not None: - return cls(self, self.get_history) - raise httpexceptions.HTTPNotFound() - - def app(self, environ, start_response): - with self.branch.lock_read(): - try: - c = self.lookup_app(environ) - return c(environ, start_response) - except: - environ["exc_info"] = sys.exc_info() - environ["branch"] = self - raise - - -class BranchWSGIAppHooks(Hooks): - """A dictionary mapping hook name to a list of callables for WSGI app branch hooks.""" - - def __init__(self): - """Create the default hooks.""" - Hooks.__init__( - self, "breezy.plugins.loggerhead.apps.branch", "BranchWSGIApp.hooks" - ) - self.add_hook( - "controller", - "Invoked when looking for the controller to use for a " - "branch subpage. The api signature is (branch_app, environ)." - "If a hook can provide a controller, it should return one, " - "as a standard WSGI app. If it can't provide a controller, " - "it should return None", - (1, 19), - ) - - -BranchWSGIApp.hooks = BranchWSGIAppHooks() diff --git a/loggerhead/apps/error.py b/loggerhead/apps/error.py deleted file mode 100644 index 544c8bed..00000000 --- a/loggerhead/apps/error.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (C) 2008 Guillermo Gonzalez -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -from ..controllers.error_ui import ErrorUI - - -class ErrorHandlerApp(object): - """Class for WSGI error logging middleware.""" - - msg = " %s.%s: %s \n" - - def __init__(self, application, **kwargs): - self.application = application - - def __call__(self, environ, start_response): - try: - return self.application(environ, start_response) - except BaseException: - # test if exc_info has been set, in the case that - # the error is caused before BranchWSGGIApp middleware - if "exc_info" in environ.keys() and "branch" in environ.keys(): - # Log and/or report any application errors - return self.handle_error(environ, start_response) - else: - # simply propagate the error, this is logged - # by paste.httpexceptions.TransLogger middleware - raise - - def handle_error(self, environ, start_response): - """Exception handler.""" - self.log_error(environ) - return errapp(environ, start_response) - - def log_error(self, environ): - exc_type, exc_object, exc_tb = environ["exc_info"] - logger = environ["branch"].log - logger.exception(self.msg, exc_type.__module__, exc_type.__name__, exc_object) - - -def errapp(environ, start_response): - """Default (and trivial) error handling WSGI application.""" - c = ErrorUI(environ["branch"], environ["exc_info"]) - return c(environ, start_response) diff --git a/loggerhead/apps/http_head.py b/loggerhead/apps/http_head.py deleted file mode 100644 index a47eccc2..00000000 --- a/loggerhead/apps/http_head.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (C) 2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# -"""WSGI apps tend to return body content as part of a HEAD request. - -We should definitely not do that. -""" - - -class HeadMiddleware(object): - """When we get a HEAD request, we should not return body content. - - WSGI defaults to just generating everything, and not paying attention to - whether it is a GET or a HEAD request. It does that because of potential - issues getting the Headers correct. - - This middleware works by just omitting the body if the request method is - HEAD. - """ - - def __init__(self, app): - self._wrapped_app = app - self._real_environ = None - self._real_start_response = None - self._real_writer = None - - def noop_write(self, chunk): - """We intentionally ignore all body content that is returned.""" - pass - - def start_response(self, status, response_headers, exc_info=None): - if exc_info is None: - self._real_writer = self._real_start_response(status, response_headers) - else: - self._real_writer = self._real_start_response( - status, response_headers, exc_info - ) - return self.noop_write - - def __call__(self, environ, start_response): - self._real_environ = environ - self._real_start_response = start_response - if environ.get("REQUEST_METHOD", "GET") == "HEAD": - result = self._wrapped_app(environ, self.start_response) - for chunk in result: - pass - else: - result = self._wrapped_app(environ, start_response) - for chunk in result: - yield chunk diff --git a/loggerhead/apps/transport.py b/loggerhead/apps/transport.py deleted file mode 100644 index bb6fb38e..00000000 --- a/loggerhead/apps/transport.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright (C) 2008-2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# -"""Serve branches at urls that mimic a transport's file system layout.""" - -import threading - -from breezy import branch, errors, lru_cache, urlutils -from breezy.config import LocationConfig -from breezy.transport import get_transport -from breezy.transport.http import wsgi -from paste import httpexceptions, urlparser -from paste.request import path_info_pop - -from .. import util -from ..apps import favicon_app, robots_app, static_app -from ..apps.branch import BranchWSGIApp -from ..controllers.directory_ui import DirectoryUI - - -class BranchesFromTransportServer(object): - def __init__(self, transport, root, name=None): - self.transport = transport - self.root = root - self.name = name - self._config = root._config - - def app_for_branch(self, branch): - if not self.name: - name = branch._get_nick(local=True) - is_root = True - else: - name = self.name - is_root = False - branch_app = BranchWSGIApp( - branch, - name, - {"cachepath": self._config.SQL_DIR}, - self.root.graph_cache, - is_root=is_root, - use_cdn=self._config.get_option("use_cdn"), - ) - return branch_app.app - - def app_for_non_branch(self, environ): - segment = path_info_pop(environ) - if segment is None: - raise httpexceptions.HTTPMovedPermanently.relative_redirect( - environ["SCRIPT_NAME"] + "/", environ - ) - elif segment == "": - if self.name: - name = self.name - else: - name = "/" - return DirectoryUI(environ["loggerhead.static.url"], self.transport, name) - else: - new_transport = self.transport.clone(segment) - if self.name: - new_name = urlutils.join(self.name, segment) - else: - new_name = "/" + segment - return BranchesFromTransportServer(new_transport, self.root, new_name) - - def app_for_bazaar_data(self, relpath): - if relpath == "/.bzr/smart": - root_transport = get_transport_for_thread(self.root.base) - wsgi_app = wsgi.SmartWSGIApp(root_transport) - return wsgi.RelpathSetter(wsgi_app, "", "loggerhead.path_info") - else: - # TODO: Use something here that uses the transport API - # rather than relying on the local filesystem API. - base = self.transport.base - try: - path = util.local_path_from_url(base) - except errors.InvalidURL: - raise httpexceptions.HTTPNotFound() - else: - return urlparser.make_static(None, path) - - def check_serveable(self, config): - if not config.get_user_option_as_bool("http_serve", default=True): - raise httpexceptions.HTTPNotFound() - - def __call__(self, environ, start_response): - path = environ["PATH_INFO"] - try: - b = branch.Branch.open_from_transport(self.transport) - except errors.NotBranchError: - if path.startswith("/.bzr"): - self.check_serveable(LocationConfig(self.transport.base)) - return self.app_for_bazaar_data(path)(environ, start_response) - if not self.transport.listable() or not self.transport.has("."): - raise httpexceptions.HTTPNotFound() - return self.app_for_non_branch(environ)(environ, start_response) - else: - self.check_serveable(b.get_config()) - if path.startswith("/.bzr"): - return self.app_for_bazaar_data(path)(environ, start_response) - else: - return self.app_for_branch(b)(environ, start_response) - - -_transport_store = threading.local() - - -def get_transport_for_thread(base): - thread_transports = getattr(_transport_store, "transports", None) - if thread_transports is None: - thread_transports = _transport_store.transports = {} - if base in thread_transports: - return thread_transports[base] - transport = get_transport(base) - thread_transports[base] = transport - return transport - - -class BranchesFromTransportRoot(object): - def __init__(self, base, config): - self.graph_cache = lru_cache.LRUCache(10) - self.base = base - self._config = config - - def __call__(self, environ, start_response): - environ["loggerhead.static.url"] = environ["SCRIPT_NAME"] - environ["loggerhead.path_info"] = environ["PATH_INFO"] - if environ["PATH_INFO"].startswith("/static/"): - segment = path_info_pop(environ) - assert segment == "static" - return static_app(environ, start_response) - elif environ["PATH_INFO"] == "/favicon.ico": - return favicon_app(environ, start_response) - elif environ["PATH_INFO"] == "/robots.txt": - return robots_app(environ, start_response) - else: - transport = get_transport_for_thread(self.base) - return BranchesFromTransportServer(transport, self)(environ, start_response) - - -class UserBranchesFromTransportRoot(object): - def __init__(self, base, config): - self.graph_cache = lru_cache.LRUCache(10) - self.base = base - self._config = config - self.trunk_dir = config.get_option("trunk_dir") - - def __call__(self, environ, start_response): - environ["loggerhead.static.url"] = environ["SCRIPT_NAME"] - environ["loggerhead.path_info"] = environ["PATH_INFO"] - path_info = environ["PATH_INFO"] - if path_info.startswith("/static/"): - segment = path_info_pop(environ) - assert segment == "static" - return static_app(environ, start_response) - elif path_info == "/favicon.ico": - return favicon_app(environ, start_response) - elif environ["PATH_INFO"] == "/robots.txt": - return robots_app(environ, start_response) - else: - transport = get_transport_for_thread(self.base) - # segments starting with ~ are user branches - if path_info.startswith("/~"): - segment = path_info_pop(environ) - return BranchesFromTransportServer( - transport.clone(segment[1:]), self, segment - )(environ, start_response) - else: - return BranchesFromTransportServer( - transport.clone(self.trunk_dir), self - )(environ, start_response) diff --git a/loggerhead/changecache.py b/loggerhead/changecache.py deleted file mode 100644 index cbfb72cb..00000000 --- a/loggerhead/changecache.py +++ /dev/null @@ -1,149 +0,0 @@ -# -# Copyright (C) 2006 Robey Pointer -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -""" -a cache for chewed-up 'file change' data structures, which are basically just -a different way of storing a revision delta. the cache improves lookup times -10x over bazaar's xml revision structure, though, so currently still worth -doing. - -once a revision is committed in bazaar, it never changes, so once we have -cached a change, it's good forever. -""" - -import marshal -import os -import pickle -import tempfile -import zlib -from sqlite3 import dbapi2 - -# We take an optimistic approach to concurrency here: we might do work twice -# in the case of races, but not crash or corrupt data. - - -def safe_init_db(filename, init_sql): - # To avoid races around creating the database, we create the db in - # a temporary file and rename it into the ultimate location. - fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(filename)) - os.close(fd) - con = dbapi2.connect(temp_path) - cur = con.cursor() - cur.execute(init_sql) - con.commit() - con.close() - os.rename(temp_path, filename) - - -class FakeShelf(object): - def __init__(self, filename): - create_table = not os.path.exists(filename) - if create_table: - safe_init_db( - filename, - "create table RevisionData (revid binary primary key, data binary)", - ) - self.connection = dbapi2.connect(filename) - self.cursor = self.connection.cursor() - - def _create_table(self, filename): - con = dbapi2.connect(filename) - cur = con.cursor() - cur.execute("create table RevisionData (revid binary primary key, data binary)") - con.commit() - con.close() - - def _serialize(self, obj): - return dbapi2.Binary(pickle.dumps(obj, protocol=2)) - - def _unserialize(self, data): - return pickle.loads(str(data)) - - def get(self, revid): - self.cursor.execute("select data from revisiondata where revid = ?", (revid,)) - filechange = self.cursor.fetchone() - if filechange is None: - return None - else: - return self._unserialize(filechange[0]) - - def add(self, revid, object): - try: - self.cursor.execute( - "insert into revisiondata (revid, data) values (?, ?)", - (revid, self._serialize(object)), - ) - self.connection.commit() - except dbapi2.IntegrityError: - # If another thread or process attempted to set the same key, we - # assume it set it to the same value and carry on with our day. - pass - - -class RevInfoDiskCache(object): - """Like `RevInfoMemoryCache` but backed in a sqlite DB.""" - - def __init__(self, cache_path): - if not os.path.exists(cache_path): - os.mkdir(cache_path) - filename = os.path.join(cache_path, "revinfo.sql") - create_table = not os.path.exists(filename) - if create_table: - safe_init_db( - filename, - "create table Data (key binary primary key, revid binary, data binary)", - ) - self.connection = dbapi2.connect(filename) - self.cursor = self.connection.cursor() - - def get(self, key, revid): - if not isinstance(key, bytes): - raise TypeError(key) - if not isinstance(revid, bytes): - raise TypeError(revid) - self.cursor.execute( - "select revid, data from data where key = ?", (dbapi2.Binary(key),) - ) - row = self.cursor.fetchone() - if row is None: - return None - elif str(row[0]) != revid: - return None - else: - try: - return marshal.loads(zlib.decompress(row[1])) - except (EOFError, ValueError, TypeError): - return None - - def set(self, key, revid, data): - if not isinstance(key, bytes): - raise TypeError(key) - if not isinstance(revid, bytes): - raise TypeError(revid) - try: - self.cursor.execute("delete from data where key = ?", (dbapi2.Binary(key),)) - blob = zlib.compress(marshal.dumps(data, 2)) - self.cursor.execute( - "insert into data (key, revid, data) values (?, ?, ?)", - list(map(dbapi2.Binary, [key, revid, blob])), - ) - self.connection.commit() - except dbapi2.IntegrityError: - # If another thread or process attempted to set the same key, we - # don't care too much -- it's only a cache after all! - pass diff --git a/loggerhead/config.py b/loggerhead/config.py deleted file mode 100644 index d8eaba48..00000000 --- a/loggerhead/config.py +++ /dev/null @@ -1,185 +0,0 @@ -# -# Copyright (C) 2008, 2009 Canonical Ltd -# -# 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 2 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. -# -"""Configuration tools for Loggerhead.""" - -import sys -import tempfile -from optparse import OptionParser - -from breezy import config - -_temporary_sql_dir = None - - -def _get_temporary_sql_dir(): - global _temporary_sql_dir - if _temporary_sql_dir is None: - _temporary_sql_dir = tempfile.mkdtemp(prefix="loggerhead-cache-") - return _temporary_sql_dir - - -def command_line_parser(): - parser = OptionParser("%prog [options] ") - parser.set_defaults( - user_dirs=False, - show_version=False, - log_folder=None, - use_cdn=False, - sql_dir=None, - allow_writes=False, - export_tarballs=True, - ) - parser.add_option( - "--user-dirs", action="store_true", help="Serve user directories as ~user." - ) - parser.add_option( - "--trunk-dir", - metavar="DIR", - help="The directory that contains the trunk branches.", - ) - parser.add_option( - "--port", - dest="user_port", - help=("Port Loggerhead should listen on (defaults to 8080)."), - ) - parser.add_option( - "--host", dest="user_host", help="Host Loggerhead should listen on." - ) - parser.add_option( - "--protocol", - dest="protocol", - help=("Protocol to use: http, scgi, fcgi, ajp(defaults to http)."), - ) - parser.add_option( - "--log-level", - default=None, - action="callback", - callback=_optparse_level_to_int_level, - type="string", - help="Set the verbosity of logging. Can either" - " be set to a numeric or string" - " (eg, 10=debug, 30=warning)", - ) - parser.add_option( - "--memory-profile", - action="store_true", - help="Profile the memory usage using Dozer.", - ) - parser.add_option("--prefix", dest="user_prefix", help="Specify host prefix.") - parser.add_option( - "--profile", - action="store_true", - help="Generate callgrind profile data to %d-stats.callgrind on each request.", - ) - parser.add_option( - "--reload", - action="store_true", - help="Restarts the application when changing python" - " files. Only used for development purposes.", - ) - parser.add_option("--log-folder", help="The directory to place log files in.") - parser.add_option( - "--version", - action="store_true", - dest="show_version", - help="Print the software version and exit", - ) - parser.add_option( - "--use-cdn", - action="store_true", - dest="use_cdn", - help="Serve jQuery from Google's CDN", - ) - parser.add_option( - "--cache-dir", dest="sql_dir", help="The directory to place the SQL cache in" - ) - parser.add_option( - "--allow-writes", - action="store_true", - help="Allow writing to the Bazaar server.", - ) - parser.add_option( - "--export-tarballs", - action="store_true", - help="Allow exporting revisions to tarballs.", - ) - return parser - - -_log_levels = { - "debug": 10, - "info": 20, - "warning": 30, - "error": 40, - "critical": 50, -} - - -def _optparse_level_to_int_level(option, opt_str, value, parser): - parser.values.log_level = _level_to_int_level(value) - - -def _level_to_int_level(value): - """Convert a string level to an integer value.""" - if value is None: - return None - try: - return int(value) - except ValueError: - pass - return _log_levels[value.lower()] - - -class LoggerheadConfig(object): - """A configuration object.""" - - def __init__(self, argv=None): - if argv is None: - argv = sys.argv[1:] - self._parser = command_line_parser() - self._options, self._args = self._parser.parse_args(argv) - - sql_dir = self.get_option("sql_dir") - if sql_dir is None: - sql_dir = _get_temporary_sql_dir() - self.SQL_DIR = sql_dir - - def get_option(self, option): - """Get the value for the config option, either - from ~/.config/breezy/breezy.conf or from the command line. - All loggerhead-specific settings start with 'http_' - """ - global_config = config.GlobalConfig().get_user_option("http_" + option) - cmd_config = getattr(self._options, option) - if global_config is not None and (cmd_config is None or cmd_config is False): - return global_config - else: - return cmd_config - - def get_log_level(self): - opt = self.get_option("log_level") - return _level_to_int_level(opt) - - def get_arg(self, index): - """Get an arg from the arg list.""" - return self._args[index] - - def print_help(self): - """Wrapper around OptionParser.print_help.""" - return self._parser.print_help() - - @property - def arg_count(self): - """Return the number of args from the option parser.""" - return len(self._args) diff --git a/loggerhead/controllers/__init__.py b/loggerhead/controllers/__init__.py deleted file mode 100644 index eaa7a5d8..00000000 --- a/loggerhead/controllers/__init__.py +++ /dev/null @@ -1,152 +0,0 @@ -# -# Copyright (C) 2008 Canonical Ltd. -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - -import json -import time - -import breezy.errors -from paste.httpexceptions import HTTPNotFound -from paste.request import parse_querystring, path_info_pop - -from .. import templates, util -from ..templatefunctions import templatefunctions -from ..zptsupport import load_template - - -class BufferingWriter(object): - def __init__(self, writefunc, buf_limit): - self.bytes = 0 - self.buf = [] - self.buflen = 0 - self.writefunc = writefunc - self.buf_limit = buf_limit - - def flush(self): - self.writefunc(b"".join(self.buf)) - self.buf = [] - self.buflen = 0 - - def write(self, data): - self.buf.append(data) - self.buflen += len(data) - self.bytes += len(data) - if self.buflen > self.buf_limit: - self.flush() - - -class TemplatedBranchView(object): - template_name = None - supports_json = False - - def __init__(self, branch, history_callable): - self._branch = branch - self._history_callable = history_callable - self.__history = None - self.log = branch.log - - @property - def _history(self): - if self.__history is not None: - return self.__history - self.__history = self._history_callable() - return self.__history - - def parse_args(self, environ): - kwargs = dict(parse_querystring(environ)) - util.set_context(kwargs) - args = [] - while True: - arg = path_info_pop(environ) - if arg is None: - break - args.append(arg) - - path = None - if len(args) > 1: - path = "/".join(args[1:]) - if isinstance(path, bytes): - # Python 2 - path = path.decode("utf-8") - self.args = args - self.kwargs = kwargs - return path - - def add_template_values(self, values): - values.update( - { - "static_url": self._branch.static_url, - "branch": self._branch, - "util": util, - "url": self._branch.context_url, - } - ) - values.update(templatefunctions) - - def __call__(self, environ, start_response): - z = time.time() - if environ.get("loggerhead.as_json") and not self.supports_json: - raise HTTPNotFound - path = self.parse_args(environ) - headers = {} - values = self.get_values(path, self.kwargs, headers) - - self.log.info( - "Getting information for %s: %.3f secs" - % (self.__class__.__name__, time.time() - z) - ) - if environ.get("loggerhead.as_json"): - headers["Content-Type"] = "application/json" - elif "Content-Type" not in headers: - headers["Content-Type"] = "text/html" - writer = start_response("200 OK", list(headers.items())) - if environ.get("REQUEST_METHOD") == "HEAD": - # No content for a HEAD request - return [] - z = time.time() - w = BufferingWriter(writer, 8192) - if environ.get("loggerhead.as_json"): - w.write( - json.dumps(values, default=util.convert_to_json_ready).encode("utf-8") - ) - else: - self.add_template_values(values) - template = load_template("%s.%s" % (templates.__name__, self.template_name)) - template.expand_into(w, **values) - w.flush() - self.log.info( - "Rendering %s: %.3f secs, %s bytes" - % (self.__class__.__name__, time.time() - z, w.bytes) - ) - return [] - - def get_revid(self): - h = self._history - if h is None: - return None - if len(self.args) > 0 and self.args != [""]: - try: - revid = h.fix_revid(self.args[0]) - except breezy.errors.NoSuchRevision: - raise HTTPNotFound - assert isinstance(revid, bytes) - else: - revid = h.last_revid - if revid is not None and not isinstance(revid, bytes): - raise TypeError(revid) - return revid diff --git a/loggerhead/controllers/annotate_ui.py b/loggerhead/controllers/annotate_ui.py deleted file mode 100644 index 95180497..00000000 --- a/loggerhead/controllers/annotate_ui.py +++ /dev/null @@ -1,88 +0,0 @@ -# -# Copyright (C) 2010 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import itertools -from datetime import datetime - -from .. import util -from ..controllers.view_ui import ViewUI - - -class AnnotateUI(ViewUI): - def annotate_file(self, path, info): - revid = info["change"].revid - if not isinstance(revid, bytes): - raise TypeError(revid) - - tree = self.tree_for(path, revid) - - change_cache = {} - last_line_revid = None - last_lineno = None - message = "" - - revisions = {} - - lineno = 0 - for (line_revid, text), lineno in zip( - tree.annotate_iter(path), itertools.count(1) - ): - if line_revid != last_line_revid: - last_line_revid = line_revid - - change = change_cache.get(line_revid, None) - if change is None: - changes = self._history.get_changes([line_revid]) - if changes: - change = changes[0] - else: - change = util.Container( - authors="", date=datetime.now(), revno="" - ) - change_cache[line_revid] = change - - try: - message = change.comment.splitlines()[0] - except IndexError: - # Comment not present for this revision - message = "" - - if last_lineno: - # The revspan is of lines between the last revision and this one. - # We set the one for the previous revision when we're creating the current revision. - revisions[last_lineno].revspan = lineno - last_lineno - - revisions[lineno] = util.Container(change=change, message=message) - - last_lineno = lineno - last_line_revid = line_revid - - # Zero-size file. Return empty revisions. - if last_lineno is None: - return revisions - - # We never set a revspan for the last revision during the loop above, so set it here. - revisions[last_lineno].revspan = lineno - last_lineno + 1 - - return revisions - - def get_values(self, path, kwargs, headers): - values = super(AnnotateUI, self).get_values(path, kwargs, headers) - values["annotated"] = self.annotate_file(path, values) - - return values diff --git a/loggerhead/controllers/atom_ui.py b/loggerhead/controllers/atom_ui.py deleted file mode 100644 index d44b8dc7..00000000 --- a/loggerhead/controllers/atom_ui.py +++ /dev/null @@ -1,38 +0,0 @@ -# -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -from ..controllers import TemplatedBranchView - - -class AtomUI(TemplatedBranchView): - template_name = "atom" - - def get_values(self, path, kwargs, headers): - history = self._history - pagesize = int(20) # self._branch.config.get('pagesize', '20')) - - revid_list = history.get_file_view(history.last_revid, None) - entries = list(history.get_changes(list(revid_list)[:pagesize])) - - headers["Content-Type"] = "application/atom+xml" - return { - "changes": entries, - "updated": entries[0].utc_date.isoformat(), - "history": self._history, - } diff --git a/loggerhead/controllers/changelog_ui.py b/loggerhead/controllers/changelog_ui.py deleted file mode 100644 index f4cbef82..00000000 --- a/loggerhead/controllers/changelog_ui.py +++ /dev/null @@ -1,115 +0,0 @@ -# -# Copyright (C) 2008, 2009 Canonical Ltd. -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import json - -from breezy import urlutils -from paste.httpexceptions import HTTPServerError - -from .. import util -from ..controllers import TemplatedBranchView - - -class ChangeLogUI(TemplatedBranchView): - template_name = "changelog" - - def get_values(self, path, kwargs, headers): - history = self._history - revid = self.get_revid() - filter_path = kwargs.get("filter_path", path) - query = kwargs.get("q", None) - start_revid = history.fix_revid(kwargs.get("start_revid", None)) - orig_start_revid = start_revid - pagesize = 20 # int(config.get('pagesize', '20')) - search_failed = False - - try: - revid, start_revid, revid_list = history.get_view( - revid, start_revid, filter_path, query, extra_rev_count=pagesize + 1 - ) - util.set_context(kwargs) - - if (query is not None) and (len(revid_list) == 0): - search_failed = True - - if len(revid_list) == 0: - scan_list = revid_list - else: - if revid in revid_list: # XXX is this always true? - i = revid_list.index(revid) - else: - i = None - scan_list = revid_list[i:] - change_list = scan_list[:pagesize] - changes = list(history.get_changes(change_list)) - data = {} - for i, c in enumerate(changes): - c.index = i - data[str(i)] = urlutils.quote( - urlutils.quote_from_bytes(c.revid, safe="") - ) - except BaseException as e: - self.log.exception("Exception fetching changes") - raise HTTPServerError("Could not fetch changes") from e - - navigation = util.Container( - pagesize=pagesize, - revid=revid, - start_revid=start_revid, - revid_list=revid_list, - filter_path=filter_path, - scan_url="/changes", - branch=self._branch, - feed=True, - history=history, - ) - if query is not None: - navigation.query = query - util.fill_in_navigation(navigation) - - # Directory Breadcrumbs - directory_breadcrumbs = util.directory_breadcrumbs( - self._branch.friendly_name, self._branch.is_root, "changes" - ) - - show_tag_col = False - for change in changes: - if change.tags is not None: - show_tag_col = True - break - - return { - "branch": self._branch, - "changes": changes, - "show_tag_col": show_tag_col, - "data": json.dumps(data), - "util": util, - "history": history, - "revid": revid, - "navigation": navigation, - "filter_path": filter_path, - "start_revid": start_revid, - "viewing_from": (orig_start_revid is not None) - and (orig_start_revid != history.last_revid), - "query": query, - "search_failed": search_failed, - "url": self._branch.context_url, - "directory_breadcrumbs": directory_breadcrumbs, - } diff --git a/loggerhead/controllers/diff_ui.py b/loggerhead/controllers/diff_ui.py deleted file mode 100644 index da5ede17..00000000 --- a/loggerhead/controllers/diff_ui.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright (C) 2008-2011 Canonical Ltd. -# (Authored by Martin Albisetti ) -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import time -from io import BytesIO - -from breezy.diff import show_diff_trees -from breezy.revision import NULL_REVISION -from paste.request import parse_querystring, path_info_pop - -from ..controllers import TemplatedBranchView - - -class DiffUI(TemplatedBranchView): - """Class to output a diff for a single file or revisions.""" - - def __call__(self, environ, start_response): - # End of URL is now /diff/?context= - # or /diff//?context= - # This allows users to choose how much context they want to see. - # Old format was /diff// or /diff/ - """Default method called from /diff URL.""" - z = time.time() - - args = [] - while True: - arg = path_info_pop(environ) - if arg is None: - break - args.append(arg) - - numlines = 3 # This is the default. - - opts = parse_querystring(environ) - for opt in opts: - if opt[0] == "context": - try: - numlines = int(opt[1]) - except ValueError: - pass - - revid_from = args[0] - # Convert a revno to a revid if we get a revno. - revid_from = self._history.fix_revid(revid_from) - change = self._history.get_changes([revid_from])[0] - - if len(args) == 2: - revid_to = self._history.fix_revid(args[1]) - elif len(change.parents) == 0: - revid_to = NULL_REVISION - else: - revid_to = change.parents[0].revid - - repo = self._branch.branch.repository - revtree1 = repo.revision_tree(revid_to) - revtree2 = repo.revision_tree(revid_from) - - diff_content_stream = BytesIO() - show_diff_trees( - revtree1, - revtree2, - diff_content_stream, - old_label="", - new_label="", - context=numlines, - ) - - content = diff_content_stream.getvalue() - - self.log.info( - "/diff %r:%r in %r secs with %r context" - % (revid_from, revid_to, time.time() - z, numlines) - ) - - revno1 = self._history.get_revno(revid_from) - revno2 = self._history.get_revno(revid_to) - filename = "%s_%s.diff" % (revno1, revno2) - headers = [ - ("Content-Type", "application/octet-stream"), - ("Content-Length", str(len(content))), - ("Content-Disposition", "attachment; filename=%s" % (filename,)), - ] - start_response("200 OK", headers) - return [content] diff --git a/loggerhead/controllers/directory_ui.py b/loggerhead/controllers/directory_ui.py deleted file mode 100644 index 37eb0421..00000000 --- a/loggerhead/controllers/directory_ui.py +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright (C) 2008 Canonical Ltd. -# (Authored by Martin Albisetti ) -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import datetime -import logging -import stat - -from breezy import branch, errors, urlutils - -try: - from breezy.transport import NoSuchFile -except ImportError: - from breezy.errors import NoSuchFile - -from .. import util -from ..controllers import TemplatedBranchView - - -class DirEntry(object): - def __init__(self, dirname, parity, branch, transport): - self.dirname = urlutils.unquote(dirname) - self.parity = parity - self.branch = branch - if branch is None: - self.last_revision = None - try: - self.last_change_time = datetime.datetime.utcfromtimestamp( - transport.stat(self.dirname).st_mtime - ) - except Exception: - self.last_change_time = None - else: - # If a branch is empty, bzr raises an exception when trying this - try: - self.last_revision = branch.repository.get_revision( - branch.last_revision() - ) - self.last_change_time = datetime.datetime.utcfromtimestamp( - self.last_revision.timestamp - ) - except errors.NoSuchRevision: - self.last_revision = None - self.last_change_time = None - - -class DirectoryUI(TemplatedBranchView): - """ """ - - template_name = "directory" - - def __init__(self, static_url_base, transport, name): - class _branch(object): - context_url = 1 - - @staticmethod - def static_url(path): - return self._static_url_base + path - - self._branch = _branch - self._history_callable = lambda: None - self._name = name - self._static_url_base = static_url_base - self.transport = transport - self.log = logging.getLogger("") - - def get_values(self, path, kwargs, response): - listing = [d for d in self.transport.list_dir(".") if not d.startswith(".")] - listing.sort(key=lambda x: x.lower()) - dirs = [] - parity = 0 - for d in listing: - try: - b = branch.Branch.open_from_transport(self.transport.clone(d)) - except BaseException: - # TODO(jelmer): don't catch all exceptions here - try: - if not stat.S_ISDIR(self.transport.stat(d).st_mode): - continue - except NoSuchFile: - continue - b = None - else: - if not b.get_config().get_user_option_as_bool( - "http_serve", default=True - ): - continue - dirs.append(DirEntry(d, parity, b, self.transport)) - parity = 1 - parity - # Create breadcrumb trail - directory_breadcrumbs = util.directory_breadcrumbs( - self._name, False, "directory" - ) - return { - "dirs": dirs, - "name": self._name, - "directory_breadcrumbs": directory_breadcrumbs, - } diff --git a/loggerhead/controllers/download_ui.py b/loggerhead/controllers/download_ui.py deleted file mode 100644 index d2699a91..00000000 --- a/loggerhead/controllers/download_ui.py +++ /dev/null @@ -1,115 +0,0 @@ -# -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import logging -import mimetypes - -from breezy.errors import NoSuchRevision - -try: - from breezy.transport import NoSuchFile -except ImportError: - from breezy.errors import NoSuchFile - -from breezy import urlutils -from paste import httpexceptions -from paste.request import path_info_pop - -from ..controllers import TemplatedBranchView - -log = logging.getLogger("loggerhead.controllers") - - -class DownloadUI(TemplatedBranchView): - def encode_filename(self, filename): - return urlutils.escape(filename) - - def get_args(self, environ): - args = [] - while True: - arg = path_info_pop(environ) - if arg is None: - break - args.append(arg) - return args - - def __call__(self, environ, start_response): - # /download// - h = self._history - args = self.get_args(environ) - if len(args) < 2: - raise httpexceptions.HTTPMovedPermanently( - self._branch.absolute_url("/changes") - ) - revid = h.fix_revid(args[0]) - try: - path, filename, content = h.get_file("/".join(args[1:]), revid) - except (NoSuchFile, NoSuchRevision): - raise httpexceptions.HTTPNotFound() - mime_type, encoding = mimetypes.guess_type(filename) - if mime_type is None: - mime_type = "application/octet-stream" - self.log.info( - "/download %s @ %s (%d bytes)", path, h.get_revno(revid), len(content) - ) - encoded_filename = self.encode_filename(filename) - headers = [ - ("Content-Type", mime_type), - ("Content-Length", str(len(content))), - ( - "Content-Disposition", - "attachment; filename*=utf-8''%s" % (encoded_filename,), - ), - ] - start_response("200 OK", headers) - return [content] - - -class DownloadTarballUI(DownloadUI): - def __call__(self, environ, start_response): - """Stream a tarball from a bazaar branch.""" - # Tried to re-use code from downloadui, not very successful - if not self._branch.export_tarballs: - raise httpexceptions.HTTPForbidden("Tarball downloads are not allowed") - archive_format = "tgz" - history = self._history - self.args = self.get_args(environ) - if len(self.args): - revid = history.fix_revid(self.args[0]) - version_part = "-r" + self.args[0] - else: - revid = self.get_revid() - version_part = "" - # XXX: Perhaps some better suggestion based on the URL or path? - # - # TODO: Perhaps set the tarball suggested mtime to the revision - # mtime. - root = self._branch.friendly_name or "branch" - filename = root + version_part + "." + archive_format - encoded_filename = self.encode_filename(filename) - headers = [ - ("Content-Type", "application/octet-stream"), - ( - "Content-Disposition", - "attachment; filename*=utf-8''%s" % (encoded_filename,), - ), - ] - start_response("200 OK", headers) - tree = history._branch.repository.revision_tree(revid) - return tree.archive(root=root, format=archive_format, name=filename) diff --git a/loggerhead/controllers/error_ui.py b/loggerhead/controllers/error_ui.py deleted file mode 100644 index 63c9ee6f..00000000 --- a/loggerhead/controllers/error_ui.py +++ /dev/null @@ -1,47 +0,0 @@ -# -# Copyright (C) 2008 Guillermo Gonzalez -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import traceback -from io import StringIO - -from .. import util -from ..controllers import TemplatedBranchView - - -class ErrorUI(TemplatedBranchView): - template_name = "error" - - def __init__(self, branch, exc_info): - super(ErrorUI, self).__init__(branch, lambda: None) - self.exc_info = exc_info - - def get_values(self, path, kwargs, response): - exc_type, exc_object, exc_tb = self.exc_info - description = StringIO() - traceback.print_exception(exc_type, exc_object, None, file=description) - directory_breadcrumbs = util.directory_breadcrumbs( - self._branch.friendly_name, self._branch.is_root, "changes" - ) - return { - "branch": self._branch, - "error_title": ( - "An unexpected error occurred whileprocessing the request:" - ), - "error_description": description.getvalue(), - "directory_breadcrumbs": directory_breadcrumbs, - } diff --git a/loggerhead/controllers/filediff_ui.py b/loggerhead/controllers/filediff_ui.py deleted file mode 100644 index 300ce5d1..00000000 --- a/loggerhead/controllers/filediff_ui.py +++ /dev/null @@ -1,142 +0,0 @@ -from io import BytesIO - -from breezy import diff, errors, urlutils - -try: - from breezy.transport import NoSuchFile -except ImportError: - from breezy.errors import NoSuchFile - -from breezy.tree import find_previous_path - -from .. import util -from ..controllers import TemplatedBranchView - - -def _process_diff(difftext): - chunks = [] - chunk = None - - def decode_line(line): - return line.decode("utf-8", "replace") - - for line in difftext.splitlines(): - if len(line) == 0: - continue - if line.startswith(b"+++ ") or line.startswith(b"--- "): - continue - if line.startswith(b"@@ "): - # new chunk - if chunk is not None: - chunks.append(chunk) - chunk = util.Container() - chunk.diff = [] - split_lines = line.split(b" ")[1:3] - lines = [int(x.split(b",")[0][1:]) for x in split_lines] - old_lineno = lines[0] - new_lineno = lines[1] - elif line.startswith(b" "): - chunk.diff.append( - util.Container( - old_lineno=old_lineno, - new_lineno=new_lineno, - type="context", - line=decode_line(line[1:]), - ) - ) - old_lineno += 1 - new_lineno += 1 - elif line.startswith(b"+"): - chunk.diff.append( - util.Container( - old_lineno=None, - new_lineno=new_lineno, - type="insert", - line=decode_line(line[1:]), - ) - ) - new_lineno += 1 - elif line.startswith(b"-"): - chunk.diff.append( - util.Container( - old_lineno=old_lineno, - new_lineno=None, - type="delete", - line=decode_line(line[1:]), - ) - ) - old_lineno += 1 - else: - chunk.diff.append( - util.Container( - old_lineno=None, new_lineno=None, type="unknown", line=repr(line) - ) - ) - if chunk is not None: - chunks.append(chunk) - return chunks - - -def diff_chunks_for_file( - repository, filename, compare_revid, revid, context_lines=None -): - if context_lines is None: - context_lines = 3 - lines = {} - compare_tree = repository.revision_tree(compare_revid) - tree = repository.revision_tree(revid) - try: - lines[revid] = tree.get_file_lines(filename) - except NoSuchFile: - lines[revid] = [] - lines[compare_revid] = compare_tree.get_file_lines(filename) - else: - compare_filename = find_previous_path(tree, compare_tree, filename) - if compare_filename is not None: - lines[compare_revid] = compare_tree.get_file_lines(compare_filename) - else: - lines[compare_revid] = [] - - buffer = BytesIO() - try: - diff.internal_diff( - "", - lines[compare_revid], - "", - lines[revid], - buffer, - context_lines=context_lines, - ) - except errors.BinaryFile: - difftext = b"" - else: - difftext = buffer.getvalue() - - return _process_diff(difftext) - - -class FileDiffUI(TemplatedBranchView): - template_name = "filediff" - supports_json = True - - def get_values(self, path, kwargs, headers): - revid = urlutils.unquote_to_bytes(self.args[0]) - compare_revid = urlutils.unquote_to_bytes(self.args[1]) - filename = urlutils.unquote(self.args[2]) - - try: - context_lines = int(kwargs["context"]) - except (KeyError, ValueError): - context_lines = None - - chunks = diff_chunks_for_file( - self._history._branch.repository, - filename, - compare_revid, - revid, - context_lines=context_lines, - ) - - return { - "chunks": chunks, - } diff --git a/loggerhead/controllers/inventory_ui.py b/loggerhead/controllers/inventory_ui.py deleted file mode 100644 index 5357edad..00000000 --- a/loggerhead/controllers/inventory_ui.py +++ /dev/null @@ -1,198 +0,0 @@ -# -# Copyright (C) 2008 Canonical Ltd. -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import posixpath - -from breezy import errors, osutils, urlutils -from breezy.revision import is_null as is_null_rev -from paste.httpexceptions import HTTPMovedPermanently, HTTPNotFound - -from .. import util -from ..controllers import TemplatedBranchView - - -def dirname(path): - if path is not None: - path = path.rstrip("/") - path = urlutils.escape(posixpath.dirname(path)) - return path - - -class InventoryUI(TemplatedBranchView): - template_name = "inventory" - supports_json = True - - def get_filelist(self, tree, path, sort_type, revno_url): - """ - return the list of all files (and their attributes) within a given - path subtree. - - @param tree: The tree - @param path: The path of a directory within the tree. - @param sort_type: How to sort the results... XXX. - """ - file_list = [] - - if tree.kind(path) != "directory": - raise HTTPMovedPermanently( - self._branch.context_url(["/view", revno_url, path]) - ) - - revid_set = set() - - child_entries = [] - - for entry in tree.iter_child_entries(path): - child_path = osutils.pathjoin(path, entry.name) - child_revision = tree.get_file_revision(child_path) - revid_set.add(child_revision) - child_entries.append((child_path, entry, child_revision)) - - change_dict = {} - for change in self._history.get_changes(list(revid_set)): - change_dict[change.revid] = change - - for child_path, entry, child_revision in child_entries: - pathname = entry.name - contents_changed_rev = None - if entry.kind == "directory": - pathname += "/" - size = None - else: - size = entry.text_size - - change_dict[child_revision].timestamp - - # TODO: For the JSON rendering, this inlines the "change" aka - # revision information attached to each file. Consider either - # pulling this out as a separate changes dict, or possibly just - # including the revision id and having a separate request to get - # back the revision info. - file = util.Container( - filename=entry.name, - executable=entry.executable, - kind=entry.kind, - absolutepath=child_path, - size=size, - revid=child_revision, - change=change_dict[child_revision], - contents_changed_rev=contents_changed_rev, - ) - file_list.append(file) - - if sort_type == "filename": - file_list.sort(key=lambda x: x.filename.lower()) # case-insensitive - elif sort_type == "size": - - def size_key(x): - if x.size is None: - return -1 - return x.size - - file_list.sort(key=size_key) - elif sort_type == "date": - file_list.sort(key=lambda x: x.change.date, reverse=True) - - if sort_type != "date": - # Don't always sort directories first. - file_list.sort(key=lambda x: x.kind != "directory") - - return file_list - - def get_values(self, path, kwargs, headers): - history = self._history - branch = history._branch - try: - revid = self.get_revid() - rev_tree = branch.repository.revision_tree(revid) - except errors.NoSuchRevision: - raise HTTPNotFound() - - start_revid = kwargs.get("start_revid", None) - sort_type = kwargs.get("sort", "filename") - - if path is None: - path = "/" - - path = path.rstrip("/") - if not rev_tree.has_filename(path) and not is_null_rev(revid): - raise HTTPNotFound() - - # Are we at the top of the tree - if path in ["/", ""]: - updir = None - else: - updir = dirname(path) - - if not is_null_rev(revid): - change = history.get_changes([revid])[0] - # If we're looking at the tip, use head: in the URL instead - if revid == branch.last_revision(): - revno_url = "head:" - else: - revno_url = history.get_revno(revid) - history.add_branch_nicks(change) - filelist = self.get_filelist(rev_tree, path, sort_type, revno_url) - - else: - start_revid = None - change = None - path = "/" - updir = None - revno_url = "head:" - filelist = [] - - return { - "revid": revid, - "revno_url": revno_url, - "change": change, - "path": path, - "updir": updir, - "filelist": filelist, - "start_revid": start_revid, - } - - def add_template_values(self, values): - super(InventoryUI, self).add_template_values(values) - # Directory Breadcrumbs - directory_breadcrumbs = util.directory_breadcrumbs( - self._branch.friendly_name, self._branch.is_root, "files" - ) - - path = values["path"] - revid = values["revid"] - # no navbar for revisions - navigation = util.Container() - - if is_null_rev(revid): - branch_breadcrumbs = [] - else: - # Create breadcrumb trail for the path within the branch - branch = self._history._branch - rev_tree = branch.repository.revision_tree(revid) - branch_breadcrumbs = util.branch_breadcrumbs(path, rev_tree, "files") - values.update( - { - "fileview_active": True, - "directory_breadcrumbs": directory_breadcrumbs, - "branch_breadcrumbs": branch_breadcrumbs, - "navigation": navigation, - } - ) diff --git a/loggerhead/controllers/revision_ui.py b/loggerhead/controllers/revision_ui.py deleted file mode 100644 index c927e161..00000000 --- a/loggerhead/controllers/revision_ui.py +++ /dev/null @@ -1,174 +0,0 @@ -# -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import json - -from breezy import urlutils -from paste.httpexceptions import HTTPServerError - -from .. import util -from ..controllers import TemplatedBranchView -from ..controllers.filediff_ui import diff_chunks_for_file - -DEFAULT_LINE_COUNT_LIMIT = 3000 - - -def dq(p): - if not isinstance(p, bytes): - p = p.encode("UTF-8") - return urlutils.quote(urlutils.quote_from_bytes(p, safe="")) - - -class RevisionUI(TemplatedBranchView): - template_name = "revision" - supports_json = True - - def get_values(self, path, kwargs, headers): - h = self._history - revid = self.get_revid() - - filter_path = kwargs.get("filter_path", None) - start_revid = h.fix_revid(kwargs.get("start_revid", None)) - query = kwargs.get("q", None) - compare_revid = h.fix_revid(kwargs.get("compare_revid", None)) - - # TODO: This try/except looks to date before real exception handling - # and should be removed - try: - revid, start_revid, revid_list = h.get_view( - revid, start_revid, filter_path, query - ) - except BaseException: - self.log.exception("Exception fetching changes") - raise HTTPServerError("Could not fetch changes") - # XXX: Some concern about namespace collisions. These are only stored - # here so they can be expanded into the template later. Should probably - # be stored in a specific dict/etc. - self.revid_list = revid_list - self.compare_revid = compare_revid - self.path = path - kwargs["start_revid"] = start_revid - - change = h.get_changes([revid])[0] - - if compare_revid is None: - file_changes = h.get_file_changes(change) - else: - file_changes = h.file_changes_for_revision_ids(compare_revid, change.revid) - - h.add_branch_nicks(change) - - if "." in change.revno: - # Walk "up" though the merge-sorted graph until we find a - # revision with merge depth 0: this is the revision that merged - # this one to mainline. - ri = self._history._rev_info - i = self._history._rev_indices[change.revid] - while ri[i][0][2] > 0: - i -= 1 - merged_in = ri[i][0][3] - else: - merged_in = None - - return { - "revid": revid.decode("utf-8"), - "change": change, - "file_changes": file_changes, - "merged_in": merged_in, - } - - def add_template_values(self, values): - super(RevisionUI, self).add_template_values(values) - remember = self._history.fix_revid(self.kwargs.get("remember", None)) - query = self.kwargs.get("q", None) - filter_path = self.kwargs.get("filter_path", None) - start_revid = self.kwargs["start_revid"] - navigation = util.Container( - revid_list=self.revid_list, - revid=values["revid"], - start_revid=start_revid, - filter_path=filter_path, - pagesize=1, - scan_url="/revision", - branch=self._branch, - feed=True, - history=self._history, - ) - if query is not None: - navigation.query = query - util.fill_in_navigation(navigation) - path = self.path - if path in ("", "/"): - path = None - - file_changes = values["file_changes"] - link_data = {} - path_to_id = {} - if path: - items = [x for x in file_changes.text_changes if x.filename == path] - if len(items) > 0: - item = items[0] - try: - context_lines = int(self.kwargs["context"]) - except (KeyError, ValueError): - context_lines = None - diff_chunks = diff_chunks_for_file( - self._history._branch.repository, - path, - item.old_revision, - item.new_revision, - context_lines=context_lines, - ) - else: - diff_chunks = None - else: - diff_chunks = None - for i, item in enumerate(file_changes.text_changes): - item.index = i - link_data["diff-" + str(i)] = "%s/%s/%s" % ( - dq(item.new_revision), - dq(item.old_revision), - dq(item.filename), - ) - path_to_id[item.filename] = "diff-" + str(i) - - # Directory Breadcrumbs - directory_breadcrumbs = util.directory_breadcrumbs( - self._branch.friendly_name, self._branch.is_root, "changes" - ) - can_export = self._branch.export_tarballs - - values.update( - { - "history": self._history, - "link_data": json.dumps(link_data), - "json_specific_path": json.dumps(path), - "path_to_id": json.dumps(path_to_id), - "directory_breadcrumbs": directory_breadcrumbs, - "navigation": navigation, - "remember": remember, - "compare_revid": self.compare_revid, - "filter_path": filter_path, - "diff_chunks": diff_chunks, - "query": query, - "can_export": can_export, - "specific_path": path, - "start_revid": start_revid, - } - ) diff --git a/loggerhead/controllers/revlog_ui.py b/loggerhead/controllers/revlog_ui.py deleted file mode 100644 index 7f5f213f..00000000 --- a/loggerhead/controllers/revlog_ui.py +++ /dev/null @@ -1,23 +0,0 @@ -from breezy import osutils, urlutils - -from ..controllers import TemplatedBranchView - - -class RevLogUI(TemplatedBranchView): - template_name = "revlog" - supports_json = True - - def get_values(self, path, kwargs, headers): - history = self._history - - revid = urlutils.unquote_to_bytes(osutils.safe_utf8(self.args[0])) - - change = history.get_changes([revid])[0] - file_changes = history.get_file_changes(change) - history.add_branch_nicks(change) - - return { - "entry": change, - "file_changes": file_changes, - "revid": revid, - } diff --git a/loggerhead/controllers/search_ui.py b/loggerhead/controllers/search_ui.py deleted file mode 100644 index 0e315eec..00000000 --- a/loggerhead/controllers/search_ui.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (C) 2008 Canonical Ltd. -# (Authored by Martin Albisetti ) -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -from .. import search -from ..controllers import TemplatedBranchView - - -class SearchUI(TemplatedBranchView): - """ - - Class to output progressive search result terms. - """ - - template_name = "search" - - def get_values(self, path, kwargs, response): - """ - Default method called from the search box as /search URL - - Returns a list of suggested search terms parsed through the - templating engine. - """ - terms = [] - query = kwargs["query"] - if len(query) > 0: - terms = search.search_revisions(self._branch.branch, query, True) - if terms is not None: - terms = [term[0] for term in terms] - else: - # Should show a 'search is not available' etc box. - terms = [] - - return {"terms": terms} diff --git a/loggerhead/controllers/view_ui.py b/loggerhead/controllers/view_ui.py deleted file mode 100644 index d7e6b8dd..00000000 --- a/loggerhead/controllers/view_ui.py +++ /dev/null @@ -1,145 +0,0 @@ -# -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import os - -from breezy.errors import BinaryFile - -try: - from breezy.transport import NoSuchFile -except ImportError: - from breezy.errors import NoSuchFile - -import breezy.textfile -from breezy import osutils -from paste.httpexceptions import HTTPBadRequest, HTTPMovedPermanently, HTTPNotFound - -from ..controllers import TemplatedBranchView - -try: - from ..highlight import highlight -except ImportError: - highlight = None -from .. import util - - -class ViewUI(TemplatedBranchView): - template_name = "view" - - def tree_for(self, path, revid): - if not isinstance(path, str): - raise TypeError(path) - if not isinstance(revid, bytes): - raise TypeError(revid) - return self._history._branch.repository.revision_tree(revid) - - def text_lines(self, path, revid): - file_name = os.path.basename(path) - - tree = self.tree_for(path, revid) - file_text = tree.get_file_text(path) - - encoding = "utf-8" - try: - file_text.decode(encoding) - except UnicodeDecodeError: - encoding = "iso-8859-15" - file_text.decode(encoding) - - file_lines = osutils.split_lines(file_text) - # This can throw breezy.errors.BinaryFile (which our caller catches). - breezy.textfile.check_text_lines(file_lines) - - file_text = file_text.decode(encoding) - file_lines = osutils.split_lines(file_text) - - if highlight is not None: - hl_lines = highlight(file_name, file_text, encoding) - # highlight strips off extra newlines at the end of the file. - extra_lines = len(file_lines) - len(hl_lines) - hl_lines.extend([""] * extra_lines) - else: - hl_lines = [util.html_escape(line) for line in file_lines] - - return hl_lines - - def file_contents(self, path, revid): - try: - file_lines = self.text_lines(path, revid) - except BinaryFile: - # bail out; this isn't displayable text - return ["(This is a binary file.)"] - - return file_lines - - def get_values(self, path, kwargs, headers): - history = self._history - branch = history._branch - revid = self.get_revid() - if path is None: - raise HTTPBadRequest("No filename provided to view") - - if not history.file_exists(revid, path): - raise HTTPNotFound() - - filename = os.path.basename(path) - - change = history.get_changes([revid])[0] - # If we're looking at the tip, use head: in the URL instead - if revid == branch.last_revision(): - revno_url = "head:" - else: - revno_url = history.get_revno(revid) - - # Directory Breadcrumbs - directory_breadcrumbs = util.directory_breadcrumbs( - self._branch.friendly_name, self._branch.is_root, "files" - ) - - tree = history.revision_tree(revid) - - # Create breadcrumb trail for the path within the branch - branch_breadcrumbs = util.branch_breadcrumbs(path, tree, "files") - - try: - if tree.kind(path) == "directory": - raise HTTPMovedPermanently( - self._branch.context_url(["/files", revno_url, path]) - ) - except NoSuchFile: - raise HTTPNotFound() - - # no navbar for revisions - navigation = util.Container() - - return { - # In AnnotateUI, "annotated" is a dictionary mapping lines to - # changes. We exploit the fact that bool({}) is False when - # checking whether we're in "annotated" mode. - "annotated": {}, - "revno_url": revno_url, - "file_path": path, - "filename": filename, - "navigation": navigation, - "change": change, - "contents": self.file_contents(path, revid), - "fileview_active": True, - "directory_breadcrumbs": directory_breadcrumbs, - "branch_breadcrumbs": branch_breadcrumbs, - } diff --git a/loggerhead/daemon.py b/loggerhead/daemon.py deleted file mode 100644 index aeccbbc4..00000000 --- a/loggerhead/daemon.py +++ /dev/null @@ -1,73 +0,0 @@ -# daemon code from ASPN -# - -import os - - -def daemonize(pidfile, home): - """ - Detach this process from the controlling terminal and run it in the - background as a daemon. - """ - - WORKDIR = "/" - REDIRECT_TO = getattr(os, "devnull", "/dev/null") - MAXFD = 1024 - - try: - pid = os.fork() - except OSError as e: - raise Exception("%s [%d]" % (e.strerror, e.errno)) - - if pid == 0: # The first child. - os.setsid() - - try: - pid = os.fork() # Fork a second child. - except OSError as e: - raise Exception("%s [%d]" % (e.strerror, e.errno)) - - if pid == 0: # The second child. - os.chdir(WORKDIR) - else: - os._exit(0) # Exit parent (the first child) of the second child. - else: - os._exit(0) # Exit parent of the first child. - - import resource # Resource usage information. - - maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1] - if maxfd == resource.RLIM_INFINITY: - maxfd = MAXFD - - fd = os.open(REDIRECT_TO, os.O_RDONLY) - os.dup2(fd, 0) - fd = os.open(REDIRECT_TO, os.O_WRONLY) - os.dup2(fd, 1) - os.dup2(fd, 2) - - # Iterate through and close all other file descriptors. - for fd in range(3, maxfd): - try: - os.close(fd) - except OSError: # ERROR, fd wasn't open to begin with (ignored) - pass - - f = open(pidfile, "w") - f.write("%d\n" % (os.getpid(),)) - f.close() - - -def is_running(pidfile): - try: - f = open(pidfile, "r") - except IOError: - return False - pid = int(f.readline()) - f.close() - try: - os.kill(pid, 0) - except OSError: - # no such process - return False - return True diff --git a/loggerhead/highlight.py b/loggerhead/highlight.py deleted file mode 100644 index 4c8f231d..00000000 --- a/loggerhead/highlight.py +++ /dev/null @@ -1,56 +0,0 @@ -# -# Copyright (C) 2009 Peter Bui -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -from html import escape - -import breezy.osutils -from pygments import highlight as _highlight_func -from pygments.formatters import HtmlFormatter -from pygments.lexers import TextLexer, guess_lexer, guess_lexer_for_filename -from pygments.util import ClassNotFound - -DEFAULT_PYGMENT_STYLE = "colorful" - -# Trying to highlight very large files using pygments was killing -# loggerhead on launchpad.net, because pygments isn't very fast. -# So we only highlight files if they're 512K or smaller. -MAX_HIGHLIGHT_SIZE = 512000 - - -def highlight(path, text, encoding, style=DEFAULT_PYGMENT_STYLE): - """ - Returns a list of highlighted (i.e. HTML formatted) strings. - """ - - if len(text) > MAX_HIGHLIGHT_SIZE: - return list(map(escape, breezy.osutils.split_lines(text))) - - formatter = HtmlFormatter(style=style, nowrap=True, classprefix="pyg-") - - try: - lexer = guess_lexer_for_filename(path, text[:1024], encoding=encoding) - except (ClassNotFound, ValueError): - try: - lexer = guess_lexer(text[:1024], encoding=encoding) - except (ClassNotFound, ValueError): - lexer = TextLexer(encoding=encoding) - - hl_lines = _highlight_func(text, lexer, formatter) - hl_lines = breezy.osutils.split_lines(hl_lines) - - return hl_lines diff --git a/loggerhead/history.py b/loggerhead/history.py deleted file mode 100644 index ff4f5b30..00000000 --- a/loggerhead/history.py +++ /dev/null @@ -1,841 +0,0 @@ -# Copyright (C) 2006-2011 Canonical Ltd. -# (Authored by Martin Albisetti ) -# Copyright (C) 2006 Robey Pointer -# Copyright (C) 2006 Goffredo Baroncelli -# Copyright (C) 2005 Jake Edge -# Copyright (C) 2005 Matt Mackall -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -# -# This file (and many of the web templates) contains work based on the -# "bazaar-webserve" project by Goffredo Baroncelli, which is in turn based -# on "hgweb" by Jake Edge and Matt Mackall. -# - - -import bisect -import datetime -import logging -import re -import textwrap -import threading - -import breezy.branch -import breezy.delta -import breezy.errors -import breezy.foreign -import breezy.osutils -import breezy.revision -from breezy import tag - -try: - from breezy.transport import NoSuchFile -except ImportError: - from breezy.errors import NoSuchFile - -from . import search, util -from .wholehistory import compute_whole_history_data - - -def is_branch(folder): - try: - breezy.branch.Branch.open(folder) - return True - except breezy.errors.NotBranchError: - return False - - -def clean_message(message): - """Clean up a commit message and return it and a short (1-line) version. - - Commit messages that are long single lines are reflowed using the textwrap - module (Robey, the original author of this code, apparently favored this - style of message). - """ - message = message.lstrip().splitlines() - - if len(message) == 1: - message = textwrap.wrap(message[0]) - - if len(message) == 0: - # We can end up where when (a) the commit message was empty or (b) - # when the message consisted entirely of whitespace, in which case - # textwrap.wrap() returns an empty list. - return [""], "" - - # Make short form of commit message. - short_message = message[0] - if len(short_message) > 60: - short_message = short_message[:60] + "..." - - return message, short_message - - -def rich_filename(path, kind): - if kind == "directory": - path += "/" - if kind == "symlink": - path += "@" - return path - - -class _RevListToTimestamps(object): - """This takes a list of revisions, and allows you to bisect by date""" - - __slots__ = ["revid_list", "repository"] - - def __init__(self, revid_list, repository): - self.revid_list = revid_list - self.repository = repository - - def __getitem__(self, index): - """Get the date of the index'd item""" - return datetime.datetime.fromtimestamp( - self.repository.get_revision(self.revid_list[index]).timestamp - ) - - def __len__(self): - return len(self.revid_list) - - -class FileChangeReporter(object): - def __init__(self, old_tree, new_tree): - self.added = [] - self.modified = [] - self.renamed = [] - self.removed = [] - self.text_changes = [] - self.old_tree = old_tree - self.new_tree = new_tree - - def revid(self, tree, path): - if path is None: - return breezy.revision.NULL_REVISION - try: - return tree.get_file_revision(path) - except NoSuchFile: - return breezy.revision.NULL_REVISION - - def report(self, paths, versioned, renamed, copied, modified, exe_change, kind): - return self._report( - paths, versioned, renamed, copied, modified, exe_change, kind - ) - - def _report(self, paths, versioned, renamed, copied, modified, exe_change, kind): - if modified not in ("unchanged", "kind changed"): - if versioned == "removed": - filename = rich_filename(paths[0], kind[0]) - else: - filename = rich_filename(paths[1], kind[1]) - self.text_changes.append( - util.Container( - filename=filename, - old_revision=self.revid(self.old_tree, paths[0]), - new_revision=self.revid(self.new_tree, paths[1]), - ) - ) - if versioned == "added": - self.added.append( - util.Container(filename=rich_filename(paths[1], kind), kind=kind[1]) - ) - elif versioned == "removed": - self.removed.append( - util.Container(filename=rich_filename(paths[0], kind), kind=kind[0]) - ) - elif renamed: - self.renamed.append( - util.Container( - old_filename=rich_filename(paths[0], kind[0]), - new_filename=rich_filename(paths[1], kind[1]), - text_modified=modified == "modified", - exe_change=exe_change, - ) - ) - else: - self.modified.append( - util.Container( - filename=rich_filename(paths[1], kind), - text_modified=modified == "modified", - exe_change=exe_change, - ) - ) - - -# The lru_cache is not thread-safe, so we need a lock around it for -# all threads. -rev_info_memory_cache_lock = threading.RLock() - - -class RevInfoMemoryCache(object): - """A store that validates values against the revids they were stored with. - - We use a unique key for each branch. - - The reason for not just using the revid as the key is so that when a new - value is provided for a branch, we replace the old value used for the - branch. - - There is another implementation of the same interface in - loggerhead.changecache.RevInfoDiskCache. - """ - - def __init__(self, cache): - self._cache = cache - - def get(self, key, revid): - """Return the data associated with `key`, subject to a revid check. - - If a value was stored under `key`, with the same revid, return it. - Otherwise return None. - """ - rev_info_memory_cache_lock.acquire() - try: - cached = self._cache.get(key) - finally: - rev_info_memory_cache_lock.release() - if cached is None: - return None - stored_revid, data = cached - if revid == stored_revid: - return data - else: - return None - - def set(self, key, revid, data): - """Store `data` under `key`, to be checked against `revid` on get().""" - rev_info_memory_cache_lock.acquire() - try: - self._cache[key] = (revid, data) - finally: - rev_info_memory_cache_lock.release() - - -# Used to store locks that prevent multiple threads from building a -# revision graph for the same branch at the same time, because that can -# cause severe performance issues that are so bad that the system seems -# to hang. -revision_graph_locks = {} -revision_graph_check_lock = threading.Lock() - - -class History(object): - """Decorate a branch to provide information for rendering. - - History objects are expected to be short lived -- when serving a request - for a particular branch, open it, read-lock it, wrap a History object - around it, serve the request, throw the History object away, unlock the - branch and throw it away. - - :ivar _rev_info: A list of information about revisions. This is by far - the most cryptic data structure in loggerhead. At the top level, it - is a list of 3-tuples [(merge-info, where-merged, parents)]. - `merge-info` is (seq, revid, merge_depth, revno_str, end_of_merge) -- - like a merged sorted list, but the revno is stringified. - `where-merged` is a tuple of revisions that have this revision as a - non-lefthand parent. Finally, `parents` is just the usual list of - parents of this revision. - :ivar _rev_indices: A dictionary mapping each revision id to the index of - the information about it in _rev_info. - :ivar _revno_revid: A dictionary mapping stringified revnos to revision - ids. - """ - - def _load_whole_history_data(self, caches, cache_key): - """Set the attributes relating to the whole history of the branch. - - :param caches: a list of caches with interfaces like - `RevInfoMemoryCache` and be ordered from fastest to slowest. - :param cache_key: the key to use with the caches. - """ - self._rev_indices = None - self._rev_info = None - - missed_caches = [] - - def update_missed_caches(): - for cache in missed_caches: - cache.set(cache_key, self.last_revid, self._rev_info) - - # Theoretically, it's possible for two threads to race in creating - # the Lock() object for their branch, so we put a lock around - # creating the per-branch Lock(). - revision_graph_check_lock.acquire() - try: - if cache_key not in revision_graph_locks: - revision_graph_locks[cache_key] = threading.Lock() - finally: - revision_graph_check_lock.release() - - revision_graph_locks[cache_key].acquire() - try: - for cache in caches: - data = cache.get(cache_key, self.last_revid) - if data is not None: - self._rev_info = data - update_missed_caches() - break - else: - missed_caches.append(cache) - else: - whole_history_data = compute_whole_history_data(self._branch) - self._rev_info, self._rev_indices = whole_history_data - update_missed_caches() - finally: - revision_graph_locks[cache_key].release() - - if self._rev_indices is not None: - self._revno_revid = {} - for (_, revid, _, revno_str, _), _, _ in self._rev_info: - self._revno_revid[revno_str] = revid - else: - self._revno_revid = {} - self._rev_indices = {} - for (seq, revid, _, revno_str, _), _, _ in self._rev_info: - self._rev_indices[revid] = seq - self._revno_revid[revno_str] = revid - - def __init__( - self, branch, whole_history_data_cache, revinfo_disk_cache=None, cache_key=None - ): - assert branch.is_locked(), ( - "Can only construct a History object with a read-locked branch." - ) - self._branch = branch - self._branch_tags = None - self._inventory_cache = {} - self._branch_nick = self._branch.get_config().get_nickname() - self.log = logging.getLogger("loggerhead.%s" % (self._branch_nick,)) - - self.last_revid = branch.last_revision() - - caches = [RevInfoMemoryCache(whole_history_data_cache)] - if revinfo_disk_cache: - caches.append(revinfo_disk_cache) - self._load_whole_history_data(caches, cache_key) - - @property - def has_revisions(self): - return not breezy.revision.is_null(self.last_revid) - - def get_config(self): - return self._branch.get_config() - - def get_revno(self, revid): - if revid not in self._rev_indices: - # ghost parent? - return "unknown" - seq = self._rev_indices[revid] - revno = self._rev_info[seq][0][3] - return revno - - def get_revids_from(self, revid_list, start_revid): - """ - Yield the mainline (wrt start_revid) revisions that merged each - revid in revid_list. - """ - if revid_list is None: - # Just yield the mainline, starting at start_revid - revid = start_revid - is_null = breezy.revision.is_null - while not is_null(revid): - yield revid - parents = self._rev_info[self._rev_indices[revid]][2] - if not parents: - return - revid = parents[0] - return - revid_set = set(revid_list) - revid = start_revid - - def introduced_revisions(revid): - r = set([revid]) - seq = self._rev_indices[revid] - md = self._rev_info[seq][0][2] - i = seq + 1 - while i < len(self._rev_info) and self._rev_info[i][0][2] > md: - r.add(self._rev_info[i][0][1]) - i += 1 - return r - - while revid_set: - if breezy.revision.is_null(revid): - return - rev_introduced = introduced_revisions(revid) - matching = rev_introduced.intersection(revid_set) - if matching: - # We don't need to look for these anymore. - revid_set.difference_update(matching) - yield revid - parents = self._rev_info[self._rev_indices[revid]][2] - if len(parents) == 0: - return - revid = parents[0] - - def get_short_revision_history_by_fileid(self, file_id): - # FIXME: would be awesome if we could get, for a folder, the list of - # revisions where items within that folder changed.i - # TODO(jelmer): Avoid versionedfile-specific texts - possible_keys = [(file_id, revid) for revid in self._rev_indices] - get_parent_map = self._branch.repository.texts.get_parent_map - # We chunk the requests as this works better with GraphIndex. - # See _filter_revisions_touching_file_id in breezy/log.py - # for more information. - revids = [] - chunk_size = 1000 - for start in range(0, len(possible_keys), chunk_size): - next_keys = possible_keys[start : start + chunk_size] - revids += [k[1] for k in get_parent_map(next_keys)] - del possible_keys, next_keys - return revids - - def get_revision_history_since(self, revid_list, date): - # if a user asks for revisions starting at 01-sep, they mean inclusive, - # so start at midnight on 02-sep. - date = date + datetime.timedelta(days=1) - # our revid list is sorted in REVERSE date order, - # so go thru some hoops here... - revid_list.reverse() - index = bisect.bisect( - _RevListToTimestamps(revid_list, self._branch.repository), date - ) - if index == 0: - return [] - revid_list.reverse() - index = -index - return revid_list[index:] - - def get_search_revid_list(self, query, revid_list): - """ - given a "quick-search" query, try a few obvious possible meanings: - - - revision id or # ("128.1.3") - - date (US style "mm/dd/yy", earth style "dd-mm-yy", or \ -iso style "yyyy-mm-dd") - - comment text as a fallback - - and return a revid list that matches. - """ - # FIXME: there is some silliness in this action. we have to look up - # all the relevant changes (time-consuming) only to return a list of - # revids which will be used to fetch a set of changes again. - - # if they entered a revid, just jump straight there; - # ignore the passed-in revid_list - revid = self.fix_revid(query) - if revid is not None: - changes = self.get_changes([revid]) - if (changes is not None) and (len(changes) > 0): - return [revid] - - date = None - m = self.us_date_re.match(query) - if m is not None: - date = datetime.datetime( - util.fix_year(int(m.group(3))), int(m.group(1)), int(m.group(2)) - ) - else: - m = self.earth_date_re.match(query) - if m is not None: - date = datetime.datetime( - util.fix_year(int(m.group(3))), int(m.group(2)), int(m.group(1)) - ) - else: - m = self.iso_date_re.match(query) - if m is not None: - date = datetime.datetime( - util.fix_year(int(m.group(1))), int(m.group(2)), int(m.group(3)) - ) - if date is not None: - if revid_list is None: - # if no limit to the query was given, - # search only the direct-parent path. - revid_list = list(self.get_revids_from(None, self.last_revid)) - return self.get_revision_history_since(revid_list, date) - - revno_re = re.compile(r"^[\d\.]+$") - # the date regex are without a final '$' so that queries like - # "2006-11-30 12:15" still mostly work. (i think it's better to give - # them 90% of what they want instead of nothing at all.) - us_date_re = re.compile(r"^(\d{1,2})/(\d{1,2})/(\d\d(\d\d?))") - earth_date_re = re.compile(r"^(\d{1,2})-(\d{1,2})-(\d\d(\d\d?))") - iso_date_re = re.compile(r"^(\d\d\d\d)-(\d\d)-(\d\d)") - - def fix_revid(self, revid): - # if a "revid" is actually a dotted revno, convert it to a revid - if revid is None: - return revid - if not isinstance(revid, str): - raise TypeError(revid) - if revid == "head:": - return self.last_revid - try: - if self.revno_re.match(revid): - revid = self._revno_revid[revid] - except KeyError: - raise breezy.errors.NoSuchRevision(self._branch_nick, revid) - if not isinstance(revid, bytes): - revid = revid.encode("utf-8") - return revid - - @staticmethod - def _iterate_sufficiently(iterable, stop_at, extra_rev_count): - """Return a list of iterable. - - If extra_rev_count is None, fully consume iterable. - Otherwise, stop at 'stop_at' + extra_rev_count. - - Example: - iterate until you find stop_at, then iterate 10 more times. - """ - if extra_rev_count is None: - return list(iterable) - result = [] - found = False - for n in iterable: - result.append(n) - if n == stop_at: - found = True - break - if found: - for count, n in enumerate(iterable): - if count >= extra_rev_count: - break - result.append(n) - return result - - def _get_file_view(self, revid, file_id): - """ - Given a revid and optional path, return a (revlist, revid) for - navigation through the current scope: from the revid (or the latest - revision) back to the original revision. - - If file_id is None, the entire revision history is the list scope. - """ - if revid is None: - revid = self.last_revid - if file_id is not None: - revlist = list(self.get_short_revision_history_by_fileid(file_id)) - revlist = self.get_revids_from(revlist, revid) - else: - revlist = self.get_revids_from(None, revid) - return revlist - - def get_view(self, revid, start_revid, path, query=None, extra_rev_count=None): - """ - use the URL parameters (revid, start_revid, path, and query) to - determine the revision list we're viewing (start_revid, path, query) - and where we are in it (revid). - - - if a query is given, we're viewing query results. - - if a path is given, we're viewing revisions for a specific - file. - - if a start_revid is given, we're viewing the branch from a - specific revision up the tree. - - if extra_rev_count is given, find the view from start_revid => - revid, and continue an additional 'extra_rev_count'. If not - given, then revid_list will contain the full history of - start_revid - - these may be combined to view revisions for a specific file, from - a specific revision, with a specific search query. - - returns a new (revid, start_revid, revid_list) where: - - - revid: current position within the view - - start_revid: starting revision of this view - - revid_list: list of revision ids for this view - - path and query are never changed so aren't returned, but they may - contain vital context for future url navigation. - """ - if start_revid is None: - start_revid = self.last_revid - - if query is None: - repo = self._branch.repository - if path is not None: - file_id = repo.revision_tree(start_revid).path2id(path) - else: - file_id = None - revid_list = self._get_file_view(start_revid, file_id) - revid_list = self._iterate_sufficiently(revid_list, revid, extra_rev_count) - if revid is None: - revid = start_revid - if revid not in revid_list: - # if the given revid is not in the revlist, use a revlist that - # starts at the given revid. - revid_list = self._get_file_view(revid, file_id) - revid_list = self._iterate_sufficiently( - revid_list, revid, extra_rev_count - ) - start_revid = revid - return revid, start_revid, revid_list - else: - file_id = None - - # potentially limit the search - if file_id is not None: - revid_list = self._get_file_view(start_revid, file_id) - else: - revid_list = None - revid_list = search.search_revisions(self._branch, query) - if revid_list and len(revid_list) > 0: - if revid not in revid_list: - revid = revid_list[0] - return revid, start_revid, revid_list - else: - # XXX: This should return a message saying that the search could - # not be completed due to either missing the plugin or missing a - # search index. - return None, None, [] - - def revision_tree(self, revid): - return self._branch.repository.revision_tree(revid) - - def file_exists(self, revid, path): - if (len(path) > 0) and not path.startswith("/"): - path = "/" + path - try: - return self.revision_tree(revid).has_filename(path) - except breezy.errors.NoSuchRevision: - return False - - def get_merge_point_list(self, revid): - """ - Return the list of revids that have merged this node. - """ - if "." not in self.get_revno(revid): - return [] - - merge_point = [] - nexts = [revid] - while nexts: - revid = nexts.pop() - children = self._rev_info[self._rev_indices[revid]][1] - for child in children: - child_parents = self._rev_info[self._rev_indices[child]][2] - if child_parents[0] == revid: - nexts.append(child) - else: - merge_point.append(child) - return merge_point - - def simplify_merge_point_list(self, revids): - """if a revision is already merged, don't show further merge points""" - d = {} - for revid in revids: - revno = self.get_revno(revid) - revnol = revno.split(".") - revnos = ".".join(revnol[:-2]) - revnolast = int(revnol[-1]) - if revnos in d: - m = d[revnos][0] - if revnolast < m: - d[revnos] = (revnolast, revid) - else: - d[revnos] = (revnolast, revid) - - return [revid for (_, revid) in d.values()] - - def add_branch_nicks(self, change): - """ - given a 'change', fill in the branch nicks on all parents and merge - points. - """ - fetch_set = set() - for p in change.parents: - fetch_set.add(p.revid) - for p in change.merge_points: - fetch_set.add(p.revid) - p_changes = self.get_changes(list(fetch_set)) - p_change_dict = {c.revid: c for c in p_changes} - for p in change.parents: - if p.revid in p_change_dict: - p.branch_nick = p_change_dict[p.revid].branch_nick - else: - p.branch_nick = "(missing)" - for p in change.merge_points: - if p.revid in p_change_dict: - p.branch_nick = p_change_dict[p.revid].branch_nick - else: - p.branch_nick = "(missing)" - - def get_changes(self, revid_list): - """Return a list of changes objects for the given revids. - - Revisions not present and NULL_REVISION will be ignored. - """ - for revid in revid_list: - if not isinstance(revid, bytes): - raise TypeError(revid_list) - changes = self.get_changes_uncached(revid_list) - if len(changes) == 0: - return changes - - # some data needs to be recalculated each time, because it may - # change as new revisions are added. - for change in changes: - merge_revids = self.simplify_merge_point_list( - self.get_merge_point_list(change.revid) - ) - change.merge_points = [ - util.Container(revid=r, revno=self.get_revno(r)) for r in merge_revids - ] - if len(change.parents) > 0: - change.parents = [ - util.Container(revid=r, revno=self.get_revno(r)) - for r in change.parents - ] - change.revno = self.get_revno(change.revid) - - parity = 0 - for change in changes: - change.parity = parity - parity ^= 1 - - return changes - - def get_changes_uncached(self, revid_list): - # FIXME: deprecated method in getting a null revision - revid_list = list( - filter(lambda revid: not breezy.revision.is_null(revid), revid_list) - ) - parent_map = self._branch.repository.get_graph().get_parent_map(revid_list) - # We need to return the answer in the same order as the input, - # less any ghosts. - present_revids = [revid for revid in revid_list if revid in parent_map] - rev_list = self._branch.repository.get_revisions(present_revids) - - return [self._change_from_revision(rev) for rev in rev_list] - - def _change_from_revision(self, revision): - """ - Given a breezy Revision, return a processed "change" for use in - templates. - """ - message, short_message = clean_message(revision.message) - - if self._branch_tags is None: - self._branch_tags = self._branch.tags.get_reverse_tag_dict() - - revtags = None - if revision.revision_id in self._branch_tags: - # tag.sort_* functions expect (tag, data) pairs, so we generate them, - # and then strip them - tags = [(t, None) for t in self._branch_tags[revision.revision_id]] - sort_func = getattr(tag, "sort_natural", None) - if sort_func is None: - tags.sort() - else: - sort_func(self._branch, tags) - revtags = ", ".join([t[0] for t in tags]) - - entry = { - "revid": revision.revision_id, - "date": datetime.datetime.fromtimestamp(revision.timestamp), - "utc_date": datetime.datetime.utcfromtimestamp(revision.timestamp), - "timestamp": revision.timestamp, - "committer": revision.committer, - "authors": revision.get_apparent_authors(), - "branch_nick": revision.properties.get("branch-nick", None), - "short_comment": short_message, - "comment": revision.message, - "comment_clean": [util.html_clean(s) for s in message], - "parents": revision.parent_ids, - "bugs": [ - bug.split()[0] - for bug in revision.properties.get("bugs", "").splitlines() - ], - "tags": revtags, - } - if isinstance(revision, breezy.foreign.ForeignRevision): - foreign_revid, mapping = (revision.foreign_revid, revision.mapping) - elif b":" in revision.revision_id: - try: - foreign_revid, mapping = ( - breezy.foreign.foreign_vcs_registry.parse_revision_id( - revision.revision_id - ) - ) - except breezy.errors.InvalidRevisionId: - foreign_revid = None - mapping = None - else: - foreign_revid = None - if foreign_revid is not None: - entry["foreign_vcs"] = mapping.vcs.abbreviation - entry["foreign_revid"] = mapping.vcs.show_foreign_revid(foreign_revid) - return util.Container(entry) - - def get_file_changes(self, entry): - if entry.parents: - old_revid = entry.parents[0].revid - else: - old_revid = breezy.revision.NULL_REVISION - return self.file_changes_for_revision_ids(old_revid, entry.revid) - - def add_changes(self, entry): - changes = self.get_file_changes(entry) - entry.changes = changes - - def get_file(self, path, revid): - """Returns (path, filename, file contents)""" - if not isinstance(path, str): - raise TypeError(path) - if not isinstance(revid, bytes): - raise TypeError(revid) - rev_tree = self._branch.repository.revision_tree(revid) - display_path = path - if not display_path.startswith("/"): - path = "/" + path - return ( - display_path, - breezy.osutils.basename(path), - rev_tree.get_file_text(path), - ) - - def file_changes_for_revision_ids(self, old_revid, new_revid): - """ - Return a nested data structure containing the changes in a delta:: - - added: list((filename)), - renamed: list((old_filename, new_filename)), - deleted: list((filename)), - modified: list((filename)), - text_changes: list((filename)), - """ - repo = self._branch.repository - if ( - breezy.revision.is_null(old_revid) - or breezy.revision.is_null(new_revid) - or old_revid == new_revid - ): - old_tree, new_tree = map(repo.revision_tree, [old_revid, new_revid]) - else: - old_tree, new_tree = repo.revision_trees([old_revid, new_revid]) - - reporter = FileChangeReporter(old_tree, new_tree) - - breezy.delta.report_changes(new_tree.iter_changes(old_tree), reporter) - - return util.Container( - added=sorted(reporter.added, key=lambda x: x.filename), - renamed=sorted(reporter.renamed, key=lambda x: x.new_filename), - removed=sorted(reporter.removed, key=lambda x: x.filename), - modified=sorted(reporter.modified, key=lambda x: x.filename), - text_changes=sorted(reporter.text_changes, key=lambda x: x.filename), - ) diff --git a/loggerhead/load_test.py b/loggerhead/load_test.py deleted file mode 100644 index 7cbcc3af..00000000 --- a/loggerhead/load_test.py +++ /dev/null @@ -1,234 +0,0 @@ -# Copyright 2011 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA - -"""Code to do some load testing against a loggerhead instance. - -This is basically meant to take a list of actions to take, and run it against a -real host, and see how the results respond.:: - - {"parameters": { - "base_url": "http://localhost:8080", - }, - "requests": [ - {"thread": "1", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"} - ], - } - -All threads have a Queue length of 1. When a third request for a given thread -is seen, no more requests are queued until that thread finishes its current -job. So this results in all requests being issued sequentially:: - - {"thread": "1", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"} - -While this would cause all requests to be sent in parallel: - - {"thread": "1", "relpath": "/changes"}, - {"thread": "2", "relpath": "/changes"}, - {"thread": "3", "relpath": "/changes"}, - {"thread": "4", "relpath": "/changes"} - -This should keep 2 threads pipelined with activity, as long as they finish in -approximately the same speed. We'll start the first thread running, and the -second thread, and queue up both with a second request once the first finishes. -When we get to the third request for thread "1", we block on queuing up more -work until the first thread 1 request has finished. - {"thread": "1", "relpath": "/changes"}, - {"thread": "2", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"}, - {"thread": "2", "relpath": "/changes"}, - {"thread": "1", "relpath": "/changes"}, - {"thread": "2", "relpath": "/changes"} - -There is not currently a way to say "run all these requests keeping exactly 2 -threads active". Though if you know the load pattern, you could approximate -this. -""" - -import json -import threading -import time -from queue import Empty, Queue - -from breezy import errors, transport, urlutils - -try: - from breezy.transport import NoSuchFile -except ImportError: - from breezy.errors import NoSuchFile - -# This code will be doing multi-threaded requests against breezy.transport -# code. We want to make sure to load everything ahead of time, so we don't get -# lazy-import failures -_ = transport.get_transport("http://example.com") - - -class RequestDescription(object): - """Describes info about a request.""" - - def __init__(self, descrip_dict): - self.thread = descrip_dict.get("thread", "1") - self.relpath = descrip_dict["relpath"] - - -class RequestWorker(object): - """Process requests in a worker thread.""" - - _timer = time.time - - def __init__(self, identifier, blocking_time=1.0, _queue_size=1): - self.identifier = identifier - self.queue = Queue(_queue_size) - self.start_time = self.end_time = None - self.stats = [] - self.blocking_time = blocking_time - - def step_next(self): - url = self.queue.get(True, self.blocking_time) - if url == "": - # This is usually an indicator that we want to stop, so just skip - # this one. - self.queue.task_done() - return - self.start_time = self._timer() - success = self.process(url) - self.end_time = self._timer() - self.update_stats(url, success) - self.queue.task_done() - - def run(self, stop_event): - while not stop_event.is_set(): - try: - self.step_next() - except Empty: - pass - - def process(self, url): - base, path = urlutils.split(url) - t = transport.get_transport(base) - try: - # TODO: We should probably look into using some part of - # blocking_timeout to decide when to stop trying to read - # content - t.get_bytes(path) - except (errors.TransportError, NoSuchFile): - return False - return True - - def update_stats(self, url, success): - self.stats.append((url, success, self.end_time - self.start_time)) - - -class ActionScript(object): - """This tracks the actions that we want to perform.""" - - _worker_class = RequestWorker - _default_base_url = "http://localhost:8080" - _default_blocking_timeout = 60.0 - - def __init__(self): - self.base_url = self._default_base_url - self.blocking_timeout = self._default_blocking_timeout - self._requests = [] - self._threads = {} - self.stop_event = threading.Event() - - @classmethod - def parse(cls, content): - script = cls() - if isinstance(content, bytes): - content = content.decode("UTF-8") - json_dict = json.loads(content) - if "parameters" not in json_dict: - raise ValueError('Missing "parameters" section') - if "requests" not in json_dict: - raise ValueError('Missing "requests" section') - param_dict = json_dict["parameters"] - request_list = json_dict["requests"] - base_url = param_dict.get("base_url", None) - if base_url is not None: - script.base_url = base_url - blocking_timeout = param_dict.get("blocking_timeout", None) - if blocking_timeout is not None: - script.blocking_timeout = blocking_timeout - for request_dict in request_list: - script.add_request(request_dict) - return script - - def add_request(self, request_dict): - request = RequestDescription(request_dict) - self._requests.append(request) - - def _get_worker(self, thread_id): - if thread_id in self._threads: - return self._threads[thread_id][0] - handler = self._worker_class( - thread_id, blocking_time=self.blocking_timeout / 10.0 - ) - - t = threading.Thread( - target=handler.run, args=(self.stop_event,), name="Thread-%s" % (thread_id,) - ) - self._threads[thread_id] = (handler, t) - t.start() - return handler - - def finish_queues(self): - """Wait for all queues of all children to finish.""" - for h, t in self._threads.values(): - h.queue.join() - - def stop_and_join(self): - """Stop all running workers, and return. - - This will stop even if workers still have work items. - """ - self.stop_event.set() - for h, t in self._threads.values(): - # Signal the queue that it should stop blocking, we don't have to - # wait for the queue to empty, because we may see stop_event before - # we see the - h.queue.put("") - # And join the controlling thread - for i in range(10): - t.join(self.blocking_timeout / 10.0) - if not t.is_alive(): - break - - def _full_url(self, relpath): - return self.base_url + relpath - - def run(self): - self.stop_event.clear() - for request in self._requests: - full_url = self._full_url(request.relpath) - worker = self._get_worker(request.thread) - worker.queue.put(full_url, True, self.blocking_timeout) - self.finish_queues() - self.stop_and_join() - - -def run_script(filename): - with open(filename, "rb") as f: - content = f.read() - script = ActionScript.parse(content) - script.run() - return script diff --git a/loggerhead/middleware/__init__.py b/loggerhead/middleware/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/loggerhead/middleware/profile.py b/loggerhead/middleware/profile.py deleted file mode 100644 index f097b74a..00000000 --- a/loggerhead/middleware/profile.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Profiling middleware for Paste.""" - -import threading - -from breezy.lsprof import profile - - -class LSProfMiddleware(object): - """Paste middleware for profiling with lsprof.""" - - def __init__(self, app, global_conf=None): - self.app = app - self.lock = threading.Lock() - self.__count = 0 - - def __run_app(self, environ, start_response): - app_iter = self.app(environ, start_response) - try: - return list(app_iter) - finally: - if getattr(app_iter, "close", None): - app_iter.close() - - def __call__(self, environ, start_response): - """Run a request.""" - self.lock.acquire() - try: - ret, stats = profile(self.__run_app, environ, start_response) - self.__count += 1 - stats.save("%d-stats.callgrind" % (self.__count,), format="callgrind") - return ret - finally: - self.lock.release() diff --git a/loggerhead/search.py b/loggerhead/search.py deleted file mode 100644 index 2988b3ed..00000000 --- a/loggerhead/search.py +++ /dev/null @@ -1,72 +0,0 @@ -# -# Copyright (C) 2008 Canonical Ltd. -# (Authored by Martin Albisetti ) -# Copyright (C) 2008 Robert Collins -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -_mod_index = None - - -def import_search(): - global errors, _mod_index, FileTextHit, RevisionHit - if _mod_index is not None: - return - try: - from breezy.plugins.search import errors - from breezy.plugins.search import index as _mod_index - from breezy.plugins.search.index import FileTextHit, RevisionHit - except ImportError: - _mod_index = None - - -def search_revisions(branch, query_list, suggest=False): - """ - Search using bzr-search plugin to find revisions matching the query. - This can either suggest query terms, or revision ids. - - param branch: branch object to search in - param query_list: string to search - param suggest: Optional flag to request suggestions instead of results - return: A list for results, either revision ids or terms - """ - import_search() - if _mod_index is None: - return None # None indicates could-not-search - try: - index = _mod_index.open_index_branch(branch) - except errors.NoSearchIndex: - return None # None indicates could-not-search - query = query_list.split(" ") - query = [(term,) for term in query] - revid_list = [] - index._branch.lock_read() - - try: - if suggest: - terms = index.suggest(query) - terms = list(terms) - terms.sort() - return terms - else: - for result in index.search(query): - if isinstance(result, FileTextHit): - revid_list.append(result.text_key[1]) - elif isinstance(result, RevisionHit): - revid_list.append(result.revision_key[0]) - return list(set(revid_list)) - finally: - index._branch.unlock() diff --git a/loggerhead/templatefunctions.py b/loggerhead/templatefunctions.py deleted file mode 100644 index fb88fcc4..00000000 --- a/loggerhead/templatefunctions.py +++ /dev/null @@ -1,236 +0,0 @@ -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -import os -from importlib.metadata import PackageNotFoundError, version - -import breezy -from breezy import urlutils - -from . import __revision__, __version__ -from .util import html_format -from .zptsupport import zpt - -templatefunctions = {} - - -def templatefunc(func): - templatefunctions[func.__name__] = func - return func - - -_base = os.path.dirname(__file__) - - -def _pt(name): - return zpt(os.path.join(_base, "templates", name + ".pt")) - - -templatefunctions["macros"] = _pt("macros").macros -templatefunctions["breadcrumbs"] = _pt("breadcrumbs").macros - - -@templatefunc -def file_change_summary( - url, entry, file_changes, style="normal", currently_showing=None -): - if style == "fragment": - - def file_link(filename): - if currently_showing and filename == currently_showing: - return html_format( - '%s', urlutils.quote(filename), filename - ) - else: - return revision_link( - url, entry.revno, filename, "#" + urlutils.quote(filename) - ) - else: - - def file_link(filename): - return html_format( - '%s', - url(["/revision", entry.revno]), - "#" + urlutils.quote(filename), - filename, - entry.revno, - filename, - ) - - return _pt("revisionfilechanges").expand( - entry=entry, file_changes=file_changes, file_link=file_link, **templatefunctions - ) - - -@templatefunc -def revisioninfo( - url, branch, entry, file_changes=None, currently_showing=None, merged_in=None -): - from . import util - - return _pt("revisioninfo").expand( - url=url, - change=entry, - branch=branch, - util=util, - file_changes=file_changes, - currently_showing=currently_showing, - merged_in=merged_in, - **templatefunctions, - ) - - -@templatefunc -def branchinfo(branch): - if branch.served_url is not None: - return _pt("branchinfo").expand(branch=branch, **templatefunctions) - else: - return "" - - -@templatefunc -def collapse_button(group, name, branch, normal="block"): - return _pt("collapse-button").expand( - group=group, name=name, normal=normal, branch=branch, **templatefunctions - ) - - -@templatefunc -def collapse_all_button(group, branch, normal="block"): - return _pt("collapse-all-button").expand( - group=group, normal=normal, branch=branch, **templatefunctions - ) - - -@templatefunc -def revno_with_nick(entry): - if entry.branch_nick: - extra = " " + entry.branch_nick - else: - extra = "" - return "(%s%s)" % (entry.revno, extra) - - -@templatefunc -def search_box(branch, navigation): - return _pt("search-box").expand( - branch=branch, navigation=navigation, **templatefunctions - ) - - -@templatefunc -def feed_link(branch, url): - return _pt("feed-link").expand(branch=branch, url=url, **templatefunctions) - - -@templatefunc -def menu(branch, url, fileview_active=False): - return _pt("menu").expand( - branch=branch, url=url, fileview_active=fileview_active, **templatefunctions - ) - - -@templatefunc -def view_link(url, revno, path): - return html_format( - '%s', - url(["/view", revno, path]), - path, - path, - ) - - -@templatefunc -def revision_link(url, revno, path, frag=""): - return html_format( - '%s', - url(["/revision", revno, path]), - frag, - path, - revno, - path, - ) - - -@templatefunc -def loggerhead_version(): - return __version__ - - -@templatefunc -def loggerhead_revision(): - return __revision__ - - -_cached_generator_string = None - - -@templatefunc -def generator_string(): - global _cached_generator_string - if _cached_generator_string is None: - versions = [] - - # TODO: Errors -- e.g. from a missing/invalid __version__ attribute, or - # ValueError accessing Distribution.version -- should be non-fatal. - - versions.append(("Loggerhead", __version__)) - - import sys - - python_version = breezy._format_version_tuple(sys.version_info) - versions.append(("Python", python_version)) - - versions.append(("Breezy", breezy.__version__)) - - versions.append(("Paste", version("Paste"))) - - try: - paste_deploy_version = version("PasteDeploy") - except PackageNotFoundError: - pass - else: - versions.append(("PasteDeploy", paste_deploy_version)) - - versions.append(("Chameleon", version("Chameleon"))) - - try: - import pygments - except ImportError: - pass - else: - versions.append(("Pygments", pygments.__version__)) - - try: - from breezy.plugins import search - except ImportError: - pass - else: - bzr_search_version = breezy._format_version_tuple(search.version_info) - versions.append(("bzr-search", bzr_search_version)) - - # TODO: On old Python versions, elementtree may be used. - - try: - dozer_version = version("Dozer") - except PackageNotFoundError: - pass - else: - versions.append(("Dozer", dozer_version)) - - version_strings = ("%s/%s" % t for t in versions) - _cached_generator_string = " ".join(version_strings) - return _cached_generator_string diff --git a/loggerhead/templates/__init__.py b/loggerhead/templates/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/loggerhead/templates/atom.pt b/loggerhead/templates/atom.pt deleted file mode 100644 index 9af822fb..00000000 --- a/loggerhead/templates/atom.pt +++ /dev/null @@ -1,33 +0,0 @@ - - - - bazaar changes for <tal:branch-name content="branch.friendly_name">branch name</tal:branch-name> - - ${updated} - url - - - - - - ${entry.revno}: ${entry.short_comment} - - - updated - - - - ID - - - - author - - - - comment - - - - diff --git a/loggerhead/templates/branchinfo.pt b/loggerhead/templates/branchinfo.pt deleted file mode 100644 index 3fca7c11..00000000 --- a/loggerhead/templates/branchinfo.pt +++ /dev/null @@ -1,6 +0,0 @@ -
- To get this branch, use:
- bzr branch - -
diff --git a/loggerhead/templates/breadcrumbs.pt b/loggerhead/templates/breadcrumbs.pt deleted file mode 100644 index 76c1b75d..00000000 --- a/loggerhead/templates/breadcrumbs.pt +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - / - - - - - / - - - - diff --git a/loggerhead/templates/changelog.pt b/loggerhead/templates/changelog.pt deleted file mode 100644 index 3f44e5bf..00000000 --- a/loggerhead/templates/changelog.pt +++ /dev/null @@ -1,168 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -
- - - -

- Sorry, no results found for your search. -

- -

- No revisions! -

- - -

- expand all expand all -

- - - - - - - - - - - - - - - - - - - - - - - - - - -
Rev SummaryAuthorsTagsDateDiffFiles
-
- - - -
-
-
- - -
- -
- - - Diff - Files -
- - - -
- - diff --git a/loggerhead/templates/collapse-all-button.pt b/loggerhead/templates/collapse-all-button.pt deleted file mode 100644 index 0fdf4e9b..00000000 --- a/loggerhead/templates/collapse-all-button.pt +++ /dev/null @@ -1,16 +0,0 @@ - - - collapse - collapse all - - - expand - expand all - - diff --git a/loggerhead/templates/collapse-button.pt b/loggerhead/templates/collapse-button.pt deleted file mode 100644 index d764381b..00000000 --- a/loggerhead/templates/collapse-button.pt +++ /dev/null @@ -1,16 +0,0 @@ - - - collapse - - - expand - - diff --git a/loggerhead/templates/directory.pt b/loggerhead/templates/directory.pt deleted file mode 100644 index 0ada152a..00000000 --- a/loggerhead/templates/directory.pt +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - -
-

- Browsing - -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FilenameLatest RevLast ChangedCommitterComment
- - .. -
- - Branch - - - -
- - Folder - -
-
- - -
- - diff --git a/loggerhead/templates/error.pt b/loggerhead/templates/error.pt deleted file mode 100644 index 6ccf8ebb..00000000 --- a/loggerhead/templates/error.pt +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - -

- - - nice/branch/name - - - - - - : error -

-
-            

-

-

-

-
- - -
diff --git a/loggerhead/templates/feed-link.pt b/loggerhead/templates/feed-link.pt deleted file mode 100644 index cceda911..00000000 --- a/loggerhead/templates/feed-link.pt +++ /dev/null @@ -1,6 +0,0 @@ - - - RSS - - diff --git a/loggerhead/templates/filediff.pt b/loggerhead/templates/filediff.pt deleted file mode 100644 index 9cde8b3a..00000000 --- a/loggerhead/templates/filediff.pt +++ /dev/null @@ -1,25 +0,0 @@ -
-
- - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
diff --git a/loggerhead/templates/inventory.pt b/loggerhead/templates/inventory.pt deleted file mode 100644 index 654e9d67..00000000 --- a/loggerhead/templates/inventory.pt +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - - - - - - - - - - - - - - -
- - -

- No revisions! -

-

-

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FilenameLatest RevLast ChangedCommitterCommentSize
- - .. -
- ..
- - - - - - - Diff - -
- Symlink - - - - - . - - -
- - - File - - - - - Diff - - - - Download File - -
-

-
- - diff --git a/loggerhead/templates/macros.pt b/loggerhead/templates/macros.pt deleted file mode 100644 index 9c7a7d69..00000000 --- a/loggerhead/templates/macros.pt +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -
- -

- -

- -
- -
-
-
- - -
- - diff --git a/loggerhead/templates/menu.pt b/loggerhead/templates/menu.pt deleted file mode 100644 index b707c93d..00000000 --- a/loggerhead/templates/menu.pt +++ /dev/null @@ -1,19 +0,0 @@ - - - - diff --git a/loggerhead/templates/revision.pt b/loggerhead/templates/revision.pt deleted file mode 100644 index 96024d6e..00000000 --- a/loggerhead/templates/revision.pt +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - -

- Viewing all changes in revision . -

-

- - « back to all changes in this revision - -

-

- Viewing changes to -

- - - - - - - - -

- expand all expand all -

- -
- -

Show diffs side-by-side

-

added added

-

removed removed

-
Lines of Context:
-
- -
- -
- -
- - -
-
- -
-
- - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- - -
- - - -
-
- - - -
- - -
diff --git a/loggerhead/templates/revisionfilechanges.pt b/loggerhead/templates/revisionfilechanges.pt deleted file mode 100644 index ad5a6e4a..00000000 --- a/loggerhead/templates/revisionfilechanges.pt +++ /dev/null @@ -1,54 +0,0 @@ - -
    - -
  • files added:
  • -
    -
  • - - -
  • -
- -
    - -
  • files removed:
  • -
    -
  • - - -
  • -
- -
    - -
  • files renamed:
  • -
    -
  • - - old_filename - - => - - - new_filename - - -
  • -
- -
    - -
  • files modified:
  • -
    -
  • - - - -
  • -
-
diff --git a/loggerhead/templates/revisioninfo.pt b/loggerhead/templates/revisioninfo.pt deleted file mode 100644 index ea1c67fb..00000000 --- a/loggerhead/templates/revisioninfo.pt +++ /dev/null @@ -1,58 +0,0 @@ -
-
-
    -
  • - Committer: - -
  • -
  • - Author(s): - -
  • -
  • - Date: - -
  • -
  • - mfrom: - - - -
  • -
  • - mto: - - - -
  • -
  • - mto: - This revision was merged to the branch mainline in - revision - . -
  • -
  • - Revision ID: - -
  • -
-
-
-
-
- -
-
-
-
    - -
-
-
diff --git a/loggerhead/templates/revlog.pt b/loggerhead/templates/revlog.pt deleted file mode 100644 index c1d92b95..00000000 --- a/loggerhead/templates/revlog.pt +++ /dev/null @@ -1,17 +0,0 @@ -
-
    -
  • - - - -
  • -
  • - -
  • -
  • - -
-
diff --git a/loggerhead/templates/search-box.pt b/loggerhead/templates/search-box.pt deleted file mode 100644 index 21dced4b..00000000 --- a/loggerhead/templates/search-box.pt +++ /dev/null @@ -1,9 +0,0 @@ - -
- - -
-
- diff --git a/loggerhead/templates/search.pt b/loggerhead/templates/search.pt deleted file mode 100644 index 71a2fed1..00000000 --- a/loggerhead/templates/search.pt +++ /dev/null @@ -1,8 +0,0 @@ -
    - -
  • -
    - -
  • No results found.
  • -
    -
diff --git a/loggerhead/templates/view.pt b/loggerhead/templates/view.pt deleted file mode 100644 index a057f2bb..00000000 --- a/loggerhead/templates/view.pt +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -
- - - - -
- - - - - - - - - - - - -
- - -
- - -
-
1
-
-
1
-              
-
-

-            
-
- -
- - diff --git a/loggerhead/tests/__init__.py b/loggerhead/tests/__init__.py deleted file mode 100644 index 207d2235..00000000 --- a/loggerhead/tests/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2006, 2010, 2011 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - - -from __future__ import absolute_import - - -def test_suite(): - import unittest - - loader = unittest.TestLoader() - return loader.loadTestsFromNames( - [ - (__name__ + "." + x) - for x in [ - "test_controllers", - "test_corners", - "test_history", - "test_http_head", - "test_load_test", - "test_simple", - "test_revision_ui", - "test_templating", - "test_util", - "test_highlight", - ] - ] - ) diff --git a/loggerhead/tests/fixtures.py b/loggerhead/tests/fixtures.py deleted file mode 100644 index 79191606..00000000 --- a/loggerhead/tests/fixtures.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (C) 2007, 2008, 2009, 2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -from __future__ import absolute_import - -from fixtures import Fixture - - -class SampleBranch(Fixture): - def __init__(self, testcase): - # Must be a bzr TestCase to hook into branch creation, unfortunately. - self.testcase = testcase - - def setUp(self): - Fixture.setUp(self) - - self.tree = self.testcase.make_branch_and_tree(".") - - self.filecontents = "some\nmultiline\ndata\nwith - -simple test page title - - -
Hello, name
- - diff --git a/loggerhead/tests/test_controllers.py b/loggerhead/tests/test_controllers.py deleted file mode 100644 index cc079f2f..00000000 --- a/loggerhead/tests/test_controllers.py +++ /dev/null @@ -1,556 +0,0 @@ -# Copyright (C) 2008-2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - - -import json -import tarfile -import tempfile - -from paste.fixture import AppError -from paste.httpexceptions import HTTPNotFound -from testtools.matchers import Matcher, Mismatch - -from ..apps.branch import BranchWSGIApp -from ..controllers.annotate_ui import AnnotateUI -from .test_simple import BasicTests, TestWithSimpleTree, consume_app - - -class TestInventoryUI(BasicTests): - def make_bzrbranch_for_tree_shape(self, shape): - tree = self.make_branch_and_tree(".") - self.build_tree(shape) - tree.smart_add([]) - tree.commit("") - self.addCleanup(tree.branch.lock_read().unlock) - return tree.branch - - def make_bzrbranch_and_inventory_ui_for_tree_shape(self, shape, env): - branch = self.make_bzrbranch_for_tree_shape(shape) - branch_app = self.make_branch_app(branch) - return branch, branch_app.lookup_app(env) - - def test_get_filelist(self): - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/files", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - bzrbranch, inv_ui = self.make_bzrbranch_and_inventory_ui_for_tree_shape( - ["filename"], env - ) - revtree = bzrbranch.repository.revision_tree(bzrbranch.last_revision()) - self.assertEqual(1, len(inv_ui.get_filelist(revtree, "", "filename", "head"))) - - def test_smoke(self): - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/files", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - bzrbranch, inv_ui = self.make_bzrbranch_and_inventory_ui_for_tree_shape( - ["filename"], env - ) - start, content = consume_app(inv_ui, env) - self.assertEqual(("200 OK", [("Content-Type", "text/html")], None), start) - self.assertContainsRe(content, b"filename") - - def test_no_content_for_HEAD(self): - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/files", - "REQUEST_METHOD": "HEAD", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - bzrbranch, inv_ui = self.make_bzrbranch_and_inventory_ui_for_tree_shape( - ["filename"], env - ) - start, content = consume_app(inv_ui, env) - self.assertEqual(("200 OK", [("Content-Type", "text/html")], None), start) - self.assertEqual(b"", content) - - def test_get_values_smoke(self): - branch = self.make_bzrbranch_for_tree_shape(["a-file"]) - branch_app = self.make_branch_app(branch) - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/files", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - inv_ui = branch_app.lookup_app(env) - inv_ui.parse_args(env) - values = inv_ui.get_values("", {}, {}) - self.assertEqual("a-file", values["filelist"][0].filename) - - def test_json_render_smoke(self): - branch = self.make_bzrbranch_for_tree_shape(["a-file"]) - branch_app = self.make_branch_app(branch) - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/+json/files", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - inv_ui = branch_app.lookup_app(env) - self.assertOkJsonResponse(inv_ui, env) - - -class TestRevisionUI(BasicTests): - def make_branch_app_for_revision_ui(self, shape1, shape2): - tree = self.make_branch_and_tree(".") - self.build_tree_contents(shape1) - tree.smart_add([]) - tree.commit("msg 1", rev_id=b"rev-1") - self.build_tree_contents(shape2) - tree.smart_add([]) - tree.commit("msg 2", rev_id=b"rev-2") - branch = tree.branch - self.addCleanup(branch.lock_read().unlock) - return self.make_branch_app(branch) - - def test_get_values(self): - branch_app = self.make_branch_app_for_revision_ui([], []) - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/revision/2", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - rev_ui = branch_app.lookup_app(env) - rev_ui.parse_args(env) - self.assertIsInstance(rev_ui.get_values("", {}, []), dict) - - def test_add_template_values(self): - branch_app = self.make_branch_app_for_revision_ui( - [("file", b"content\n")], [("file", b"new content\n")] - ) - env = { - "SCRIPT_NAME": "/", - "PATH_INFO": "/revision/1/non-existent-file", - "QUERY_STRING": "start_revid=1", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - revision_ui = branch_app.lookup_app(env) - path = revision_ui.parse_args(env) - values = revision_ui.get_values(path, revision_ui.kwargs, {}) - revision_ui.add_template_values(values) - self.assertIsNone(values["diff_chunks"]) - - def test_add_template_values_with_changes(self): - branch_app = self.make_branch_app_for_revision_ui( - [("file", b"content\n")], [("file", b"new content\n")] - ) - env = { - "SCRIPT_NAME": "/", - "PATH_INFO": "/revision/1/file", - "QUERY_STRING": "start_revid=1", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - revision_ui = branch_app.lookup_app(env) - path = revision_ui.parse_args(env) - values = revision_ui.get_values(path, revision_ui.kwargs, {}) - revision_ui.add_template_values(values) - self.assertEqual(len(values["diff_chunks"]), 1) - - def test_add_template_values_with_non_ascii(self): - branch_app = self.make_branch_app_for_revision_ui( - [("skr\xe1", b"content\n")], [("skr\xe1", b"new content\n")] - ) - env = { - "SCRIPT_NAME": "/", - "PATH_INFO": "/revision/1", - "QUERY_STRING": "start_revid=1", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - revision_ui = branch_app.lookup_app(env) - path = revision_ui.parse_args(env) - values = revision_ui.get_values(path, revision_ui.kwargs, {}) - revision_ui.add_template_values(values) - self.assertEqual( - json.loads(values["link_data"]), - { - "diff-0": "rev-1/null%253A/%252F", - "diff-1": "rev-1/null%253A/skr%25C3%25A1", - }, - ) - self.assertEqual( - json.loads(values["path_to_id"]), {"/": "diff-0", "skr\xe1": "diff-1"} - ) - - def test_get_values_smoke(self): - branch_app = self.make_branch_app_for_revision_ui( - [("file", b"content\n"), ("other-file", b"other\n")], - [("file", b"new content\n")], - ) - env = { - "SCRIPT_NAME": "/", - "PATH_INFO": "/revision/head:", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - revision_ui = branch_app.lookup_app(env) - revision_ui.parse_args(env) - values = revision_ui.get_values("", {}, {}) - - self.assertEqual(values["revid"], "rev-2") - self.assertEqual(values["change"].comment, "msg 2") - self.assertEqual(values["file_changes"].modified[0].filename, "file") - self.assertEqual(values["merged_in"], None) - - def test_json_render_smoke(self): - branch_app = self.make_branch_app_for_revision_ui( - [("file", b"content\n"), ("other-file", b"other\n")], - [("file", b"new content\n")], - ) - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/+json/revision/head:", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - revision_ui = branch_app.lookup_app(env) - self.assertOkJsonResponse(revision_ui, env) - - -class TestAnnotateUI(BasicTests): - def make_annotate_ui_for_file_history(self, filename, rev_ids_texts): - tree = self.make_branch_and_tree(".") - self.build_tree_contents([(filename, "")]) - tree.add([filename]) - for rev_id, text, message in rev_ids_texts: - self.build_tree_contents([(filename, text)]) - tree.commit(rev_id=rev_id, message=message) - tree.branch.lock_read() - self.addCleanup(tree.branch.unlock) - branch_app = BranchWSGIApp(tree.branch, friendly_name="test_name") - return AnnotateUI(branch_app, branch_app.get_history) - - def test_annotate_file(self): - history = [(b"rev1", b"old\nold\n", "."), (b"rev2", b"new\nold\n", ".")] - ann_ui = self.make_annotate_ui_for_file_history("filename", history) - # A lot of this state is set up by __call__, but we'll do it directly - # here. - ann_ui.args = ["rev2"] - annotate_info = ann_ui.get_values("filename", kwargs={}, headers={}) - annotated = annotate_info["annotated"] - self.assertEqual(2, len(annotated)) - self.assertEqual("2", annotated[1].change.revno) - self.assertEqual("1", annotated[2].change.revno) - - def test_annotate_empty_comment(self): - # Testing empty comment handling without breaking - history = [(b"rev1", b"old\nold\n", "."), (b"rev2", b"new\nold\n", "")] - ann_ui = self.make_annotate_ui_for_file_history("filename", history) - ann_ui.args = ["rev2"] - ann_ui.get_values("filename", kwargs={}, headers={}) - - def test_annotate_file_zero_sized(self): - # Test against a zero-sized file without breaking. No annotation - # must be present. - history = [(b"rev1", b"", ".")] - ann_ui = self.make_annotate_ui_for_file_history("filename", history) - ann_ui.args = ["rev1"] - annotate_info = ann_ui.get_values("filename", kwargs={}, headers={}) - annotated = annotate_info["annotated"] - self.assertEqual(0, len(annotated)) - - def test_annotate_nonexistent_file(self): - history = [(b"rev1", b"", ".")] - ann_ui = self.make_annotate_ui_for_file_history("filename", history) - ann_ui.args = ["rev1"] - self.assertRaises(HTTPNotFound, ann_ui.get_values, "not-filename", {}, {}) - - def test_annotate_nonexistent_rev(self): - history = [(b"rev1", b"", ".")] - ann_ui = self.make_annotate_ui_for_file_history("filename", history) - ann_ui.args = ["norev"] - self.assertRaises(HTTPNotFound, ann_ui.get_values, "not-filename", {}, {}) - - -class TestFileDiffUI(BasicTests): - def make_branch_app_for_filediff_ui(self): - builder = self.make_branch_builder("branch") - builder.start_series() - rev1 = builder.build_snapshot( - None, - [ - ("add", ("", None, "directory", "")), - ("add", ("filename", None, "file", b"content\n")), - ], - message="First commit.", - ) - rev2 = builder.build_snapshot( - None, [("modify", ("filename", b"new content\n"))] - ) - builder.finish_series() - branch = builder.get_branch() - self.addCleanup(branch.lock_read().unlock) - return self.make_branch_app(branch), (rev1, rev2) - - def test_get_values_smoke(self): - branch_app, (rev1, rev2) = self.make_branch_app_for_filediff_ui() - env = { - "SCRIPT_NAME": "/", - "PATH_INFO": "/+filediff/{}/{}/filename".format( - rev2.decode("utf-8"), rev1.decode("utf-8") - ), - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - filediff_ui = branch_app.lookup_app(env) - filediff_ui.parse_args(env) - values = filediff_ui.get_values("", {}, {}) - chunks = values["chunks"] - self.assertEqual("insert", chunks[0].diff[1].type) - self.assertEqual("new content", chunks[0].diff[1].line) - - def test_json_render_smoke(self): - branch_app, (rev1, rev2) = self.make_branch_app_for_filediff_ui() - env = { - "SCRIPT_NAME": "/", - "PATH_INFO": "/+json/+filediff/{}/{}/filename".format( - rev2.decode("utf-8"), rev1.decode("utf-8") - ), - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - filediff_ui = branch_app.lookup_app(env) - self.assertOkJsonResponse(filediff_ui, env) - - -class TestRevLogUI(BasicTests): - def make_branch_app_for_revlog_ui(self): - builder = self.make_branch_builder("branch") - builder.start_series() - revid = builder.build_snapshot( - None, - [ - ("add", ("", None, "directory", "")), - ("add", ("filename", None, "file", b"content\n")), - ], - message="First commit.", - ) - builder.finish_series() - branch = builder.get_branch() - self.addCleanup(branch.lock_read().unlock) - return self.make_branch_app(branch), revid - - def test_get_values_smoke(self): - branch_app, revid = self.make_branch_app_for_revlog_ui() - env = { - "SCRIPT_NAME": "/", - "PATH_INFO": "/+revlog/%s" % revid.decode("utf-8"), - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - revlog_ui = branch_app.lookup_app(env) - revlog_ui.parse_args(env) - values = revlog_ui.get_values("", {}, {}) - self.assertEqual(values["file_changes"].added[1].filename, "filename") - self.assertEqual(values["entry"].comment, "First commit.") - - def test_json_render_smoke(self): - branch_app, revid = self.make_branch_app_for_revlog_ui() - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/+json/+revlog/%s" % revid.decode("utf-8"), - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - revlog_ui = branch_app.lookup_app(env) - self.assertOkJsonResponse(revlog_ui, env) - - -class TestControllerHooks(BasicTests): - def test_dummy_hook(self): - return - # A hook that returns None doesn't influence the searching for - # a controller. - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/custom", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - - def myhook(app, environ): - return None - - branch = self.make_branch(".") - self.addCleanup(branch.lock_read().unlock) - app = self.make_branch_app(branch) - self.addCleanup( - BranchWSGIApp.hooks.uninstall_named_hook, "controller", "captain hook" - ) - BranchWSGIApp.hooks.install_named_hook("controller", myhook, "captain hook") - self.assertRaises(KeyError, app.lookup_app, env) - - def test_working_hook(self): - # A hook can provide an app to use for a particular request. - env = { - "SCRIPT_NAME": "", - "PATH_INFO": "/custom", - "REQUEST_METHOD": "GET", - "wsgi.url_scheme": "http", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - } - - def myhook(app, environ): - return "I am hooked" - - branch = self.make_branch(".") - self.addCleanup(branch.lock_read().unlock) - app = self.make_branch_app(branch) - self.addCleanup( - BranchWSGIApp.hooks.uninstall_named_hook, "controller", "captain hook" - ) - BranchWSGIApp.hooks.install_named_hook("controller", myhook, "captain hook") - self.assertEqual("I am hooked", app.lookup_app(env)) - - -class MatchesDownloadHeaders(Matcher): - def __init__(self, expect_filename, expect_mimetype): - self.expect_filename = expect_filename - self.expect_mimetype = expect_mimetype - - def match(self, response): - # Maybe the c-t should be more specific, but this is probably good for - # making sure it gets saved without the client trying to decompress it - # or anything. - if ( - response.header("Content-Type") == self.expect_mimetype - and response.header("Content-Disposition") - == "attachment; filename*=utf-8''" + self.expect_filename - ): - pass - else: - return Mismatch("wrong response headers: %r" % response.headers) - - def __str__(self): - return "MatchesDownloadHeaders({!r}, {!r})".format( - self.expect_filename, self.expect_mimetype - ) - - -class TestDownloadUI(TestWithSimpleTree): - def test_download(self): - app = self.setUpLoggerhead() - response = app.get("/download/1/myfilename") - self.assertEqual( - b"some\nmultiline\ndata\nwithhi" - self.addFileAndCommit("myfilename", msg) - app = self.setUpLoggerhead() - res = app.get("/revision/1") - self.assertNotIn(msg, res.body) - - def test_empty_commit_message(self): - """Check that an empty commit message does not break the rendering.""" - self.createBranch() - - # Make a commit that has an empty message. - self.addFileAndCommit("myfilename", "") - - # Check that it didn't break things. - app = self.setUpLoggerhead() - res = app.get("/changes") - # It's not much of an assertion, but we only really care about - # "assert not crashed". - res.mustcontain("1") - - def test_whitespace_only_commit_message(self): - """Check that a whitespace-only commit message does not break the - rendering.""" - self.createBranch() - - # Make a commit that has a whitespace only message. - self.addFileAndCommit("myfilename", " ") - - # Check that it didn't break things. - app = self.setUpLoggerhead() - res = app.get("/changes") - # It's not much of an assertion, but we only really care about - # "assert not crashed". - res.mustcontain("1") diff --git a/loggerhead/tests/test_highlight.py b/loggerhead/tests/test_highlight.py deleted file mode 100644 index ff804c11..00000000 --- a/loggerhead/tests/test_highlight.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright 2022 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -from breezy import tests - -from ..highlight import highlight - - -class TestHighLight(tests.TestCase): - def test_no_highlighting_for_big_texts(self): - rv = highlight( - path="", - text="text\n" * 102401, # bigger than MAX_HIGHLIGHT_SIZE - encoding="utf-8", - ) - self.assertIsInstance(rv, list) - self.assertLength(102401, rv) - # no highlighting applied - for item in rv: - self.assertEqual("text\n", item) diff --git a/loggerhead/tests/test_history.py b/loggerhead/tests/test_history.py deleted file mode 100644 index 7ec7389d..00000000 --- a/loggerhead/tests/test_history.py +++ /dev/null @@ -1,337 +0,0 @@ -# Copyright (C) 2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# - -"""Direct tests of the loggerhead/history.py module""" - -from datetime import datetime - -from breezy import tag, tests -from breezy.foreign import ForeignRevision, ForeignVcs, VcsMapping - -from .. import history as _mod_history - - -class TestCaseWithExamples(tests.TestCaseWithMemoryTransport): - def make_linear_ancestry(self): - # Time goes up - # rev-3 - # | - # rev-2 - # | - # rev-1 - builder = self.make_branch_builder("branch") - builder.start_series() - rev1 = builder.build_snapshot( - None, [("add", ("", b"root-id", "directory", None))] - ) - rev2 = builder.build_snapshot([rev1], []) - rev3 = builder.build_snapshot([rev2], []) - builder.finish_series() - b = builder.get_branch() - self.addCleanup(b.lock_read().unlock) - return _mod_history.History(b, {}), [rev1, rev2, rev3] - - def make_long_linear_ancestry(self): - builder = self.make_branch_builder("branch") - revs = [] - builder.start_series() - revs.append( - builder.build_snapshot(None, [("add", ("", b"root-id", "directory", None))]) - ) - for r in "BCDEFGHIJKLMNOPQRSTUVWXYZ": - revs.append(builder.build_snapshot(None, [])) - builder.finish_series() - b = builder.get_branch() - self.addCleanup(b.lock_read().unlock) - return _mod_history.History(b, {}), revs - - def make_merged_ancestry(self): - # Time goes up - # rev-3 - # | \ - # | rev-2 - # | / - # rev-1 - builder = self.make_branch_builder("branch") - builder.start_series() - rev1 = builder.build_snapshot( - None, [("add", ("", b"root-id", "directory", None))] - ) - rev2 = builder.build_snapshot([rev1], []) - rev3 = builder.build_snapshot([rev1, rev2], []) - builder.finish_series() - b = builder.get_branch() - self.addCleanup(b.lock_read().unlock) - return _mod_history.History(b, {}), [rev1, rev2, rev3] - - def make_deep_merged_ancestry(self): - # Time goes up - # F - # |\ - # | E - # | |\ - # | | D - # | |/ - # B C - # |/ - # A - builder = self.make_branch_builder("branch") - builder.start_series() - rev_a = builder.build_snapshot( - None, [("add", ("", b"root-id", "directory", None))] - ) - rev_b = builder.build_snapshot([rev_a], []) - rev_c = builder.build_snapshot([rev_a], []) - rev_d = builder.build_snapshot([rev_c], []) - rev_e = builder.build_snapshot([rev_c, rev_d], []) - rev_f = builder.build_snapshot([rev_b, rev_e], []) - builder.finish_series() - b = builder.get_branch() - self.addCleanup(b.lock_read().unlock) - return (_mod_history.History(b, {}), [rev_a, rev_b, rev_c, rev_d, rev_e, rev_f]) - - def assertRevidsFrom(self, expected, history, search_revs, tip_rev): - self.assertEqual(expected, list(history.get_revids_from(search_revs, tip_rev))) - - -class _DictProxy: - def __init__(self, d): - self._d = d - self._accessed = set() - self.__setitem__ = d.__setitem__ - - def __getitem__(self, name): - self._accessed.add(name) - return self._d[name] - - def __len__(self): - return len(self._d) - - -def track_rev_info_accesses(h): - """Track __getitem__ access to History._rev_info, - - :return: set of items accessed - """ - h._rev_info = _DictProxy(h._rev_info) - return h._rev_info._accessed - - -class TestHistoryGetRevidsFrom(TestCaseWithExamples): - def test_get_revids_from_simple_mainline(self): - history, revs = self.make_linear_ancestry() - self.assertRevidsFrom(list(reversed(revs)), history, None, revs[2]) - - def test_get_revids_from_merged_mainline(self): - history, revs = self.make_merged_ancestry() - self.assertRevidsFrom([revs[2], revs[0]], history, None, revs[2]) - - def test_get_revids_given_one_rev(self): - history, revs = self.make_merged_ancestry() - # rev-3 was the first mainline revision to see rev-2. - self.assertRevidsFrom([revs[2]], history, [revs[1]], revs[2]) - - def test_get_revids_deep_ancestry(self): - history, revs = self.make_deep_merged_ancestry() - self.assertRevidsFrom([revs[-1]], history, [revs[-1]], revs[-1]) - self.assertRevidsFrom([revs[-1]], history, [revs[-2]], revs[-1]) - self.assertRevidsFrom([revs[-1]], history, [revs[-3]], revs[-1]) - self.assertRevidsFrom([revs[-1]], history, [revs[-4]], revs[-1]) - self.assertRevidsFrom([revs[1]], history, [revs[-5]], revs[-1]) - self.assertRevidsFrom([revs[0]], history, [revs[-6]], revs[-1]) - - def test_get_revids_doesnt_over_produce_simple_mainline(self): - # get_revids_from shouldn't walk the whole ancestry just to get the - # answers for the first few revisions. - history, revs = self.make_long_linear_ancestry() - accessed = track_rev_info_accesses(history) - result = history.get_revids_from(None, revs[-1]) - self.assertEqual(set(), accessed) - self.assertEqual(revs[-1], next(result)) - # We already know revs[-1] because we passed it in. - self.assertEqual(set(), accessed) - self.assertEqual(revs[-2], next(result)) - self.assertEqual({history._rev_indices[revs[-1]]}, accessed) - - def test_get_revids_doesnt_over_produce_for_merges(self): - # get_revids_from shouldn't walk the whole ancestry just to get the - # answers for the first few revisions. - history, revs = self.make_long_linear_ancestry() - accessed = track_rev_info_accesses(history) - result = history.get_revids_from([revs[-3], revs[-5]], revs[-1]) - self.assertEqual(set(), accessed) - self.assertEqual(revs[-3], next(result)) - # We access 'W' because we are checking that W wasn't merged into X. - # The important bit is that we aren't getting the whole ancestry. - self.assertEqual( - {history._rev_indices[x] for x in list(reversed(revs))[:4]}, accessed - ) - self.assertEqual(revs[-5], next(result)) - self.assertEqual( - {history._rev_indices[x] for x in list(reversed(revs))[:6]}, accessed - ) - self.assertRaises(StopIteration, next, result) - self.assertEqual( - {history._rev_indices[x] for x in list(reversed(revs))[:6]}, accessed - ) - - -class TestHistoryChangeFromRevision(tests.TestCaseWithTransport): - def make_single_commit(self): - tree = self.make_branch_and_tree("test") - rev_id = tree.commit( - "Commit Message", - timestamp=1299838474.317, - timezone=3600, - committer="Joe Example ", - revprops={}, - ) - self.addCleanup(tree.branch.lock_write().unlock) - rev = tree.branch.repository.get_revision(rev_id) - history = _mod_history.History(tree.branch, {}) - return history, rev - - def test_simple(self): - history, rev = self.make_single_commit() - change = history._change_from_revision(rev) - self.assertEqual(rev.revision_id, change.revid) - self.assertEqual(datetime.fromtimestamp(1299838474.317), change.date) - self.assertEqual(datetime.utcfromtimestamp(1299838474.317), change.utc_date) - self.assertEqual(["Joe Example "], change.authors) - self.assertEqual("test", change.branch_nick) - self.assertEqual("Commit Message", change.short_comment) - self.assertEqual("Commit Message", change.comment) - self.assertEqual(["Commit Message"], change.comment_clean) - self.assertEqual([], change.parents) - self.assertEqual([], change.bugs) - self.assertEqual(None, change.tags) - - def test_tags(self): - history, rev = self.make_single_commit() - b = history._branch - b.tags.set_tag("tag-1", rev.revision_id) - b.tags.set_tag("tag-2", rev.revision_id) - b.tags.set_tag("Tag-10", rev.revision_id) - change = history._change_from_revision(rev) - # If available, tags are 'naturally' sorted. (sorting numbers in order, - # and ignoring case, etc.) - if getattr(tag, "sort_natural", None) is not None: - self.assertEqual("tag-1, tag-2, Tag-10", change.tags) - else: - self.assertEqual("Tag-10, tag-1, tag-2", change.tags) - - def test_committer_vs_authors(self): - tree = self.make_branch_and_tree("test") - rev_id = tree.commit( - "Commit Message", - timestamp=1299838474.317, - timezone=3600, - committer="Joe Example ", - revprops={ - "authors": "A Author \n" - "B Author " - }, - ) - self.addCleanup(tree.branch.lock_write().unlock) - rev = tree.branch.repository.get_revision(rev_id) - history = _mod_history.History(tree.branch, {}) - change = history._change_from_revision(rev) - self.assertEqual("Joe Example ", change.committer) - self.assertEqual( - ["A Author ", "B Author "], - change.authors, - ) - - -class TestHistory_IterateSufficiently(tests.TestCase): - def assertIterate(self, expected, iterable, stop_at, extra_rev_count): - self.assertEqual( - expected, - _mod_history.History._iterate_sufficiently( - iterable, stop_at, extra_rev_count - ), - ) - - def test_iter_no_extra(self): - lst = list("abcdefghijklmnopqrstuvwxyz") - self.assertIterate(["a", "b", "c"], iter(lst), "c", 0) - self.assertIterate(["a", "b", "c", "d"], iter(lst), "d", 0) - - def test_iter_not_found(self): - # If the key in question isn't found, we just exhaust the list - lst = list("abcdefghijklmnopqrstuvwxyz") - self.assertIterate(lst, iter(lst), "not-there", 0) - - def test_iter_with_extra(self): - lst = list("abcdefghijklmnopqrstuvwxyz") - self.assertIterate(["a", "b", "c"], iter(lst), "b", 1) - self.assertIterate(["a", "b", "c", "d", "e"], iter(lst), "c", 2) - - def test_iter_with_too_many_extra(self): - lst = list("abcdefghijklmnopqrstuvwxyz") - self.assertIterate(lst, iter(lst), "y", 10) - self.assertIterate(lst, iter(lst), "z", 10) - - def test_iter_with_extra_None(self): - lst = list("abcdefghijklmnopqrstuvwxyz") - self.assertIterate(lst, iter(lst), "z", None) - - -class TestHistoryGetView(TestCaseWithExamples): - def test_get_view_limited_history(self): - # get_view should only load enough history to serve the result, not all - # history. - history, revs = self.make_long_linear_ancestry() - accessed = track_rev_info_accesses(history) - revid, start_revid, revid_list = history.get_view( - revs[-1], revs[-1], None, extra_rev_count=5 - ) - self.assertEqual(list(reversed(revs))[:6], revid_list) - self.assertEqual(revs[-1], revid) - self.assertEqual(revs[-1], start_revid) - self.assertEqual( - {history._rev_indices[x] for x in list(reversed(revs))[:6]}, accessed - ) - - -class TestHistoryGetChangedUncached(TestCaseWithExamples): - def test_native(self): - history, revs = self.make_linear_ancestry() - changes = history.get_changes_uncached([revs[0], revs[1]]) - self.assertEqual(2, len(changes)) - self.assertEqual(revs[0], changes[0].revid) - self.assertEqual(revs[1], changes[1].revid) - self.assertIs(None, getattr(changes[0], "foreign_vcs", None)) - self.assertIs(None, getattr(changes[0], "foreign_revid", None)) - - def test_foreign(self): - # Test with a mocked foreign revision, as it's not possible - # to rely on any foreign plugins being installed. - history, revs = self.make_linear_ancestry() - foreign_vcs = ForeignVcs(None, "vcs") - foreign_vcs.show_foreign_revid = repr - foreign_rev = ForeignRevision( - ("uuid", 1234), - VcsMapping(foreign_vcs), - "revid-in-bzr", - message="message", - timestamp=234423423.3, - ) - change = history._change_from_revision(foreign_rev) - self.assertEqual("revid-in-bzr", change.revid) - self.assertEqual("('uuid', 1234)", change.foreign_revid) - self.assertEqual("vcs", change.foreign_vcs) diff --git a/loggerhead/tests/test_http_head.py b/loggerhead/tests/test_http_head.py deleted file mode 100644 index f5b5cd77..00000000 --- a/loggerhead/tests/test_http_head.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright 2011 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -"""Tests for the HeadMiddleware app.""" - -from io import BytesIO - -from breezy import tests - -from ..apps import http_head - -content = [ - b"", - b"Listed", - b"Content", - b"", -] -headers = {"X-Ignored-Header": "Value"} - - -def yielding_app(environ, start_response): - start_response("200 OK", headers) - yield from content - - -def list_app(environ, start_response): - start_response("200 OK", headers) - return content - - -def writer_app(environ, start_response): - writer = start_response("200 OK", headers) - for chunk in content: - writer(chunk) - return [] - - -class TestHeadMiddleware(tests.TestCase): - def _trap_start_response(self, status, response_headers, exc_info=None): - self._write_buffer = BytesIO() - self._start_response_passed = (status, response_headers, exc_info) - return self._write_buffer.write - - def _consume_app(self, app, request_method): - environ = {"REQUEST_METHOD": request_method} - value = list(app(environ, self._trap_start_response)) - self._write_buffer.writelines(value) - - def _verify_get_passthrough(self, app): - app = http_head.HeadMiddleware(app) - self._consume_app(app, "GET") - self.assertEqual(("200 OK", headers, None), self._start_response_passed) - self.assertEqualDiff(b"".join(content), self._write_buffer.getvalue()) - - def _verify_head_no_body(self, app): - app = http_head.HeadMiddleware(app) - self._consume_app(app, "HEAD") - self.assertEqual(("200 OK", headers, None), self._start_response_passed) - self.assertEqualDiff(b"", self._write_buffer.getvalue()) - - def test_get_passthrough_yielding(self): - self._verify_get_passthrough(yielding_app) - - def test_head_passthrough_yielding(self): - self._verify_head_no_body(yielding_app) - - def test_get_passthrough_list(self): - self._verify_get_passthrough(list_app) - - def test_head_passthrough_list(self): - self._verify_head_no_body(list_app) - - def test_get_passthrough_writer(self): - self._verify_get_passthrough(writer_app) - - def test_head_passthrough_writer(self): - self._verify_head_no_body(writer_app) diff --git a/loggerhead/tests/test_inventory_ui.py b/loggerhead/tests/test_inventory_ui.py deleted file mode 100644 index 68606e3a..00000000 --- a/loggerhead/tests/test_inventory_ui.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (C) 2022 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# - -from .test_simple import TestWithSimpleTree - - -class TestInventoryUI(TestWithSimpleTree): - def test_authors_vs_committer(self): - app = self.setUpLoggerhead() - res = app.get("/files") - # download url in top directory is composed correctly - res.mustcontain("/download/rev-1/myfilename") - - res2 = app.get("/files/head:/folder") - # download url in subdirectory is composed correctly - res2.mustcontain("/download/rev-1/folder/myfilename") diff --git a/loggerhead/tests/test_load_test.py b/loggerhead/tests/test_load_test.py deleted file mode 100644 index 42553d21..00000000 --- a/loggerhead/tests/test_load_test.py +++ /dev/null @@ -1,340 +0,0 @@ -# Copyright 2011 Canonical Ltd -# -# 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 2 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 -# - -"""Tests for the load testing code.""" - -import socket -import threading -import time -from queue import Empty - -from breezy import tests -from breezy.tests import http_server - -from .. import load_test - -empty_script = """{ - "parameters": {}, - "requests": [] -}""" - - -class TestRequestDescription(tests.TestCase): - def test_init_from_dict(self): - rd = load_test.RequestDescription({"thread": "10", "relpath": "/foo"}) - self.assertEqual("10", rd.thread) - self.assertEqual("/foo", rd.relpath) - - def test_default_thread_is_1(self): - rd = load_test.RequestDescription({"relpath": "/bar"}) - self.assertEqual("1", rd.thread) - self.assertEqual("/bar", rd.relpath) - - -_cur_time = time.time() - - -def one_sec_timer(): - """Every time this timer is called, it increments by 1 second.""" - global _cur_time - _cur_time += 1.0 - return _cur_time - - -class NoopRequestWorker(load_test.RequestWorker): - # Every call to _timer will increment by one - _timer = staticmethod(one_sec_timer) - - # Ensure that process never does anything - def process(self, url): - return True - - -class TestRequestWorkerInfrastructure(tests.TestCase): - """Tests various infrastructure bits, without doing actual requests.""" - - def test_step_next_tracks_time(self): - rt = NoopRequestWorker("id") - rt.queue.put("item") - rt.step_next() - self.assertTrue(rt.queue.empty()) - self.assertEqual([("item", True, 1.0)], rt.stats) - - def test_step_multiple_items(self): - rt = NoopRequestWorker("id") - rt.queue.put("item") - rt.step_next() - rt.queue.put("next-item") - rt.step_next() - self.assertTrue(rt.queue.empty()) - self.assertEqual([("item", True, 1.0), ("next-item", True, 1.0)], rt.stats) - - def test_step_next_does_nothing_for_noop(self): - rt = NoopRequestWorker("id") - rt.queue.put("item") - rt.step_next() - rt.queue.put("") - rt.step_next() - self.assertEqual([("item", True, 1.0)], rt.stats) - - def test_step_next_will_timeout(self): - # We don't want step_next to block forever - rt = NoopRequestWorker("id", blocking_time=0.001) - self.assertRaises(Empty, rt.step_next) - - def test_run_stops_for_stop_event(self): - rt = NoopRequestWorker("id", blocking_time=0.001, _queue_size=2) - rt.queue.put("item1") - rt.queue.put("item2") - event = threading.Event() - t = threading.Thread(target=rt.run, args=(event,)) - t.start() - # Wait for the queue to be processed - rt.queue.join() - # Now we can queue up another one, and wait for it - rt.queue.put("item3") - rt.queue.join() - # Now set the stopping event - event.set() - # Add another item to the queue, which might get processed, but the - # next item won't - rt.queue.put("item4") - rt.queue.put("item5") - t.join() - self.assertEqual( - [("item1", True, 1.0), ("item2", True, 1.0), ("item3", True, 1.0)], - rt.stats[:3], - ) - # The last event might be item4 or might be item3, the important thing - # is that even though there are still queued events, we won't - # process anything past the first - self.assertNotEqual("item5", rt.stats[-1][0]) - - -class TestRequestWorker(tests.TestCaseWithTransport): - def setUp(self): - super().setUp() - self.transport_readonly_server = http_server.HttpServer - - def test_request_items(self): - rt = load_test.RequestWorker("id", blocking_time=0.01, _queue_size=2) - self.build_tree(["file1", "file2"]) - readonly_url1 = self.get_readonly_url("file1") - self.assertStartsWith(readonly_url1, "http://") - readonly_url2 = self.get_readonly_url("file2") - rt.queue.put(readonly_url1) - rt.queue.put(readonly_url2) - rt.step_next() - rt.step_next() - self.assertEqual(readonly_url1, rt.stats[0][0]) - self.assertEqual(readonly_url2, rt.stats[1][0]) - - def test_request_nonexistant_items(self): - rt = load_test.RequestWorker("id", blocking_time=0.01, _queue_size=2) - readonly_url1 = self.get_readonly_url("no-file1") - rt.queue.put(readonly_url1) - rt.step_next() - self.assertEqual(readonly_url1, rt.stats[0][0]) - self.assertEqual(False, rt.stats[0][1]) - - def test_no_server(self): - s = socket.socket() - # Bind to a port, but don't listen on it - s.bind(("localhost", 0)) - url = "http://%s:%s/path" % s.getsockname() - rt = load_test.RequestWorker("id", blocking_time=0.01, _queue_size=2) - rt.queue.put(url) - rt.step_next() - self.assertEqual((url, False), rt.stats[0][:2]) - - -class NoActionScript(load_test.ActionScript): - _worker_class = NoopRequestWorker - _default_blocking_timeout = 0.01 - - -class TestActionScriptInfrastructure(tests.TestCase): - def test_parse_requires_parameters_and_requests(self): - self.assertRaises(ValueError, load_test.ActionScript.parse, "") - self.assertRaises(ValueError, load_test.ActionScript.parse, "{}") - self.assertRaises( - ValueError, load_test.ActionScript.parse, '{"parameters": {}}' - ) - self.assertRaises(ValueError, load_test.ActionScript.parse, '{"requests": []}') - load_test.ActionScript.parse( - '{"parameters": {}, "requests": [], "comment": "section"}' - ) - script = load_test.ActionScript.parse(empty_script) - self.assertIsNot(None, script) - - def test_parse_default_base_url(self): - script = load_test.ActionScript.parse(empty_script) - self.assertEqual("http://localhost:8080", script.base_url) - - def test_parse_find_base_url(self): - script = load_test.ActionScript.parse( - '{"parameters": {"base_url": "http://example.com"}, "requests": []}' - ) - self.assertEqual("http://example.com", script.base_url) - - def test_parse_default_blocking_timeout(self): - script = load_test.ActionScript.parse(empty_script) - self.assertEqual(60.0, script.blocking_timeout) - - def test_parse_find_blocking_timeout(self): - script = load_test.ActionScript.parse( - '{"parameters": {"blocking_timeout": 10.0}, "requests": []}' - ) - self.assertEqual(10.0, script.blocking_timeout) - - def test_parse_finds_requests(self): - script = load_test.ActionScript.parse( - '{"parameters": {}, "requests": [' - ' {"relpath": "/foo"},' - ' {"relpath": "/bar"}' - " ]}" - ) - self.assertEqual(2, len(script._requests)) - self.assertEqual("/foo", script._requests[0].relpath) - self.assertEqual("/bar", script._requests[1].relpath) - - def test__get_worker(self): - script = NoActionScript() - # If an id is found, then we should create it - self.assertEqual({}, script._threads) - worker = script._get_worker("id") - self.assertIn("id", script._threads) - # We should have set the blocking timeout - self.assertEqual(script.blocking_timeout / 10.0, worker.blocking_time) - - # Another request will return the identical object - self.assertIs(worker, script._get_worker("id")) - - # And the stop event will stop the thread - script.stop_and_join() - - def test__full_url(self): - script = NoActionScript() - self.assertEqual("http://localhost:8080/path", script._full_url("/path")) - self.assertEqual( - "http://localhost:8080/path/to/foo", script._full_url("/path/to/foo") - ) - script.base_url = "http://example.com" - self.assertEqual( - "http://example.com/path/to/foo", script._full_url("/path/to/foo") - ) - script.base_url = "http://example.com/base" - self.assertEqual( - "http://example.com/base/path/to/foo", script._full_url("/path/to/foo") - ) - script.base_url = "http://example.com" - self.assertEqual("http://example.com:8080/path", script._full_url(":8080/path")) - - def test_single_threaded(self): - script = NoActionScript.parse("""{ - "parameters": {"base_url": ""}, - "requests": [ - {"thread": "1", "relpath": "first"}, - {"thread": "1", "relpath": "second"}, - {"thread": "1", "relpath": "third"}, - {"thread": "1", "relpath": "fourth"} - ]}""") - script.run() - worker = script._get_worker("1") - self.assertEqual( - ["first", "second", "third", "fourth"], [s[0] for s in worker.stats] - ) - - def test_two_threads(self): - script = NoActionScript.parse("""{ - "parameters": {"base_url": ""}, - "requests": [ - {"thread": "1", "relpath": "first"}, - {"thread": "2", "relpath": "second"}, - {"thread": "1", "relpath": "third"}, - {"thread": "2", "relpath": "fourth"} - ]}""") - script.run() - worker = script._get_worker("1") - self.assertEqual(["first", "third"], [s[0] for s in worker.stats]) - worker = script._get_worker("2") - self.assertEqual(["second", "fourth"], [s[0] for s in worker.stats]) - - -class TestActionScriptIntegration(tests.TestCaseWithTransport): - def setUp(self): - super().setUp() - self.transport_readonly_server = http_server.HttpServer - - def test_full_integration(self): - self.build_tree(["first", "second", "third", "fourth"]) - url = self.get_readonly_url() - script = load_test.ActionScript.parse( - """{{ - "parameters": {{"base_url": "{}", "blocking_timeout": 2.0}}, - "requests": [ - {{"thread": "1", "relpath": "first"}}, - {{"thread": "2", "relpath": "second"}}, - {{"thread": "1", "relpath": "no-this"}}, - {{"thread": "2", "relpath": "or-this"}}, - {{"thread": "1", "relpath": "third"}}, - {{"thread": "2", "relpath": "fourth"}} - ]}}""".format(url) - ) - script.run() - worker = script._get_worker("1") - self.assertEqual( - [("first", True), ("no-this", False), ("third", True)], - [(s[0].rsplit("/", 1)[1], s[1]) for s in worker.stats], - ) - worker = script._get_worker("2") - self.assertEqual( - [("second", True), ("or-this", False), ("fourth", True)], - [(s[0].rsplit("/", 1)[1], s[1]) for s in worker.stats], - ) - - -class TestRunScript(tests.TestCaseWithTransport): - def setUp(self): - super().setUp() - self.transport_readonly_server = http_server.HttpServer - - def test_run_script(self): - self.build_tree(["file1", "file2", "file3", "file4"]) - url = self.get_readonly_url() - self.build_tree_contents( - [ - ( - "localhost.script", - """{{ - "parameters": {{"base_url": "{}", "blocking_timeout": 0.1}}, - "requests": [ - {{"thread": "1", "relpath": "file1"}}, - {{"thread": "2", "relpath": "file2"}}, - {{"thread": "1", "relpath": "file3"}}, - {{"thread": "2", "relpath": "file4"}} - ] -}}""".format(url), - ) - ] - ) - script = load_test.run_script("localhost.script") - worker = script._threads["1"][0] - self.assertEqual( - [("file1", True), ("file3", True)], - [(s[0].rsplit("/", 1)[1], s[1]) for s in worker.stats], - ) - worker = script._threads["2"][0] - self.assertEqual( - [("file2", True), ("file4", True)], - [(s[0].rsplit("/", 1)[1], s[1]) for s in worker.stats], - ) diff --git a/loggerhead/tests/test_revision_ui.py b/loggerhead/tests/test_revision_ui.py deleted file mode 100644 index 61707fbe..00000000 --- a/loggerhead/tests/test_revision_ui.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (C) 2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# - - -from .test_simple import BasicTests - - -class TestRevisionUI(BasicTests): - def test_authors_vs_committer(self): - self.createBranch() - self.tree.commit( - "First", - committer="Joe Example ", - revprops={ - "authors": "A Author \n" - "B Author " - }, - ) - app = self.setUpLoggerhead() - res = app.get("/revision/1") - # We would like to assert that Joe Example is connected to Committer, - # and the Authors are connected. However, that requires asserting the - # exact HTML connections, which I wanted to avoid. - res.mustcontain("Committer", "Joe Example", "Author(s)", "A Author, B Author") - - def test_author_is_committer(self): - self.createBranch() - self.tree.commit("First", committer="Joe Example ") - app = self.setUpLoggerhead() - res = app.get("/revision/1") - # We would like to assert that Joe Example is connected to Committer, - # and the Authors are connected. However, that requires asserting the - # exact HTML connections, which I wanted to avoid. - res.mustcontain("Committer", "Joe Example") - self.assertNotIn(b"Author(s)", res.body) diff --git a/loggerhead/tests/test_simple.py b/loggerhead/tests/test_simple.py deleted file mode 100644 index 1998065a..00000000 --- a/loggerhead/tests/test_simple.py +++ /dev/null @@ -1,277 +0,0 @@ -# Copyright (C) 2007, 2008, 2009, 2011 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# - - -import json -import logging -import re -from html import escape -from io import BytesIO - -from breezy import config -from breezy.tests import TestCaseWithTransport -from paste.fixture import TestApp -from paste.httpexceptions import HTTPExceptionHandler, HTTPMovedPermanently - -from ..apps.branch import BranchWSGIApp -from ..apps.http_head import HeadMiddleware -from .fixtures import SampleBranch - - -class BasicTests(TestCaseWithTransport): - def setUp(self): - TestCaseWithTransport.setUp(self) - logging.basicConfig(level=logging.ERROR) - logging.getLogger("bzr").setLevel(logging.CRITICAL) - - def createBranch(self): - self.tree = self.make_branch_and_tree(".") - - def setUpLoggerhead(self, **kw): - branch_app = BranchWSGIApp(self.tree.branch, "", **kw).app - return TestApp(HTTPExceptionHandler(branch_app)) - - def assertOkJsonResponse(self, app, env): - start, content = consume_app(app, env) - self.assertEqual("200 OK", start[0]) - self.assertEqual("application/json", dict(start[1])["Content-Type"]) - self.assertEqual(None, start[2]) - json.loads(content.decode("UTF-8")) - - def make_branch_app(self, branch, **kw): - branch_app = BranchWSGIApp(branch, friendly_name="friendly-name", **kw) - branch_app._environ = { - "wsgi.url_scheme": "", - "SERVER_NAME": "", - "SERVER_PORT": "80", - } - branch_app._url_base = "" - return branch_app - - -class TestWithSimpleTree(BasicTests): - def setUp(self): - BasicTests.setUp(self) - self.sample_branch_fixture = SampleBranch(self) - - # XXX: This could be cleaned up more... -- mbp 2011-11-25 - self.useFixture(self.sample_branch_fixture) - self.tree = self.sample_branch_fixture.tree - self.path = self.sample_branch_fixture.path - self.filecontents = self.sample_branch_fixture.filecontents - self.msg = self.sample_branch_fixture.msg - - def test_public_private(self): - app = self.make_branch_app(self.tree.branch, private=True) - self.assertEqual(app.public_private_css(), "private") - app = self.make_branch_app(self.tree.branch) - self.assertEqual(app.public_private_css(), "public") - - def test_changes(self): - app = self.setUpLoggerhead() - res = app.get("/changes") - res.mustcontain(escape(self.msg)) - - def test_changes_for_file(self): - app = self.setUpLoggerhead() - res = app.get("/changes?filter_path=%s" % self.path) - res.mustcontain(escape(self.msg)) - - def test_changes_branch_from(self): - app = self.setUpLoggerhead(served_url="lp:loggerhead") - res = app.get("/changes") - self.assertIn("To get this branch, use:", res) - self.assertIn("lp:loggerhead", res) - - def test_changes_search(self): - app = self.setUpLoggerhead() - res = app.get("/changes", params={"q": "foo"}) - res.mustcontain("Sorry, no results found for your search.") - - def test_annotate(self): - app = self.setUpLoggerhead() - res = app.get("/annotate/1/%s" % self.path, params={}) - # If pygments is installed, it inserts with<' - # 'htmlspecialchars - # So we pre-filter the body, to make sure remove spans of that type. - body_no_span = re.sub(b'', b"", res.body) - body_no_span = body_no_span.replace(b"", b"") - for line in self.filecontents.splitlines(): - escaped = escape(line).encode("utf-8") - self.assertIn( - escaped, - body_no_span, - "did not find {!r} in {!r}".format(escaped, body_no_span), - ) - - def test_inventory(self): - app = self.setUpLoggerhead() - res = app.get("/files") - res.mustcontain("myfilename") - res = app.get("/files/") - res.mustcontain("myfilename") - res = app.get("/files/1") - res.mustcontain("myfilename") - res = app.get("/files/1/") - res.mustcontain("myfilename") - - def test_inventory_bad_rev_404(self): - app = self.setUpLoggerhead() - app.get("/files/200", status=404) - app.get("/files/invalid-revid", status=404) - - def test_inventory_bad_path_404(self): - app = self.setUpLoggerhead() - app.get("/files/1/hooha", status=404) - - def test_revision(self): - app = self.setUpLoggerhead() - res = app.get("/revision/1") - res.mustcontain(no=["anotherfile<"]) - res.mustcontain("anotherfile<") - res.mustcontain("myfilename") - - -class TestEmptyBranch(BasicTests): - """Test that an empty branch doesn't break""" - - def setUp(self): - BasicTests.setUp(self) - self.createBranch() - - def test_changes(self): - app = self.setUpLoggerhead() - res = app.get("/changes") - res.mustcontain("No revisions!") - - def test_inventory(self): - app = self.setUpLoggerhead() - res = app.get("/files") - res.mustcontain("No revisions!") - - -class TestHiddenBranch(BasicTests): - """ - Test that hidden branches aren't shown - FIXME: not tested that it doesn't show up on listings - """ - - def setUp(self): - BasicTests.setUp(self) - self.createBranch() - try: - locations = config.locations_config_filename() - except AttributeError: - from breezy import bedding - - locations = bedding.locations_config_path() - ensure_config_dir_exists = bedding.ensure_config_dir_exists - else: - ensure_config_dir_exists = config.ensure_config_dir_exists - ensure_config_dir_exists() - with open(locations, "w") as f: - f.write("[{}]\nhttp_serve = False".format(self.tree.branch.base)) - - def test_no_access(self): - app = self.setUpLoggerhead() - app.get("/changes", status=404) - - -class TestControllerRedirects(BasicTests): - """ - Test that a file under /files redirects to /view, - and a directory under /view redirects to /files. - """ - - def setUp(self): - BasicTests.setUp(self) - self.createBranch() - self.build_tree(("file", "folder/", "folder/file")) - self.tree.smart_add([]) - self.tree.commit("") - - def test_view_folder(self): - app = TestApp(BranchWSGIApp(self.tree.branch, "").app) - - e = self.assertRaises(HTTPMovedPermanently, app.get, "/view/head:/folder") - self.assertEqual(e.location(), "/files/head:/folder") - - def test_files_file(self): - app = TestApp(BranchWSGIApp(self.tree.branch, "").app) - - e = self.assertRaises(HTTPMovedPermanently, app.get, "/files/head:/folder/file") - self.assertEqual(e.location(), "/view/head:/folder/file") - e = self.assertRaises(HTTPMovedPermanently, app.get, "/files/head:/file") - self.assertEqual(e.location(), "/view/head:/file") - - -class TestHeadMiddleware(BasicTests): - def setUp(self): - BasicTests.setUp(self) - self.createBranch() - self.msg = "trivial commit message" - self.revid = self.tree.commit(message=self.msg) - - def setUpLoggerhead(self, **kw): - branch_app = BranchWSGIApp(self.tree.branch, "", **kw).app - return TestApp(HTTPExceptionHandler(HeadMiddleware(branch_app))) - - def test_get(self): - app = self.setUpLoggerhead() - res = app.get("/changes") - res.mustcontain(self.msg) - self.assertEqual("text/html", res.header("Content-Type")) - - def test_head(self): - app = self.setUpLoggerhead() - res = app.get("/changes", extra_environ={"REQUEST_METHOD": "HEAD"}) - self.assertEqual("text/html", res.header("Content-Type")) - self.assertEqualDiff(b"", res.body) - - -def consume_app(app, env): - body = BytesIO() - start = [] - - def start_response(status, headers, exc_info=None): - start.append((status, headers, exc_info)) - return body.write - - extra_content = list(app(env, start_response)) - body.writelines(extra_content) - return start[0], body.getvalue() - - -# class TestGlobalConfig(BasicTests): -# """ -# Test that global config settings are respected -# """ - -# def setUp(self): -# BasicTests.setUp(self) -# self.createBranch() -# config.GlobalConfig().set_user_option('http_version', 'True') - -# def test_setting_respected(self): -# FIXME: Figure out how to test this properly -# app = self.setUpLoggerhead() -# res = app.get('/changes', status=200) diff --git a/loggerhead/tests/test_templating.py b/loggerhead/tests/test_templating.py deleted file mode 100644 index 21bbe8d2..00000000 --- a/loggerhead/tests/test_templating.py +++ /dev/null @@ -1,14 +0,0 @@ -from ..zptsupport import load_template - -RENDERED = "\n\n%s\n\n\ -\n
Hello, %s
\n\n" - - -def test_template_lookup(): - template = load_template("loggerhead.tests.simple") - assert template - TITLE = "test" - NAME = "World" - info = dict(title=TITLE, name=NAME) - s = template.expand(**info) - assert s.startswith(RENDERED % (TITLE, NAME)) diff --git a/loggerhead/tests/test_util.py b/loggerhead/tests/test_util.py deleted file mode 100644 index f05f1a3a..00000000 --- a/loggerhead/tests/test_util.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright 2011 Canonical Ltd -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -from breezy import tests - -from ..util import html_escape, html_format - - -class TestHTMLEscaping(tests.TestCase): - def test_html_escape(self): - self.assertEqual("foo "'<>&", html_escape("foo \"'<>&")) - - def test_html_format(self): - self.assertEqual( - '<baz>&', - html_format('%s', "baz\"'", "&"), - ) diff --git a/loggerhead/util.py b/loggerhead/util.py deleted file mode 100644 index c42ec41f..00000000 --- a/loggerhead/util.py +++ /dev/null @@ -1,694 +0,0 @@ -# -# Copyright (C) 2008 Canonical Ltd. -# (Authored by Martin Albisetti -# Copyright (C) 2006 Goffredo Baroncelli -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# - -from __future__ import print_function - -import base64 -import datetime -import logging -import os -import re -import struct -import subprocess -import sys -import threading -import time -from xml.etree import ElementTree as ET - -import bleach -from breezy import urlutils - -log = logging.getLogger("loggerhead.controllers") - - -def fix_year(year): - if year < 70: - year += 2000 - if year < 100: - year += 1900 - return year - - -# Display of times. - -# date_day -- just the day -# date_time -- full date with time (UTC) -# -# approximatedate -- for use in tables -# -# approximatedate return an elementtree Element -# with the full date (UTC) in a tooltip. - - -def date_day(value): - return value.strftime("%Y-%m-%d") - - -def date_time(value): - if value is not None: - # Note: this assumes that the value is UTC in some fashion. - return value.strftime("%Y-%m-%d %H:%M:%S UTC") - else: - return "N/A" - - -def _approximatedate(date): - if date is None: - return "Never" - delta = datetime.datetime.utcnow() - date - future = delta < datetime.timedelta(0, 0, 0) - delta = abs(delta) - years = delta.days // 365 - months = delta.days // 30 # This is approximate. - days = delta.days - hours = delta.seconds // 3600 - minutes = (delta.seconds - (3600 * hours)) / 60 - seconds = delta.seconds % 60 - result = "" - if future: - result += "in " - if years != 0: - amount = years - unit = "year" - elif months != 0: - amount = months - unit = "month" - elif days != 0: - amount = days - unit = "day" - elif hours != 0: - amount = hours - unit = "hour" - elif minutes != 0: - amount = minutes - unit = "minute" - else: - amount = seconds - unit = "second" - if amount != 1: - unit += "s" - result += "%s %s" % (int(amount), unit) - if not future: - result += " ago" - return result - - -def _wrap_with_date_time_title(date, formatted_date): - elem = ET.Element("span") - elem.text = formatted_date - elem.set("title", date_time(date)) - return elem - - -def approximatedate(date): - # FIXME: Returns an object instead of a string - return _wrap_with_date_time_title(date, _approximatedate(date)) - - -class Container(object): - """ - Convert a dict into an object with attributes. - """ - - def __init__(self, _dict=None, **kw): - self._properties = {} - if _dict is not None: - for key, value in _dict.items(): - setattr(self, key, value) - for key, value in kw.items(): - setattr(self, key, value) - - def __repr__(self): - out = "{ " - for key, value in self.__dict__.items(): - if key.startswith("_") or ( - getattr(self.__dict__[key], "__call__", None) is not None - ): - continue - out += "%r => %r, " % (key, value) - out += "}" - return out - - def __getattr__(self, attr): - """Used for handling things that aren't already available.""" - if attr.startswith("_") or attr not in self._properties: - raise AttributeError("No attribute: %s" % (attr,)) - val = self._properties[attr](self, attr) - setattr(self, attr, val) - return val - - def _set_property(self, attr, prop_func): - """Set a function that will be called when an attribute is desired. - - We will cache the return value, so the function call should be - idempotent. We will pass 'self' and the 'attr' name when triggered. - """ - if attr.startswith("_"): - raise ValueError("Cannot create properties that start with _") - self._properties[attr] = prop_func - - -def trunc(text, limit=10): - if len(text) <= limit: - return text - return text[:limit] + "..." - - -STANDARD_PATTERN = re.compile(r"^(.*?)\s*<(.*?)>\s*$") -EMAIL_PATTERN = re.compile(r"[-\w\d\+_!%\.]+@[-\w\d\+_!%\.]+") - - -def hide_email(email): - """ - try to obscure any email address in a bazaar committer's name. - """ - m = STANDARD_PATTERN.search(email) - if m is not None: - name = m.group(1) - email = m.group(2) - return name - m = EMAIL_PATTERN.search(email) - if m is None: - # can't find an email address in here - return email - username, domain = m.group(0).split("@") - domains = domain.split(".") - if len(domains) >= 2: - return "%s at %s" % (username, domains[-2]) - return "%s at %s" % (username, domains[0]) - - -def hide_emails(emails): - """ - try to obscure any email address in a list of bazaar committers' names. - """ - result = [] - for email in emails: - result.append(hide_email(email)) - return result - - -# only do this if unicode turns out to be a problem -# _BADCHARS_RE = re.compile(ur'[\u007f-\uffff]') - -# Can't be a dict; & needs to be done first. -html_entity_subs = [ - ("&", "&"), - ('"', """), - ("'", "'"), # ' is defined in XML, but not HTML. - (">", ">"), - ("<", "<"), -] - - -def html_escape(s): - """Transform dangerous (X)HTML characters into entities. - - Like cgi.escape, except also escaping \" and '. This makes it safe to use - in both attribute and element content. - - If you want to safely fill a format string with escaped values, use - html_format instead - """ - for char, repl in html_entity_subs: - s = s.replace(char, repl) - return s - - -def html_format(template, *args): - """Safely format an HTML template string, escaping the arguments. - - The template string must not be user-controlled; it will not be escaped. - """ - return template % tuple(html_escape(arg) for arg in args) - - -# FIXME: get rid of this method; use fixed_width() and avoid XML(). - - -def html_clean(s): - """ - clean up a string for html display. expand any tabs, encode any html - entities, and replace spaces with ' '. this is primarily for use - in displaying monospace text. - """ - s = html_escape(s.expandtabs()) - s = s.replace(" ", " ") - return s - - -NONBREAKING_SPACE = "\N{NO-BREAK SPACE}" - - -def fill_div(s): - """ - CSS is stupid. In some cases we need to replace an empty value with - a non breaking space ( ). There has to be a better way of doing this. - - return: the same value received if not empty, and a ' ' if it is. - """ - if s is None: - return " " - elif isinstance(s, int): - return s - elif not s.strip(): - return " " - elif isinstance(s, bytes): - try: - s = s.decode("utf-8") - except UnicodeDecodeError: - s = s.decode("iso-8859-15") - return s - elif isinstance(s, str): - return s - else: - return repr(s) - - -def fixed_width(s): - """ - expand tabs and turn spaces into "non-breaking spaces", so browsers won't - chop up the string. - """ - if not isinstance(s, str): - # this kinda sucks. file contents are just binary data, and no - # encoding metadata is stored, so we need to guess. this is probably - # okay for most code, but for people using things like KOI-8, this - # will display gibberish. we have no way of detecting the correct - # encoding to use. - try: - s = s.decode("utf-8") - except UnicodeDecodeError: - s = s.decode("iso-8859-15") - - s = html_escape(s).expandtabs().replace(" ", NONBREAKING_SPACE) - - return bleach.clean(s).replace("\n", "
") - - -def fake_permissions(kind, executable): - # fake up unix-style permissions given only a "kind" and executable bit - if kind == "directory": - return "drwxr-xr-x" - if executable: - return "-rwxr-xr-x" - return "-rw-r--r--" - - -def b64(s): - s = base64.encodestring(s).replace("\n", "") - while (len(s) > 0) and (s[-1] == "="): - s = s[:-1] - return s - - -def uniq(uniqs, s): - """ - turn a potentially long string into a unique smaller string. - """ - if s in uniqs: - return uniqs[s] - uniqs[type(None)] = next = uniqs.get(type(None), 0) + 1 - x = struct.pack(">I", next) - while (len(x) > 1) and (x[0] == "\x00"): - x = x[1:] - uniqs[s] = b64(x) - return uniqs[s] - - -KILO = 1024 -MEG = 1024 * KILO -GIG = 1024 * MEG -P95_MEG = int(0.9 * MEG) -P95_GIG = int(0.9 * GIG) - - -def human_size(size, min_divisor=0): - size = int(size) - if (size == 0) and (min_divisor == 0): - return "Empty" - if (size < 1024) and (min_divisor == 0): - return str(size) + " bytes" - - if (size >= P95_GIG) or (min_divisor >= GIG): - divisor = GIG - elif (size >= P95_MEG) or (min_divisor >= MEG): - divisor = MEG - else: - divisor = KILO - - dot = size % divisor - base = size - dot - dot = dot * 10 // divisor - base //= divisor - if dot >= 10: - base += 1 - dot -= 10 - - out = str(base) - if (base < 100) and (dot != 0): - out += ".%d" % (dot,) - if divisor == KILO: - out += " KB" - elif divisor == MEG: - out += " MB" - elif divisor == GIG: - out += " GB" - return out - - -def local_path_from_url(url): - """Convert Bazaar URL to local path, ignoring readonly+ prefix""" - readonly_prefix = "readonly+" - if url.startswith(readonly_prefix): - url = url[len(readonly_prefix) :] - return urlutils.local_path_from_url(url) - - -def fill_in_navigation(navigation): - """ - given a navigation block (used by the template for the page header), fill - in useful calculated values. - """ - if navigation.revid in navigation.revid_list: # XXX is this always true? - navigation.position = navigation.revid_list.index(navigation.revid) - else: - navigation.position = 0 - navigation.count = len(navigation.revid_list) - navigation.page_position = navigation.position // navigation.pagesize + 1 - navigation.page_count = ( - len(navigation.revid_list) + (navigation.pagesize - 1) - ) // navigation.pagesize - - def get_offset(offset): - if (navigation.position + offset < 0) or ( - navigation.position + offset > navigation.count - 1 - ): - return None - return navigation.revid_list[navigation.position + offset] - - navigation.last_in_page_revid = get_offset(navigation.pagesize - 1) - navigation.prev_page_revid = get_offset(-1 * navigation.pagesize) - navigation.next_page_revid = get_offset(1 * navigation.pagesize) - prev_page_revno = navigation.history.get_revno(navigation.prev_page_revid) - next_page_revno = navigation.history.get_revno(navigation.next_page_revid) - start_revno = navigation.history.get_revno(navigation.start_revid) - - params = {"filter_path": navigation.filter_path} - if getattr(navigation, "query", None) is not None: - params["q"] = navigation.query - - if getattr(navigation, "start_revid", None) is not None: - params["start_revid"] = start_revno - - if navigation.prev_page_revid: - navigation.prev_page_url = navigation.branch.context_url( - [navigation.scan_url, prev_page_revno], **params - ) - if navigation.next_page_revid: - navigation.next_page_url = navigation.branch.context_url( - [navigation.scan_url, next_page_revno], **params - ) - - -def directory_breadcrumbs(path, is_root, view): - """ - Generate breadcrumb information from the directory path given - - The path given should be a path up to any branch that is currently being - served - - Arguments: - path -- The path to convert into breadcrumbs - is_root -- Whether or not loggerhead is serving a branch at its root - view -- The type of view we are showing (files, changes etc) - """ - # Is our root directory itself a branch? - if is_root: - breadcrumbs = [ - { - "dir_name": path, - "path": "", - "suffix": view, - } - ] - else: - # Create breadcrumb trail for the path leading up to the branch - breadcrumbs = [ - { - "dir_name": "(root)", - "path": "", - "suffix": "", - } - ] - if path != "/": - dir_parts = path.strip("/").split("/") - for index, dir_name in enumerate(dir_parts): - breadcrumbs.append( - { - "dir_name": dir_name, - "path": "/".join(dir_parts[: index + 1]), - "suffix": "", - } - ) - # If we are not in the directory view, the last crumb is a branch, - # so we need to specify a view - if view != "directory": - breadcrumbs[-1]["suffix"] = "/" + view - return breadcrumbs - - -def branch_breadcrumbs(path, tree, view): - """ - Generate breadcrumb information from the branch path given - - The path given should be a path that exists within a branch - - Arguments: - path -- The path to convert into breadcrumbs - tree -- Tree to get file information from - view -- The type of view we are showing (files, changes etc) - """ - dir_parts = path.strip("/").split("/") - inner_breadcrumbs = [] - for index, dir_name in enumerate(dir_parts): - inner_breadcrumbs.append( - { - "dir_name": dir_name, - "path": "/".join(dir_parts[: index + 1]), - "suffix": "/" + view, - } - ) - return inner_breadcrumbs - - -def decorator(unbound): - def new_decorator(f): - g = unbound(f) - g.__name__ = f.__name__ - g.__doc__ = f.__doc__ - g.__dict__.update(f.__dict__) - return g - - new_decorator.__name__ = unbound.__name__ - new_decorator.__doc__ = unbound.__doc__ - new_decorator.__dict__.update(unbound.__dict__) - return new_decorator - - -@decorator -def lsprof(f): - def _f(*a, **kw): - import cPickle - - from .loggerhead.lsprof import profile - - z = time.time() - ret, stats = profile(f, *a, **kw) - log.debug( - "Finished profiled %s in %d msec." - % (f.__name__, int((time.time() - z) * 1000)) - ) - stats.sort() - stats.freeze() - now = time.time() - msec = int(now * 1000) % 1000 - timestr = time.strftime("%Y%m%d%H%M%S", time.localtime(now)) + ( - "%03d" % (msec,) - ) - filename = f.__name__ + "-" + timestr + ".lsprof" - cPickle.dump(stats, open(filename, "w"), 2) - return ret - - return _f - - -# just thinking out loud here... -# -# so, when browsing around, there are 5 pieces of context, most optional: -# - current revid -# current location along the navigation path (while browsing) -# - starting revid (start_revid) -# the current beginning of navigation (navigation continues back to -# the original revision) -- this defines an 'alternate mainline' -# when the user navigates into a branch. -# - filter_path -# if navigating the revisions that touched a file -# - q (query) -# if navigating the revisions that matched a search query -# - remember -# a previous revision to remember for future comparisons -# -# current revid is given on the url path. the rest are optional components -# in the url params. -# -# other transient things can be set: -# - compare_revid -# to compare one revision to another, on /revision only -# - sort -# for re-ordering an existing page by different sort - -t_context = threading.local() -_valid = ("start_revid", "filter_path", "q", "remember", "compare_revid", "sort") - - -def set_context(map): - t_context.map = dict((k, v) for (k, v) in map.items() if k in _valid) - - -def get_context(**overrides): - """ - Soon to be deprecated. - - - return a context map that may be overridden by specific values passed in, - but only contains keys from the list of valid context keys. - - if 'clear' is set, only the 'remember' context value will be added, and - all other context will be omitted. - """ - map = dict() - if overrides.get("clear", False): - map["remember"] = t_context.map.get("remember", None) - else: - map.update(t_context.map) - overrides = dict((k, v) for (k, v) in overrides.items() if k in _valid) - map.update(overrides) - return map - - -class Reloader(object): - """ - This class wraps all paste.reloader logic. All methods are @classmethod. - """ - - _reloader_environ_key = "PYTHON_RELOADER_SHOULD_RUN" - - @classmethod - def _turn_sigterm_into_systemexit(cls): - """ - Attempts to turn a SIGTERM exception into a SystemExit exception. - """ - try: - import signal - except ImportError: - return - - def handle_term(signo, frame): - raise SystemExit - - signal.signal(signal.SIGTERM, handle_term) - - @classmethod - def is_installed(cls): - return os.environ.get(cls._reloader_environ_key) - - @classmethod - def install(cls): - from paste import reloader - - reloader.install(int(1)) - - @classmethod - def restart_with_reloader(cls): - """Based on restart_with_monitor from paste.script.serve.""" - print("Starting subprocess with file monitor") - while True: - args = [sys.executable] + sys.argv - new_environ = os.environ.copy() - new_environ[cls._reloader_environ_key] = "true" - proc = None - try: - try: - cls._turn_sigterm_into_systemexit() - proc = subprocess.Popen(args, env=new_environ) - exit_code = proc.wait() - proc = None - except KeyboardInterrupt: - print("^C caught in monitor process") - return 1 - finally: - if proc is not None and getattr(os, "kill", None) is not None: - import signal - - try: - os.kill(proc.pid, signal.SIGTERM) - except (OSError, IOError): - pass - - # Reloader always exits with code 3; but if we are - # a monitor, any exit code will restart - if exit_code != 3: - return exit_code - print("-" * 20, "Restarting", "-" * 20) - - -def convert_file_errors(application): - """WSGI wrapper to convert some file errors to Paste exceptions""" - - def new_application(environ, start_response): - try: - return application(environ, start_response) - except (IOError, OSError) as e: - import errno - - from paste import httpexceptions - - if e.errno == errno.ENOENT: - raise httpexceptions.HTTPNotFound() - elif e.errno == errno.EACCES: - raise httpexceptions.HTTPForbidden() - else: - raise - - return new_application - - -def convert_to_json_ready(obj): - if isinstance(obj, Container): - d = obj.__dict__.copy() - del d["_properties"] - return d - elif isinstance(obj, bytes): - return obj.decode("UTF-8") - elif isinstance(obj, datetime.datetime): - return tuple(obj.utctimetuple()) - raise TypeError(repr(obj) + " is not JSON serializable") diff --git a/loggerhead/wholehistory.py b/loggerhead/wholehistory.py deleted file mode 100644 index 396341d4..00000000 --- a/loggerhead/wholehistory.py +++ /dev/null @@ -1,89 +0,0 @@ -# -# Copyright (C) 2008, 2009 Canonical Ltd. -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# -"""Cache the whole history data needed by loggerhead about a branch.""" - -import logging -import time - -from breezy.revision import NULL_REVISION, is_null -from breezy.tsort import merge_sort - - -def _strip_NULL_ghosts(revision_graph): - """ - Copied over from breezy meant as a temporary workaround for - deprecated methods. - """ - # Filter ghosts, and null: - if NULL_REVISION in revision_graph: - del revision_graph[NULL_REVISION] - for key, parents in revision_graph.items(): - revision_graph[key] = tuple( - parent for parent in parents if parent in revision_graph - ) - return revision_graph - - -def compute_whole_history_data(branch): - """Compute _rev_info and _rev_indices for a branch. - - See History.__doc__ for what these data structures mean. - """ - z = time.time() - - last_revid = branch.last_revision() - - log = logging.getLogger("loggerhead.%s" % (branch.get_config().get_nickname(),)) - - graph = branch.repository.get_graph() - parent_map = dict( - (key, value) - for key, value in graph.iter_ancestry([last_revid]) - if value is not None - ) - - _revision_graph = _strip_NULL_ghosts(parent_map) - - _rev_info = [] - _rev_indices = {} - - if is_null(last_revid): - _merge_sort = [] - else: - _merge_sort = merge_sort(_revision_graph, last_revid, generate_revno=True) - - for info in _merge_sort: - seq, revid, merge_depth, revno, end_of_merge = info - revno_str = ".".join(str(n) for n in revno) - parents = _revision_graph[revid] - _rev_indices[revid] = len(_rev_info) - _rev_info.append( - [(seq, revid, merge_depth, revno_str, end_of_merge), (), parents] - ) - - for revid in _revision_graph.keys(): - if _rev_info[_rev_indices[revid]][0][2] == 0: - continue - for parent in _revision_graph[revid]: - c = _rev_info[_rev_indices[parent]] - if revid not in c[1]: - c[1] = c[1] + (revid,) - - log.info("built revision graph cache: %.3f secs" % (time.time() - z,)) - - return (_rev_info, _rev_indices) diff --git a/loggerhead/zptsupport.py b/loggerhead/zptsupport.py deleted file mode 100644 index 75b2d083..00000000 --- a/loggerhead/zptsupport.py +++ /dev/null @@ -1,71 +0,0 @@ -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335 USA -# -"""Support for Zope Page Templates using the Chameleon library.""" - -import os -import re - -from chameleon import PageTemplate -from importlib.resources import files - -_zpt_cache: dict[str, "TemplateWrapper"] = {} - - -def zpt(tfile): - tinstance = _zpt_cache.get(tfile) - stat = os.stat(tfile) - if tinstance is None or tinstance.stat != stat: - with open(tfile) as tf: - text = tf.read() - text = re.sub(r"\s*\n\s*", "\n", text) - text = re.sub(r"[ \t]+", " ", text) - tinstance = _zpt_cache[tfile] = TemplateWrapper(PageTemplate(text), tfile, stat) - return tinstance - - -class TemplateWrapper(object): - def __init__(self, template, filename, stat): - self.template = template - self.filename = filename - self.stat = stat - - def expand(self, **info): - return self.template(**info) - - def expand_into(self, f, **info): - f.write(self.template(**info).encode("UTF-8")) - - @property - def macros(self): - return self.template.macros - - -def load_template(classname): - """Searches for a template along the Python path. - - Template files must end in ".pt" and be in legitimate packages. - Templates are automatically checked for changes and reloaded as - necessary. - """ - divider = classname.rfind(".") - if divider > -1: - package = classname[0:divider] - basename = classname[divider + 1 :] - else: - raise ValueError("All templates must be in a package") - - tfile = str(files(package) / f"{basename}.pt") - return zpt(tfile) diff --git a/loggerheadd b/loggerheadd deleted file mode 100755 index ce1a6525..00000000 --- a/loggerheadd +++ /dev/null @@ -1,112 +0,0 @@ -#!/bin/sh -### BEGIN INIT INFO -# Required-Start: $local_fs $remote_fs $network -# Default-Start: 3 5 -# Default-Stop: 0 1 2 6 -# Short-Description: Loggerhead -# Description: Manage Loggerhead (a web viewer for projects in bazaar) -### END INIT INFO - - -# -# Configure this please: -# (Please stop loggerhead before changing the configuration, otherwise this -# script might not be able to kill loggerhead) -# - -LHUSER=loggerhead - -if [ `whoami` = "$LHUSER" ]; then - SUDO="" -else - SUDO="sudo -H -u $LHUSER" -fi - -# If loggerhead-serve is not in your path, you will need to specify the full path: -SERVE_BRANCHES_CMD=loggerhead-serve - -LOG_FOLDER=/var/log/loggerhead -LOG_FILE=$LOG_FOLDER/loggerheadd.log -URL_PREFIX=/loggerhead -PORT=8080 - -#please specify the base directory to serve: -BZRROOT=/bzrroot - -# You can add additional options to loggerhead-serve here: -START_CMD="$SERVE_BRANCHES_CMD --prefix=$URL_PREFIX --log-folder=$LOG_FOLDER --port=$PORT $BZRROOT" - - -# -# main part -# - -loggerhead_process(){ - $SUDO pgrep -fl "$START_CMD" -} - -loggerhead_status(){ - process=`loggerhead_process` - #echo "$process" - listening=`netstat -nl |grep -e ":$PORT "` - #echo "$listening" - if [ -z "$process" ]; then - echo "Loggerhead is *not* running." - else - echo "Loggerhead is running." - if [ -z "$listening" ]; then - echo "This server is *not* listening on port $PORT." - else - echo "This server is listening on port $PORT." - fi - fi -} - -start_loggerhead(){ - echo "Starting loggerhead. (See $LOG_FOLDER for details.)" - - # make sure the log folder is created - if [ ! -d $LOG_FOLDER ] - then - $SUDO mkdir -p $LOG_FOLDER - fi - echo "" > $LOG_FILE - $SUDO python3 $START_CMD > $LOG_FILE 2>&1 & - - #wait a little while of some logging to appear - log="" - for i in $(seq 1 3 30); do - log=`cat $LOG_FILE` - if [ -n "$log" ]; then - break - fi - sleep 0.3 - done - tail $LOG_FILE - loggerhead_status -} - -stop_loggerhead(){ - echo "Stopping loggerhead." - $SUDO pkill -f "$START_CMD" - loggerhead_status -} - -case "$1" in - start) - start_loggerhead - ;; - stop) - stop_loggerhead - ;; - status) - loggerhead_status - ;; - restart) - stop_loggerhead - start_loggerhead - ;; - *) - echo "Usage: loggerheadd { start | stop | status | restart }" - exit 1 -esac diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index cec97d8b..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = ["setuptools>=61.2"] -build-backend = "setuptools.build_meta" - -[project] -name = "loggerhead" -version = "2.0.3" -description = "Loggerhead is a web viewer for projects in bazaar" -license = {text = "GNU GPL v2 or later"} -maintainers = [{name = "Michael Hudson", email = "michael.hudson@canonical.com"}] -dependencies = [ - "Chameleon", - "Paste>=1.6", - "bleach", - "breezy>=3.1", - "packaging", - "pygments", -] - -[project.readme] -file = "README.rst" -content-type = "text/x-rst" - -[project.optional-dependencies] -proxied = ["PasteDeploy>=1.3"] -flup = ["flup"] -dev = ["fixtures", "testtools", "testscenarios"] - -[tool.setuptools] -script-files = ["loggerhead-serve"] -packages = [ - "breezy.plugins.loggerhead", - "loggerhead", - "loggerhead.apps", - "loggerhead.controllers", - "loggerhead.middleware", - "loggerhead.templates", -] -package-dir = {"breezy.plugins.loggerhead" = "."} -include-package-data = false - -[project.urls] -repository = "https://launchpad.net/loggerhead" - -[tool.setuptools.data-files] -"share/man/man1" = ["loggerhead-serve.1"] -"share/doc/loggerhead" = [ - "apache-loggerhead.conf", - "breezy.conf", - "loggerheadd", -] - -[tool.setuptools.package-data] -loggerhead = [ - "static/css/*.css", - "static/images/*", - "static/javascript/*.js", - "templates/*.pt", -] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2eb5befd..00000000 --- a/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -setuptools -Paste >= 1.6 -dulwich; python_version > "3.5" -dulwich <= 0.20.25; python_version <= "3.5" -testtools -breezy >= 3.1; python_version > "3.5" -breezy >= 3.1, < 3.2; python_version <= "3.5" -bleach; python_version > "3.5" -bleach < 4.0.0; python_version <= "3.5" -packaging < 21.0; python_version <= "3.5" -Pygments < 2.12; python_version <= "3.5" -Pygments; python_version > "3.5" -certifi < 2022.5.18; python_version <= "3.5" -fixtures < 4.0.0; python_version <= "3.5" -patiencediff < 0.2.3; python_version <= "3.5" diff --git a/setup.py b/setup.py deleted file mode 100755 index 4fd236c9..00000000 --- a/setup.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2008 Canonical Ltd. -# (Authored by Martin Albisetti ) -# 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 2 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, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -"""Loggerhead is a web viewer for projects in bazaar""" - -from setuptools import setup - -setup() diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 00000000..4335b01c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,490 @@ +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::{routing::get, Router}; +use chrono::{DateTime, Utc}; +use moka::sync::Cache; +use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; + +use breezyshim::branch::Branch; +use breezyshim::revisionid::RevisionId; + +use crate::cache::RevInfoDiskCache; +use crate::history::WholeHistory; +use crate::util::errors::AppError; + +/// Shared application state handed to every handler. +pub struct AppState { + /// Location (filesystem path or URL) of the branch served by this + /// router instance. In directory mode each sub-branch gets its own + /// nested `AppState`, so every handler can just read this without + /// knowing whether we're in single- or multi-branch mode. + pub root: String, + /// URL prefix under which this branch is served. Empty (`""`) in + /// single-branch mode; `"/"` in directory mode. Every + /// template-facing link computed by a controller is prefixed with + /// this so that output works behind an additional mount point. + pub url_prefix: String, + /// Whether `root` is a single branch or a directory of branches. + /// Only meaningful on the top-level AppState; the per-branch + /// AppState created for each nested directory mount is always + /// `ServeMode::Branch`. + pub serve_mode: ServeMode, + /// Cached whole-branch graph, invalidated when the branch tip changes. + pub whole_history_cache: Cache>, + /// Optional SQLite-backed persistent cache. + pub disk_cache: Option>, + /// Whether tarball exports are permitted. + pub export_tarballs: bool, + /// Filesystem directory containing CSS/JS/image assets (served under + /// `/static`). + pub static_dir: std::path::PathBuf, + /// Cached value of the branch-config `http_serve` flag. Filled on + /// first request via `http_serveable()` so subsequent requests + /// don't re-read the config. + pub http_serve_cache: std::sync::OnceLock, +} + +impl AppState { + pub fn new( + root: String, + disk_cache: Option>, + export_tarballs: bool, + static_dir: std::path::PathBuf, + user_dirs: bool, + trunk_dir: Option, + url_prefix: String, + ) -> Self { + let serve_mode = detect_serve_mode(&root, user_dirs, trunk_dir); + Self { + root, + url_prefix, + serve_mode, + whole_history_cache: Cache::new(10), + disk_cache, + export_tarballs, + static_dir, + http_serve_cache: std::sync::OnceLock::new(), + } + } + + /// Build a per-branch `AppState` for a sub-branch inside a + /// directory-mode deployment. Shares the disk cache and static-dir + /// with the parent; gets its own in-memory history cache. + pub fn nested(parent: &AppState, root: String, name: &str) -> Self { + Self { + root, + url_prefix: format!("{}/{name}", parent.url_prefix), + serve_mode: ServeMode::Branch, + whole_history_cache: Cache::new(10), + disk_cache: parent.disk_cache.clone(), + export_tarballs: parent.export_tarballs, + static_dir: parent.static_dir.clone(), + http_serve_cache: std::sync::OnceLock::new(), + } + } + + /// Resolve whether this branch may be served over HTTP. On first + /// call, opens the branch (GIL-bound) to read the + /// `http_serve` user option; result is cached. + pub fn http_serveable(&self) -> Result { + if let Some(v) = self.http_serve_cache.get() { + return Ok(*v); + } + let root = self.root.clone(); + let computed = tokio::task::block_in_place(move || -> Result { + let branch = crate::breezy::open_branch(&root)?; + Ok(crate::breezy::is_http_serveable(&branch)) + })?; + // If another thread won the race it's fine: OnceLock::set just + // fails with Err(existing); we use `get_or_init` semantics. + let _ = self.http_serve_cache.set(computed); + Ok(computed) + } + + /// Produce an absolute URL for `path` under this branch's prefix. + pub fn url(&self, path: &str) -> String { + if self.url_prefix.is_empty() { + path.to_string() + } else { + format!("{}{}", self.url_prefix, path) + } + } + + /// Fetch the whole-history for `branch`'s current tip, consulting the + /// in-memory LRU and optional disk cache, computing + storing on miss. + /// Must be called from a blocking context (holds the GIL). + pub fn load_whole_history(&self, branch: &dyn Branch) -> Result, AppError> { + let tip = branch.last_revision(); + if let Some(w) = self.whole_history_cache.get(&tip) { + return Ok(w); + } + // Scope disk-cache entries by branch root so a single shared cache + // file can back multiple branches (directory / user-dirs mode). + let branch_key = self.root.as_bytes(); + if let Some(from_disk) = self + .disk_cache + .as_ref() + .and_then(|d| d.get_whole_history(branch_key, &tip)) + { + tracing::debug!("whole_history: disk cache hit"); + let w = Arc::new(from_disk); + self.whole_history_cache.insert(tip, w.clone()); + return Ok(w); + } + tracing::debug!("whole_history: computing (miss on memory & disk)"); + let computed = WholeHistory::compute(branch)?; + if let Some(d) = self.disk_cache.as_ref() { + d.set_whole_history(branch_key, &tip, &computed); + } + let w = Arc::new(computed); + self.whole_history_cache.insert(tip, w.clone()); + Ok(w) + } +} + +/// Permanent redirect to `/changes`, matching Python loggerhead's root +/// behaviour (see `apps/branch.py::lookup_app`). +async fn root_redirect( + axum::extract::State(state): axum::extract::State>, +) -> Redirect { + Redirect::permanent(&state.url("/changes")) +} + +/// Trivial liveness probe, matching Python loggerhead's `/health`. +async fn health() -> &'static str { + "ok" +} + +/// Middleware that 404s a request when the branch's config has +/// `http_serve=false`. Runs on every request to a per-branch router. +/// The check is done inside `spawn_blocking` because it touches +/// breezy. Uses a small per-state cache (an `OnceLock`) so repeated +/// requests don't re-read the config. +async fn http_serve_layer( + State(state): State>, + req: Request, + next: Next, +) -> Response { + let allowed = state.http_serveable(); + let allowed = match allowed { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "failed to read http_serve; allowing by default"); + true + } + }; + if !allowed { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + next.run(req).await +} + +/// Returns the most recent tip-timestamp known for this branch, if any. +/// We consult the in-memory LRU by picking the maximum timestamp across +/// currently-cached `WholeHistory` entries. Usually there's just one. +fn cached_last_modified(state: &AppState) -> Option { + let mut best: Option = None; + for (_, wh) in state.whole_history_cache.iter() { + if let Some(t) = wh.tip_timestamp { + best = Some(best.map_or(t, |b| b.max(t))); + } + } + best +} + +/// Axum middleware that adds `Last-Modified` to successful responses +/// and short-circuits to 304 Not Modified when the client's +/// `If-Modified-Since` is at least as new as the branch tip. Only +/// attached to per-branch HTML routers. +async fn last_modified_layer( + State(state): State>, + req: Request, + next: Next, +) -> Response { + let ims = req + .headers() + .get(header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + .and_then(|s| httpdate::parse_http_date(s).ok()); + let last_ts = cached_last_modified(&state); + + if let (Some(ims), Some(ts)) = (ims, last_ts) { + let tip_secs = ts as i64; + let ims_secs = ims + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if ims_secs >= tip_secs { + return StatusCode::NOT_MODIFIED.into_response(); + } + } + + let mut resp = next.run(req).await; + if resp.status().is_success() { + if let Some(ts) = last_ts { + if let Some(dt) = DateTime::::from_timestamp(ts as i64, 0) { + let rfc = dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); + if let Ok(v) = HeaderValue::from_str(&rfc) { + resp.headers_mut().insert(header::LAST_MODIFIED, v); + } + } + } + } + resp +} + +/// Serve mode determined at startup. +#[derive(Clone, Debug)] +pub enum ServeMode { + /// `root` points directly at a single branch. + Branch, + /// `root` is a directory containing branches at `/`. + /// `/` shows a listing; `//…` drills into the branch. + Directory, + /// `root` is a directory structured as `//`. + /// Each user branch is exposed at `/~//…`. + /// Optionally a `trunk_dir` subdir under `` hosts + /// "common" branches served at `//…` without a + /// `~user` prefix. + UserDirs { + /// Subdirectory under `` whose children are branches + /// served at `//`. `None` means no trunk section. + trunk_dir: Option, + }, +} + +/// Detect at startup whether `root` is itself a branch or a directory +/// containing branches. The `user_dirs_config` option forces UserDirs +/// mode regardless of whether `root` itself is openable as a branch. +/// Errors fall back to Branch mode — the per-branch handlers will +/// produce a sensible error on the first request. +pub fn detect_serve_mode(root: &str, user_dirs: bool, trunk_dir: Option) -> ServeMode { + if user_dirs { + return ServeMode::UserDirs { trunk_dir }; + } + if crate::breezy::open_branch(root).is_ok() { + ServeMode::Branch + } else if std::path::Path::new(root).is_dir() { + ServeMode::Directory + } else { + ServeMode::Branch + } +} + +pub fn build_router(state: Arc) -> Router { + match &state.serve_mode { + ServeMode::Directory => build_directory_router(state), + ServeMode::Branch => build_branch_router(state), + ServeMode::UserDirs { trunk_dir } => { + let trunk = trunk_dir.clone(); + build_user_dirs_router(state, trunk) + } + } +} + +fn build_directory_router(state: Arc) -> Router { + use crate::controllers::directory; + let static_dir = state.static_dir.clone(); + let subdirs = list_subdirs(&state.root); + let top = Router::new() + .route("/", get(directory::show)) + .with_state(state.clone()); + let mut router: Router<()> = top + .nest_service("/static", ServeDir::new(&static_dir)) + .route("/favicon.ico", get(favicon_redirect)); + // Discover branches once at startup and mount each under /. + // New branches added to the directory after startup will 404 until + // the server restarts — acceptable for a loggerhead deployment. + // Branches whose config sets `http_serve=false` are skipped here + // (mirrors Python's per-branch gate). + for name in subdirs { + let child_root = format!("{}/{}", state.root.trim_end_matches('/'), name); + match crate::breezy::open_branch(&child_root) { + Ok(b) if !crate::breezy::is_http_serveable(&b) => { + tracing::info!(branch = %name, "skipping: http_serve=false"); + continue; + } + Ok(_) => {} + Err(_) => continue, + } + let child_state = Arc::new(AppState::nested(&state, child_root, &name)); + let branch_router = build_branch_router_inner(child_state); + router = router.nest(&format!("/{name}"), branch_router); + } + router.layer(TraceLayer::new_for_http()) +} + +fn build_user_dirs_router(state: Arc, trunk_dir: Option) -> Router { + use crate::controllers::directory; + let static_dir = state.static_dir.clone(); + let top = Router::new() + .route("/", get(directory::show)) + .with_state(state.clone()); + let mut router: Router<()> = top + .nest_service("/static", ServeDir::new(&static_dir)) + .route("/favicon.ico", get(favicon_redirect)); + + // ~user/branch branches. Each user directory under holds + // branches; each branch is mounted at /~//. + let root_trimmed = state.root.trim_end_matches('/'); + for user in list_subdirs(&state.root) { + // Users whose name starts with `.` are skipped by list_subdirs, + // but also skip the trunk_dir here to avoid double-mounting. + if Some(&user) == trunk_dir.as_ref() { + continue; + } + let user_root = format!("{root_trimmed}/{user}"); + for branch_name in list_subdirs(&user_root) { + let child_root = format!("{user_root}/{branch_name}"); + match crate::breezy::open_branch(&child_root) { + Ok(b) if !crate::breezy::is_http_serveable(&b) => { + tracing::info!( + branch = %format!("~{user}/{branch_name}"), + "skipping: http_serve=false" + ); + continue; + } + Ok(_) => {} + Err(_) => continue, + } + let nested_prefix = format!("/~{user}/{branch_name}"); + let child_state = Arc::new(AppState { + root: child_root, + url_prefix: format!("{}{nested_prefix}", state.url_prefix), + serve_mode: ServeMode::Branch, + whole_history_cache: Cache::new(10), + disk_cache: state.disk_cache.clone(), + export_tarballs: state.export_tarballs, + static_dir: state.static_dir.clone(), + http_serve_cache: std::sync::OnceLock::new(), + }); + let branch_router = build_branch_router_inner(child_state); + router = router.nest(&nested_prefix, branch_router); + } + } + + // Optional trunk area: // at //. + if let Some(trunk) = trunk_dir.as_deref() { + let trunk_root = format!("{root_trimmed}/{trunk}"); + for branch_name in list_subdirs(&trunk_root) { + let child_root = format!("{trunk_root}/{branch_name}"); + match crate::breezy::open_branch(&child_root) { + Ok(b) if !crate::breezy::is_http_serveable(&b) => { + tracing::info!( + branch = %branch_name, + "skipping trunk: http_serve=false" + ); + continue; + } + Ok(_) => {} + Err(_) => continue, + } + let child_state = Arc::new(AppState::nested(&state, child_root, &branch_name)); + let branch_router = build_branch_router_inner(child_state); + router = router.nest(&format!("/{branch_name}"), branch_router); + } + } + + router.layer(TraceLayer::new_for_http()) +} + +fn list_subdirs(root: &str) -> Vec { + let Ok(read) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut names: Vec = read + .filter_map(|e| e.ok()) + .filter_map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + if n.starts_with('.') { + None + } else if e.file_type().ok().is_some_and(|t| t.is_dir()) { + Some(n) + } else { + None + } + }) + .collect(); + names.sort_by_key(|s| s.to_lowercase()); + names +} + +fn build_branch_router(state: Arc) -> Router { + let static_dir = state.static_dir.clone(); + build_branch_router_inner(state) + .nest_service("/static", ServeDir::new(&static_dir)) + .route("/favicon.ico", get(favicon_redirect)) + .layer(TraceLayer::new_for_http()) +} + +/// Redirect `/favicon.ico` to the static asset so browsers that request +/// the conventional path don't hit a 404. Matches Python loggerhead's +/// top-level `favicon_app` handler. +async fn favicon_redirect() -> Redirect { + Redirect::permanent("/static/images/favicon.ico") +} + +// TODO: Python loggerhead also served `/.bzr/smart` (bzr+http smart protocol) +// via breezy.transport.http.wsgi. Not yet ported — users who need `bzr branch +// http://host/path` over the loggerhead port will need an external solution +// until this lands. + +/// Per-branch routes, without the top-level `/static` mount or tracing +/// layer. Used both directly in single-branch mode and from +/// `build_directory_router` at each `/` nesting point. +fn build_branch_router_inner(state: Arc) -> Router { + use crate::controllers::{ + annotate, atom, changelog, diff, download, filediff, inventory, json, revision, revlog, + search, view, + }; + Router::new() + .route("/", get(root_redirect)) + .route("/health", get(health)) + .route("/changes", get(changelog::show)) + .route("/changes/:revno", get(changelog::show_from)) + .route("/revision/:revid", get(revision::show)) + .route("/revision/:revid/*path", get(revision::show_with_path)) + .route("/diff/:new_revid", get(diff::show_one)) + .route("/diff/:new_revid/:old_revid", get(diff::show_two)) + .route( + "/+filediff/:new_revid/:old_revid/*path", + get(filediff::show), + ) + .route("/files", get(inventory::show_root)) + .route("/files/:revno", get(inventory::show_rev)) + .route("/files/:revno/*path", get(inventory::show_rev_path)) + .route("/view/:revno/*path", get(view::show)) + .route("/annotate/:revno/*path", get(annotate::show)) + .route("/download", get(download::show_bare)) + .route("/download/", get(download::show_bare)) + .route("/download/:revid/*path", get(download::show_file)) + .route("/tarball/:revid", get(download::tarball)) + .route("/atom", get(atom::show)) + .route("/+revlog/:revid", get(revlog::show)) + .route("/search", get(search::show)) + // +json variants: machine-readable versions of the HTML views. + .route("/+json/changes", get(json::changes)) + .route("/+json/changes/:revno", get(json::changes_from)) + .route("/+json/revision/:revid", get(json::revision)) + .route("/+json/files", get(json::files_root)) + .route("/+json/files/:revno", get(json::files_rev)) + .route("/+json/files/:revno/*path", get(json::files_rev_path)) + .route( + "/+json/+filediff/:new_revid/:old_revid/*path", + get(json::filediff), + ) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + last_modified_layer, + )) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + http_serve_layer, + )) + .with_state(state) +} diff --git a/src/breezy/mod.rs b/src/breezy/mod.rs new file mode 100644 index 00000000..91904d33 --- /dev/null +++ b/src/breezy/mod.rs @@ -0,0 +1,59 @@ +//! Thin ergonomics layer over breezyshim. +//! +//! All calls into breezyshim hold the Python GIL, so every public function +//! here is synchronous and intended to run on a blocking thread. Wrap calls +//! from async code in [`tokio::task::spawn_blocking`]. + +use std::path::Path; + +use breezyshim::branch::{Branch, GenericBranch}; +use url::Url; + +use crate::util::errors::AppError; + +/// True iff the branch's config permits being served over HTTP. +/// Mirrors Python loggerhead's +/// `branch.get_config().get_user_option_as_bool("http_serve", +/// default=True)` check. Errors reading the config fall back to +/// `true` (permissive) — matching Python's behaviour when the key +/// is missing. +pub fn is_http_serveable(branch: &dyn Branch) -> bool { + branch + .get_config() + .get_user_option_as_bool("http_serve", true) + .unwrap_or(true) +} + +/// Open a Breezy branch from a filesystem path or URL. +pub fn open_branch(location: &str) -> Result { + let url = match Url::parse(location) { + Ok(u) => u, + Err(_) => { + let abs = Path::new(location) + .canonicalize() + .map_err(|e| AppError::Other(format!("canonicalize {location}: {e}")))?; + Url::from_directory_path(&abs) + .map_err(|()| AppError::Other(format!("cannot turn {abs:?} into file:// URL")))? + } + }; + let branch = breezyshim::branch::open_as_generic(&url)?; + Ok(branch) +} + +/// Information about a branch suitable for the landing page. +pub struct BranchInfo { + pub nick: String, + pub last_revision: String, + pub revno: u32, +} + +pub fn branch_info(location: &str) -> Result { + let branch = open_branch(location)?; + let (revno, last_rev) = branch.last_revision_info(); + let nick = branch.name().unwrap_or_else(|| "".to_string()); + Ok(BranchInfo { + nick, + last_revision: last_rev.as_str().to_string(), + revno, + }) +} diff --git a/src/cache/disk.rs b/src/cache/disk.rs new file mode 100644 index 00000000..cfebca3a --- /dev/null +++ b/src/cache/disk.rs @@ -0,0 +1,358 @@ +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use breezyshim::revisionid::RevisionId; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::history::{RevInfo, WholeHistory}; +use crate::util::errors::AppError; + +/// SQLite-backed cache of expensive per-branch computations. +/// +/// Storage layout: +/// +/// ```sql +/// CREATE TABLE data ( +/// key BLOB PRIMARY KEY, -- logical key (e.g. "whole_history") +/// revid BLOB, -- branch tip this entry was computed for +/// data BLOB -- encoded payload +/// ); +/// ``` +/// +/// Entries become stale once the branch tip moves; the caller decides what +/// to do by passing the current tip to [`get_whole_history`] — a mismatch +/// produces `None`. +pub struct RevInfoDiskCache { + conn: Mutex, + #[allow(dead_code)] + path: PathBuf, +} + +impl RevInfoDiskCache { + /// Open (creating if necessary) a cache under `cache_path`. + /// + /// The directory is created on demand. The DB file is named `revinfo.sql` + /// inside that directory, matching the Python layout. + pub fn open(cache_path: &Path) -> Result { + std::fs::create_dir_all(cache_path) + .map_err(|e| AppError::Other(format!("mkdir {cache_path:?}: {e}")))?; + let path = cache_path.join("revinfo.sql"); + let conn = + Connection::open(&path).map_err(|e| AppError::Other(format!("open {path:?}: {e}")))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS data ( + key BLOB PRIMARY KEY, + revid BLOB, + data BLOB + ); + PRAGMA journal_mode = WAL;", + ) + .map_err(|e| AppError::Other(format!("init schema: {e}")))?; + Ok(Self { + conn: Mutex::new(conn), + path, + }) + } + + /// Look up a cached [`WholeHistory`]. Returns `None` if absent, if the + /// stored tip doesn't match `current_tip`, or if the payload can't be + /// decoded (which is treated as a miss — the cache will be overwritten + /// on the next `set`). + /// + /// `branch_key` scopes the entry to a particular branch so multiple + /// branches can share one cache file (directory / user-dirs mode). + pub fn get_whole_history( + &self, + branch_key: &[u8], + current_tip: &RevisionId, + ) -> Option { + let conn = self.conn.lock().ok()?; + let row: (Vec, Vec) = conn + .query_row( + "SELECT revid, data FROM data WHERE key = ?", + params![whole_history_key(branch_key)], + |r| Ok((r.get::<_, Vec>(0)?, r.get::<_, Vec>(1)?)), + ) + .optional() + .ok() + .flatten()?; + if row.0.as_slice() != current_tip.as_bytes() { + return None; + } + decode_whole_history(&row.1).ok() + } + + /// Store `wh` for `branch_key`, stamped with `tip`. Errors are logged + /// but not propagated — a cache-write failure is never fatal for a read. + pub fn set_whole_history(&self, branch_key: &[u8], tip: &RevisionId, wh: &WholeHistory) { + let blob = encode_whole_history(wh); + let Ok(conn) = self.conn.lock() else { return }; + if let Err(e) = conn.execute( + "INSERT INTO data (key, revid, data) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET revid = excluded.revid, data = excluded.data", + params![whole_history_key(branch_key), tip.as_bytes(), blob], + ) { + tracing::warn!(error = %e, "disk cache write failed"); + } + } +} + +fn whole_history_key(branch_key: &[u8]) -> Vec { + let mut v = Vec::with_capacity(WHOLE_HISTORY_PREFIX.len() + 1 + branch_key.len()); + v.extend_from_slice(WHOLE_HISTORY_PREFIX); + v.push(b':'); + v.extend_from_slice(branch_key); + v +} + +const WHOLE_HISTORY_PREFIX: &[u8] = b"whole_history"; + +/// Magic+version header so we can change the encoding later and reject +/// stale entries rather than misinterpret them. +/// +/// v1: entries only. +/// v2: entries + optional tip timestamp (for Last-Modified). +const MAGIC: &[u8; 4] = b"LHWH"; +const VERSION: u8 = 2; + +fn encode_whole_history(wh: &WholeHistory) -> Vec { + let mut out = Vec::with_capacity(256 + wh.entries.len() * 64); + out.extend_from_slice(MAGIC); + out.push(VERSION); + // v2: optional tip timestamp (flag byte + f64 bits). + match wh.tip_timestamp { + Some(t) => { + out.push(1); + out.extend_from_slice(&t.to_bits().to_le_bytes()); + } + None => out.push(0), + } + put_u64(&mut out, wh.entries.len() as u64); + for e in &wh.entries { + put_u64(&mut out, e.sequence as u64); + put_bytes(&mut out, e.revid.as_bytes()); + put_u64(&mut out, e.merge_depth as u64); + put_bytes(&mut out, e.revno.as_bytes()); + out.push(u8::from(e.end_of_merge)); + put_u64(&mut out, e.parents.len() as u64); + for p in &e.parents { + put_bytes(&mut out, p.as_bytes()); + } + put_u64(&mut out, e.children.len() as u64); + for c in &e.children { + put_bytes(&mut out, c.as_bytes()); + } + } + out +} + +fn decode_whole_history(blob: &[u8]) -> Result { + let mut cur = Cursor::new(blob); + let magic = cur.take(4)?; + if magic != MAGIC { + return Err(DecodeError::BadMagic); + } + let version = cur.take_u8()?; + if version != VERSION { + return Err(DecodeError::BadVersion(version)); + } + let tip_timestamp = match cur.take_u8()? { + 0 => None, + 1 => { + let bytes = cur.take(8)?; + let mut arr = [0u8; 8]; + arr.copy_from_slice(bytes); + Some(f64::from_bits(u64::from_le_bytes(arr))) + } + _ => return Err(DecodeError::BadMagic), // treat as corrupt + }; + let n = cur.take_u64()? as usize; + let mut entries = Vec::with_capacity(n); + let mut index = std::collections::HashMap::with_capacity(n); + for _ in 0..n { + let sequence = cur.take_u64()? as usize; + let revid = RevisionId::from(cur.take_bytes()?); + let merge_depth = cur.take_u64()? as usize; + let revno_bytes = cur.take_bytes()?; + let revno = + String::from_utf8(revno_bytes).map_err(|_| DecodeError::InvalidUtf8("revno"))?; + let end_of_merge = cur.take_u8()? != 0; + let pcount = cur.take_u64()? as usize; + let mut parents = Vec::with_capacity(pcount); + for _ in 0..pcount { + parents.push(RevisionId::from(cur.take_bytes()?)); + } + let ccount = cur.take_u64()? as usize; + let mut children = Vec::with_capacity(ccount); + for _ in 0..ccount { + children.push(RevisionId::from(cur.take_bytes()?)); + } + index.insert(revid.clone(), entries.len()); + entries.push(RevInfo { + sequence, + revid, + merge_depth, + revno, + end_of_merge, + parents, + children, + }); + } + Ok(WholeHistory { + entries, + index, + tip_timestamp, + }) +} + +fn put_u64(out: &mut Vec, v: u64) { + out.extend_from_slice(&v.to_le_bytes()); +} + +fn put_bytes(out: &mut Vec, b: &[u8]) { + put_u64(out, b.len() as u64); + out.extend_from_slice(b); +} + +struct Cursor<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> { + let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?; + if end > self.buf.len() { + return Err(DecodeError::Truncated); + } + let out = &self.buf[self.pos..end]; + self.pos = end; + Ok(out) + } + + fn take_u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn take_u64(&mut self) -> Result { + let bytes = self.take(8)?; + let mut arr = [0u8; 8]; + arr.copy_from_slice(bytes); + Ok(u64::from_le_bytes(arr)) + } + + fn take_bytes(&mut self) -> Result, DecodeError> { + let n = self.take_u64()? as usize; + Ok(self.take(n)?.to_vec()) + } +} + +#[derive(Debug, thiserror::Error)] +enum DecodeError { + #[error("truncated cache entry")] + Truncated, + #[error("bad magic")] + BadMagic, + #[error("unsupported cache version {0}")] + BadVersion(u8), + #[error("invalid utf-8 in {0}")] + InvalidUtf8(&'static str), +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::TempDir; + + fn sample_whole_history() -> WholeHistory { + let r1 = RevisionId::from(b"rev-1".to_vec()); + let r2 = RevisionId::from(b"rev-2".to_vec()); + let entries = vec![ + RevInfo { + sequence: 0, + revid: r2.clone(), + merge_depth: 0, + revno: "2".to_string(), + end_of_merge: true, + parents: vec![r1.clone()], + children: Vec::new(), + }, + RevInfo { + sequence: 1, + revid: r1.clone(), + merge_depth: 0, + revno: "1".to_string(), + end_of_merge: true, + parents: Vec::new(), + children: Vec::new(), + }, + ]; + let mut index = HashMap::new(); + index.insert(r2, 0); + index.insert(r1, 1); + WholeHistory { + entries, + index, + tip_timestamp: Some(1_700_000_000.0), + } + } + + #[test] + fn round_trip_encoding() { + let wh = sample_whole_history(); + let encoded = encode_whole_history(&wh); + let decoded = decode_whole_history(&encoded).unwrap(); + assert_eq!(decoded.entries.len(), wh.entries.len()); + for (a, b) in decoded.entries.iter().zip(wh.entries.iter()) { + assert_eq!(a.sequence, b.sequence); + assert_eq!(a.revid.as_bytes(), b.revid.as_bytes()); + assert_eq!(a.revno, b.revno); + assert_eq!(a.parents.len(), b.parents.len()); + } + } + + #[test] + fn disk_cache_get_set() { + let tmp = TempDir::new().unwrap(); + let cache = RevInfoDiskCache::open(tmp.path()).unwrap(); + let tip = RevisionId::from(b"rev-2".to_vec()); + let bk = b"branch-a".as_slice(); + assert!(cache.get_whole_history(bk, &tip).is_none()); + + let wh = sample_whole_history(); + cache.set_whole_history(bk, &tip, &wh); + let fetched = cache.get_whole_history(bk, &tip).unwrap(); + assert_eq!(fetched.entries.len(), 2); + + // Different tip → treated as miss. + let other = RevisionId::from(b"other".to_vec()); + assert!(cache.get_whole_history(bk, &other).is_none()); + + // Different branch key → also a miss. + assert!(cache.get_whole_history(b"branch-b", &tip).is_none()); + } + + #[test] + fn rejects_bad_magic() { + assert!(matches!( + decode_whole_history(b"ZZZZ\x01\x00\x00\x00\x00\x00\x00\x00\x00"), + Err(DecodeError::BadMagic) + )); + } + + #[test] + fn rejects_bad_version() { + let mut blob = MAGIC.to_vec(); + blob.push(99); + blob.extend_from_slice(&0u64.to_le_bytes()); + assert!(matches!( + decode_whole_history(&blob), + Err(DecodeError::BadVersion(99)) + )); + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 00000000..5f18d392 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,14 @@ +//! On-disk cache for pre-computed branch history. +//! +//! Ported from `loggerhead/changecache.py::RevInfoDiskCache`. This is a +//! best-effort cache: missing or corrupted entries just fall through to +//! recomputation, and concurrent writers race optimistically. +//! +//! The on-disk format is **not** compatible with the Python cache (the Rust +//! implementation stores `WholeHistory` with a custom binary encoding rather +//! than pickle/marshal). The table layout is kept similar so an operator can +//! tell what's in the file. + +pub mod disk; + +pub use disk::RevInfoDiskCache; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..5124aa52 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,67 @@ +use std::net::IpAddr; +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "loggerhead-serve", + about = "Web viewer for Bazaar/Breezy branches", + version +)] +pub struct Args { + /// Path or URL of the branch (or root directory of branches) to serve. + #[arg(value_name = "PATH_OR_URL")] + pub root: String, + + /// Port to listen on. + #[arg(long, default_value_t = 8080)] + pub port: u16, + + /// Host address to bind to. + #[arg(long, default_value = "0.0.0.0")] + pub host: IpAddr, + + /// URL prefix, for deployment behind a reverse proxy. + #[arg(long, default_value = "")] + pub prefix: String, + + /// Directory to place the on-disk revision-info cache (SQLite). + /// Python loggerhead calls this flag `--cache-dir`; `--cachepath` + /// is kept as an alias. + #[arg(long = "cache-dir", alias = "cachepath", value_name = "DIR")] + pub cachepath: Option, + + /// Allow tarball downloads of revisions. + #[arg(long, default_value_t = true)] + pub export_tarballs: bool, + + /// Directory to write log files to (currently logs are still emitted to + /// stderr; this flag is accepted for CLI-compat with the Python + /// implementation and reserved for future file-logging support). + #[arg(long, value_name = "DIR")] + pub log_folder: Option, + + /// Log level (matches Python loggerhead's `--log-level`). One of + /// `trace`, `debug`, `info`, `warn`, `error`. + #[arg(long, value_name = "LEVEL")] + pub log_level: Option, + + /// Directory of static CSS/JS/image assets to serve under `/static`. + /// Defaults to the Python loggerhead static dir shipped with this + /// checkout; override for a Debian install (e.g. `/usr/share/loggerhead/static`). + #[arg(long, value_name = "DIR")] + pub static_dir: Option, + + /// Serve the root as a directory of user branches. Each + /// `//` is exposed at `/~//`. + /// Useful for Launchpad-style layouts. + #[arg(long)] + pub user_dirs: bool, + + /// When `--user-dirs` is set, the subdirectory under `` + /// that contains "trunk" branches to serve under `/` without + /// the `~user` prefix. No-op otherwise. + #[arg(long, value_name = "DIR")] + pub trunk_dir: Option, +} diff --git a/src/controllers/annotate.rs b/src/controllers/annotate.rs new file mode 100644 index 00000000..64ae1bb7 --- /dev/null +++ b/src/controllers/annotate.rs @@ -0,0 +1,107 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::{Kind, Tree}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; + +#[derive(Template)] +#[template(path = "annotate.html")] +struct AnnotateTemplate { + // base + nick: String, + fileview_active: bool, + url_prefix: String, + // page + revno: String, + path: String, + lines: Vec, +} + +struct Line { + n: usize, + revno: String, + revid_short: String, + text: String, +} + +/// GET /annotate/:revno/*path — blame-style view of a file at the given +/// revision (use `head:` for the tip). +pub async fn show( + State(state): State>, + Path((revno_req, path)): Path<(String, String)>, +) -> AppResult> { + let path_norm = path.trim_matches('/').to_string(); + if path_norm.is_empty() { + return Err(AppError::Other("no filename provided".into())); + } + let path_for_task = path_norm.clone(); + let url_prefix = state.url_prefix.clone(); + + let (nick, annotated, revno) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let revid = history + .fix_revid(&revno_req) + .ok_or_else(|| AppError::NotFound(format!("no revision {revno_req}")))?; + let revno = history.whole.get_revno(&revid); + + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + let p = PathBuf::from(&path_for_task); + if !tree.has_filename(&p) { + return Err(AppError::NotFound(format!("{path_for_task} not found"))); + } + if !matches!(tree.kind(&p)?, Kind::File) { + return Err(AppError::Other(format!( + "{path_for_task} is not a regular file" + ))); + } + + let mut annotated: Vec<(RevisionId, Vec)> = Vec::new(); + for item in tree.annotate_iter(&p, None)? { + annotated.push(item?); + } + + let lines: Vec = annotated + .into_iter() + .enumerate() + .map(|(i, (rid, bytes))| { + let revno = history.whole.get_revno(&rid); + let full = String::from_utf8_lossy(rid.as_bytes()).into_owned(); + let short = full.split('-').next_back().unwrap_or("").to_string(); + Line { + n: i + 1, + revno, + revid_short: short, + text: String::from_utf8_lossy(&bytes) + .trim_end_matches('\n') + .to_string(), + } + }) + .collect(); + Ok::<_, AppError>((history.nick, lines, revno)) + }) + .await??; + + let tmpl = AnnotateTemplate { + nick, + fileview_active: true, + url_prefix, + revno, + path: path_norm, + lines: annotated, + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/atom.rs b/src/controllers/atom.rs new file mode 100644 index 00000000..b509f704 --- /dev/null +++ b/src/controllers/atom.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Host, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use breezyshim::branch::Branch; +use chrono::{DateTime, Utc}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::{Change, History}; +use crate::util::errors::{AppError, AppResult}; +use crate::util::fmt::hide_email; + +const PAGE_SIZE: usize = 20; + +/// GET /atom — Atom feed of the last PAGE_SIZE mainline revisions. +/// +/// Byte-structurally matches Python loggerhead's `atom.pt` output: +/// id, rel=self, rel=alternate, and per-entry ids all resolve to +/// absolute `http(s)://host/` URLs. The Host header gives +/// us the scheme+host. +pub async fn show( + State(state): State>, + Host(host): Host, + headers: HeaderMap, +) -> AppResult { + // axum 0.7 `Host` extractor doesn't give us the scheme; we infer + // "https" if the X-Forwarded-Proto header says so, else "http". + let scheme = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "http".into()); + let prefix = state.url_prefix.clone(); + let base = format!("{scheme}://{host}{prefix}"); + + let (nick, entries) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let mainline = history.mainline_from(&history.last_revid); + let page: Vec<_> = mainline.into_iter().take(PAGE_SIZE).collect(); + let changes = history.get_changes(&branch, &page)?; + Ok::<_, AppError>((history.nick, changes)) + }) + .await??; + + let body = render_atom(&base, &nick, &entries); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8") + .body(Body::from(body)) + .unwrap() + .into_response()) +} + +fn render_atom(base: &str, nick: &str, entries: &[Change]) -> String { + let updated = entries + .first() + .and_then(|c| DateTime::::from_timestamp(c.timestamp as i64, 0)) + .unwrap_or_else(Utc::now) + .to_rfc3339(); + let atom_self = format!("{base}/atom"); + let changes_url = format!("{base}/changes"); + let mut out = String::with_capacity(1024 + entries.len() * 512); + out.push_str( + r#" + + "#, + ); + out.push_str(&format!("bazaar changes for {}", xml_escape(nick))); + out.push_str("\n "); + out.push_str(&updated); + out.push_str("\n "); + out.push_str(&xml_escape(&atom_self)); + out.push_str("\n \n \n"); + for entry in entries { + let date = DateTime::::from_timestamp(entry.timestamp as i64, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_default(); + let rev_url = format!("{base}/revision/{}", entry.revno); + let author_name = hide_email(&entry.committer); + out.push_str(" \n "); + out.push_str(&xml_escape(&format!( + "{}: {}", + entry.revno, entry.short_message + ))); + out.push_str("\n "); + out.push_str(&date); + out.push_str("\n "); + out.push_str(&xml_escape(&rev_url)); + out.push_str("\n "); + out.push_str(&xml_escape(&author_name)); + out.push_str("\n "); + out.push_str(&xml_escape(&entry.message)); + out.push_str("\n \n \n"); + } + out.push_str("\n"); + out +} + +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} diff --git a/src/controllers/changelog.rs b/src/controllers/changelog.rs new file mode 100644 index 00000000..eb44ba19 --- /dev/null +++ b/src/controllers/changelog.rs @@ -0,0 +1,320 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, Query, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::Tree; +use serde::Deserialize; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::{Change, History}; +use crate::util::errors::{AppError, AppResult}; +use crate::util::fmt::{approximate_date, hide_email, utc_iso}; + +/// Query parameters accepted by the `/changes` page. +#[derive(Debug, Deserialize, Default)] +pub struct ChangelogQuery { + pub start_revid: Option, + /// Restrict the log to revisions that touched this path. + pub filter_path: Option, +} + +const PAGE_SIZE: usize = 20; + +#[derive(Template)] +#[template(path = "changelog.html")] +struct ChangelogTemplate { + // shared base-template fields + nick: String, + fileview_active: bool, + url_prefix: String, + served_url: String, + // page-specific + last_revno: String, + /// Revno at the end of the current page (for the "From Revision X to Y" + /// header). Empty if the page is empty. + end_revno: String, + /// Does any row on this page have tags? If so, the template renders + /// the extra "Tags" column. + show_tag_col: bool, + changes: Vec, + /// URL for the Newer (previous page) link, if there is one. + prev_page_url: Option, + /// URL for the Older (next page) link, if there is one. + next_page_url: Option, + /// The filter_path query echoed back so the header can say "Changes + /// to ". Empty when there's no filter. + filter_path: String, + /// JSON map `{ "0": "", "1": ..., ... }` consumed + /// by `static/javascript/changelog.js` to build `/+revlog/` + /// URLs for the expand-a-row feature. The key is the row index + /// (`log-N` element id suffix). + revids_json: String, +} + +struct ChangeView { + revno: String, + short_message: String, + author: String, + utc_iso: String, + relative_date: String, + /// Comma-separated list of tag names attached to this revision. + tags: String, + /// True iff the commit is a merge (has more than one parent). The + /// template shows a small merge-from icon next to the summary. + is_merge: bool, + /// Merge depth (0 = mainline). The template renders an extra + /// `padding-left` proportional to this so the log visualises the + /// merge structure. + merge_depth: usize, +} + +/// `GET /changes` — full mainline from the branch tip. +pub async fn show( + State(state): State>, + Query(q): Query, +) -> AppResult> { + render(state, None, q).await +} + +/// `GET /changes/:revno` — log starting from `revno` (Python loggerhead's +/// "view history from revision N" link). +pub async fn show_from( + State(state): State>, + Path(revno): Path, + Query(q): Query, +) -> AppResult> { + render(state, Some(revno), q).await +} + +/// Build the base query-string for pagination links: echoes `filter_path` +/// and the effective `start_revid` (as a revno, matching Python's format). +fn pagination_query(filter_path: &Option, start_revno: Option<&str>) -> String { + let mut parts: Vec<(&str, String)> = Vec::new(); + if let Some(fp) = filter_path.as_deref() { + if !fp.is_empty() { + parts.push(("filter_path", fp.to_string())); + } + } + if let Some(sr) = start_revno { + if !sr.is_empty() { + parts.push(("start_revid", sr.to_string())); + } + } + if parts.is_empty() { + String::new() + } else { + let joined: Vec = parts + .into_iter() + .map(|(k, v)| { + format!( + "{k}={}", + percent_encoding::utf8_percent_encode(&v, percent_encoding::NON_ALPHANUMERIC) + ) + }) + .collect(); + format!("?{}", joined.join("&")) + } +} + +struct PageData { + nick: String, + start_revno: String, + end_revno: String, + show_tag_col: bool, + changes: Vec, + /// JSON blob: `{ "0": "", ... }` for changelog.js. + revids_json: String, + /// Revno of the revision one page older (larger offset) than the + /// current view's start, if it exists. + next_start_revno: Option, + /// Revno of the revision one page newer (smaller offset) than the + /// current view's start, if it exists. + prev_start_revno: Option, +} + +async fn render( + state: Arc, + start_ref: Option, + q: ChangelogQuery, +) -> AppResult> { + let filter_path_for_query = q.filter_path.clone(); + let filter_path = q.filter_path.clone(); + let state2 = state.clone(); + let data = tokio::task::spawn_blocking(move || -> AppResult { + let branch = open_branch(&state2.root)?; + let _lock = branch.lock_read()?; + let whole = state2.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + + // Resolve starting point: explicit URL segment, query param, tip. + let start_revid = match start_ref.as_deref().or(q.start_revid.as_deref()) { + Some(r) => history + .fix_revid(r) + .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?, + None => history.last_revid.clone(), + }; + + // Full merge-sorted list reachable from the branch tip. Unlike + // the mainline-only view this includes merged revisions with + // merge_depth > 0 so the template can render a graph indent. + let full_entries = history.merge_sorted_from(&history.last_revid); + let full_filtered: Vec = if let Some(fp) = filter_path.as_deref() { + if fp.is_empty() { + full_entries.into_iter().map(|e| e.revid).collect() + } else { + let ids: Vec<_> = full_entries.into_iter().map(|e| e.revid).collect(); + filter_by_path(&branch, &ids, fp)? + } + } else { + full_entries.into_iter().map(|e| e.revid).collect() + }; + + // Find the index of `start_revid` within the filtered mainline. + let start_pos = full_filtered + .iter() + .position(|r| *r == start_revid) + .unwrap_or(0); + let end_exclusive = (start_pos + PAGE_SIZE).min(full_filtered.len()); + let page: Vec = full_filtered[start_pos..end_exclusive].to_vec(); + + let changes = history.get_changes(&branch, &page)?; + + // Is there a Next (Older) page? + let next_start_revno = full_filtered + .get(end_exclusive) + .map(|r| history.whole.get_revno(r)); + // Is there a Previous (Newer) page? + let prev_start_revno = if start_pos >= PAGE_SIZE { + full_filtered + .get(start_pos - PAGE_SIZE) + .map(|r| history.whole.get_revno(r)) + } else if start_pos > 0 { + // Prev page isn't a full PAGE_SIZE away — snap to the tip. + Some(history.whole.get_revno(&full_filtered[0])) + } else { + None + }; + + let start_revno = history.whole.get_revno(&start_revid); + let end_revno = changes.last().map(|c| c.revno.clone()).unwrap_or_default(); + + let show_tag_col = changes.iter().any(|c| !c.tags.is_empty()); + // Build `revids_json` in parallel with the ChangeView list so + // changelog.js can expand rows via /+revlog/. Key is + // the row index as a string, value is the percent-encoded revid. + let mut revid_map = serde_json::Map::new(); + let views: Vec = changes + .into_iter() + .enumerate() + .map(|(i, c)| { + let revid_enc = percent_encoding::utf8_percent_encode( + &String::from_utf8_lossy(c.revid.as_bytes()), + percent_encoding::NON_ALPHANUMERIC, + ) + .to_string(); + revid_map.insert(i.to_string(), serde_json::Value::String(revid_enc)); + let merge_depth = history + .whole + .index + .get(&c.revid) + .map(|&i| history.whole.entries[i].merge_depth) + .unwrap_or(0); + let mut view = ChangeView::from(c); + view.merge_depth = merge_depth; + view + }) + .collect(); + let revids_json = serde_json::Value::Object(revid_map).to_string(); + + Ok::<_, AppError>(PageData { + nick: history.nick, + start_revno, + end_revno, + show_tag_col, + changes: views, + revids_json, + next_start_revno, + prev_start_revno, + }) + }) + .await??; + + // Pagination link construction is URL-routing-shaped so we do it + // out here where we already have the url_prefix. + let url_prefix = &state.url_prefix; + let next_page_url = data.next_start_revno.as_ref().map(|r| { + format!( + "{}/changes{}", + url_prefix, + pagination_query(&filter_path_for_query, Some(r)) + ) + }); + let prev_page_url = data.prev_start_revno.as_ref().map(|r| { + format!( + "{}/changes{}", + url_prefix, + pagination_query(&filter_path_for_query, Some(r)) + ) + }); + + let tmpl = ChangelogTemplate { + nick: data.nick, + fileview_active: false, + url_prefix: state.url_prefix.clone(), + served_url: state.root.clone(), + last_revno: data.start_revno, + end_revno: data.end_revno, + show_tag_col: data.show_tag_col, + changes: data.changes, + prev_page_url, + next_page_url, + filter_path: filter_path_for_query.unwrap_or_default(), + revids_json: data.revids_json, + }; + Ok(Html(tmpl.render()?)) +} + +impl From for ChangeView { + fn from(c: Change) -> Self { + let is_merge = c.parents.len() > 1; + let tags = c.tags.join(", "); + ChangeView { + revno: c.revno, + short_message: c.short_message, + author: hide_email(&c.committer), + utc_iso: utc_iso(c.timestamp, c.timezone), + relative_date: approximate_date(c.timestamp), + tags, + is_merge, + merge_depth: 0, + } + } +} + +fn filter_by_path( + branch: &dyn Branch, + mainline: &[RevisionId], + path: &str, +) -> Result, AppError> { + let repo = branch.repository(); + let p = PathBuf::from(path); + let mut out = Vec::new(); + for rid in mainline { + if rid.is_null() { + continue; + } + let tree = repo.revision_tree(rid)?; + if let Ok(file_rev) = tree.get_file_revision(&p) { + if file_rev == *rid { + out.push(rid.clone()); + } + } + } + Ok(out) +} diff --git a/src/controllers/diff.rs b/src/controllers/diff.rs new file mode 100644 index 00000000..c139da0a --- /dev/null +++ b/src/controllers/diff.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use breezyshim::branch::Branch; +use breezyshim::diff::show_diff_trees_with; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use serde::Deserialize; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; + +#[derive(Debug, Deserialize, Default)] +pub struct DiffQuery { + /// Override the default 3 lines of unified-diff context. Matches + /// Python loggerhead's `?context=N`. + pub context: Option, +} + +/// GET /diff/:new_revid — diff from new's first parent to new. +pub async fn show_one( + State(state): State>, + Path(new_revid_enc): Path, + Query(q): Query, +) -> AppResult { + render_diff(state, new_revid_enc, None, q.context).await +} + +/// GET /diff/:new_revid/:old_revid — diff from old to new. +pub async fn show_two( + State(state): State>, + Path((new_revid_enc, old_revid_enc)): Path<(String, String)>, + Query(q): Query, +) -> AppResult { + render_diff(state, new_revid_enc, Some(old_revid_enc), q.context).await +} + +async fn render_diff( + state: Arc, + new_revid_enc: String, + old_revid_enc: Option, + context: Option, +) -> AppResult { + let (diff_bytes, revno_new, revno_old) = + tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + + let new_revid = history + .fix_revid(&new_revid_enc) + .ok_or_else(|| AppError::NotFound(format!("no revision {new_revid_enc}")))?; + let old_revid = match old_revid_enc { + Some(enc) => history + .fix_revid(&enc) + .ok_or_else(|| AppError::NotFound(format!("no revision {enc}")))?, + None => { + // Default to new's first parent, or NULL_REVISION for root commits. + let entry_idx = history + .whole + .index + .get(&new_revid) + .copied() + .ok_or_else(|| AppError::NotFound("new revision not in branch".into()))?; + history.whole.entries[entry_idx] + .parents + .first() + .cloned() + .unwrap_or_else(RevisionId::null) + } + }; + + let repo = branch.repository(); + let new_tree = repo.revision_tree(&new_revid)?; + let old_tree = repo.revision_tree(&old_revid)?; + + let mut buf: Vec = Vec::new(); + show_diff_trees_with(&old_tree, &new_tree, &mut buf, Some(""), Some(""), context)?; + + let revno_new = history.whole.get_revno(&new_revid); + let revno_old = history.whole.get_revno(&old_revid); + Ok::<_, AppError>((buf, revno_new, revno_old)) + }) + .await??; + + let filename = format!("{revno_new}_{revno_old}.diff"); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename={filename}"), + ) + .body(Body::from(diff_bytes)) + .unwrap() + .into_response()) +} diff --git a/src/controllers/directory.rs b/src/controllers/directory.rs new file mode 100644 index 00000000..45ef59d0 --- /dev/null +++ b/src/controllers/directory.rs @@ -0,0 +1,127 @@ +use std::sync::Arc; + +use askama::Template; +use axum::extract::State; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use chrono::{DateTime, Utc}; + +use crate::app::AppState; +use crate::breezy::{is_http_serveable, open_branch}; +use crate::util::errors::{AppError, AppResult}; +use crate::util::fmt::hide_email; + +#[derive(Template)] +#[template(path = "directory.html")] +struct DirectoryTemplate { + // base-template fields — `nick` is the root name here + nick: String, + #[allow(dead_code)] + fileview_active: bool, + url_prefix: String, + entries: Vec, +} + +struct Entry { + name: String, + /// Href relative to the root — either a sub-branch view or a + /// drill-down into a deeper listing. + href: String, + /// True if this entry is itself a branch (so the row gets the + /// revision info). + is_branch: bool, + /// Last revision committer + ISO date, shown only for branches. + last_committer: String, + last_date: String, +} + +/// `GET /` when the configured root is a directory rather than a branch. +/// +/// Currently this only renders the listing; drill-down into sub-branches +/// (the old Python `BranchesFromTransportServer`) is not yet implemented +/// because axum's Router is built at startup and re-dispatching per +/// request requires either dynamic routing or per-controller support +/// for a branch sub-path. For single-branch installs, point +/// `loggerhead-serve` directly at the branch directory. +pub async fn show(State(state): State>) -> AppResult> { + let root_path = std::path::PathBuf::from(&state.root); + let root_name = root_path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| state.root.clone()); + + let state2 = state.clone(); + let entries = tokio::task::spawn_blocking(move || -> AppResult> { + let mut out: Vec = Vec::new(); + let read = std::fs::read_dir(&state2.root) + .map_err(|e| AppError::Other(format!("read_dir {}: {e}", state2.root)))?; + let mut names: Vec = read + .filter_map(|e| e.ok()) + .filter_map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + if n.starts_with('.') { + None + } else if e.file_type().ok().is_some_and(|t| t.is_dir()) { + Some(n) + } else { + None + } + }) + .collect(); + names.sort_by_key(|s| s.to_lowercase()); + + for name in names { + let child_path = format!("{}/{}", state2.root.trim_end_matches('/'), name); + let (is_branch, last_committer, last_date) = match open_branch(&child_path) { + Ok(branch) if !is_http_serveable(&branch) => { + // Hide branches whose config says http_serve=false. + continue; + } + Ok(branch) => { + let (committer, date) = branch + .lock_read() + .ok() + .map(|_| { + let rid = branch.last_revision(); + if rid.is_null() { + return (String::new(), String::new()); + } + let repo = branch.repository(); + match repo.get_revision(&rid) { + Ok(rev) => { + let committer = hide_email(&rev.committer); + let date = + DateTime::::from_timestamp(rev.timestamp as i64, 0) + .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_default(); + (committer, date) + } + Err(_) => (String::new(), String::new()), + } + }) + .unwrap_or_default(); + (true, committer, date) + } + Err(_) => (false, String::new(), String::new()), + }; + out.push(Entry { + href: format!("/{name}/"), + name, + is_branch, + last_committer, + last_date, + }); + } + Ok(out) + }) + .await??; + + let tmpl = DirectoryTemplate { + nick: root_name, + fileview_active: false, + url_prefix: state.url_prefix.clone(), + entries, + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/download.rs b/src/controllers/download.rs new file mode 100644 index 00000000..7df7758f --- /dev/null +++ b/src/controllers/download.rs @@ -0,0 +1,130 @@ +use std::path::Path as StdPath; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use breezyshim::branch::Branch; +use breezyshim::export::{archive, ArchiveFormat}; +use breezyshim::repository::Repository; +use breezyshim::tree::Tree; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; + +/// GET /download (no args) — permanent redirect to `/changes`. Matches +/// Python's DownloadUI, which redirects when fewer than two args are given. +pub async fn show_bare(State(state): State>) -> Redirect { + Redirect::permanent(&state.url("/changes")) +} + +/// GET /download/:revid/*path — stream a single file at `path` from +/// `revid` (a dotted revno, `head:`, or a raw revid). +pub async fn show_file( + State(state): State>, + Path((revid_enc, path_enc)): Path<(String, String)>, +) -> AppResult { + let idref = percent_decode_str(&revid_enc) + .decode_utf8_lossy() + .into_owned(); + let path = percent_decode_str(&path_enc) + .decode_utf8_lossy() + .into_owned(); + let filename = StdPath::new(&path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); + + let path_for_task = path.clone(); + let content = tokio::task::spawn_blocking(move || -> AppResult> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let revid = history + .fix_revid(&idref) + .ok_or_else(|| AppError::NotFound(format!("no revision {idref}")))?; + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + let p = PathBuf::from(&path_for_task); + Ok(tree.get_file_text(&p)?) + }) + .await??; + + let mime = mime_guess::from_path(&filename) + .first_or_octet_stream() + .to_string(); + let encoded = utf8_percent_encode(&filename, NON_ALPHANUMERIC).to_string(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename*=utf-8''{encoded}"), + ) + .body(Body::from(content)) + .unwrap() + .into_response()) +} + +/// GET /tarball/:revid — stream a tgz of the tree at the given +/// revision reference (dotted revno, `head:`, or raw revid). +pub async fn tarball( + State(state): State>, + Path(revid_enc): Path, +) -> AppResult { + if !state.export_tarballs { + return Err(AppError::Other("tarball export is disabled".into())); + } + let idref = percent_decode_str(&revid_enc) + .decode_utf8_lossy() + .into_owned(); + + // Gather the archive into memory inside spawn_blocking. Streaming the + // iterator across the async boundary while still holding the GIL is + // awkward; for the sizes most loggerhead deployments see this is a fair + // tradeoff, and we can revisit with a bounded mpsc channel if needed. + let (bytes, filename) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let revid = history + .fix_revid(&idref) + .ok_or_else(|| AppError::NotFound(format!("no revision {idref}")))?; + let nick = branch + .get_config() + .get_nickname() + .unwrap_or_else(|_| "branch".into()); + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + // Python names the file `-r.tgz` when a rev is + // specified; we use the revno form since `idref` may already + // be a dotted revno. + let revno_part = history.whole.get_revno(&revid); + let filename = format!("{nick}-r{revno_part}.tgz"); + let mut out = Vec::new(); + for chunk in archive(&tree, ArchiveFormat::Tgz, &filename, None, Some(&nick))? { + out.extend_from_slice(&chunk?); + } + Ok::<_, AppError>((out, filename)) + }) + .await??; + + let encoded = utf8_percent_encode(&filename, NON_ALPHANUMERIC).to_string(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename*=utf-8''{encoded}"), + ) + .body(Body::from(bytes)) + .unwrap() + .into_response()) +} diff --git a/src/controllers/filediff.rs b/src/controllers/filediff.rs new file mode 100644 index 00000000..0dcda4dc --- /dev/null +++ b/src/controllers/filediff.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::Tree; +use percent_encoding::percent_decode_str; +use similar::{ChangeTag, TextDiff}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::util::errors::{AppError, AppResult}; + +#[derive(Template)] +#[template(path = "filediff.html")] +struct FileDiffTemplate { + chunks: Vec, +} + +struct Chunk { + #[allow(dead_code)] + header: String, + lines: Vec, +} + +struct DiffLine { + old_lineno: Option, + new_lineno: Option, + kind: &'static str, + /// HTML-ready fragment: characters are escaped and tabs/spaces + /// are rewritten to ` ` so the `
` container preserves + /// leading indentation the way Python's `util.html_clean` does. + /// Rendered through Askama's `|safe` filter. + text: String, +} + +/// GET /+filediff/:new_revid/:old_revid/*path — render a unified diff for a +/// single file between two revisions. +pub async fn show( + State(state): State>, + Path((new_revid_enc, old_revid_enc, path_enc)): Path<(String, String, String)>, +) -> AppResult> { + let new_revid = revid_from_enc(&new_revid_enc); + let old_revid = revid_from_enc(&old_revid_enc); + let path = percent_decode_str(&path_enc) + .decode_utf8_lossy() + .into_owned(); + + let (old_lines, new_lines) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let repo = branch.repository(); + let new_tree = repo.revision_tree(&new_revid)?; + let old_tree = repo.revision_tree(&old_revid)?; + let p = std::path::Path::new(&path); + let new_lines = read_file_lines(&new_tree, p); + let old_lines = read_file_lines(&old_tree, p); + Ok::<_, AppError>((old_lines, new_lines)) + }) + .await??; + + let chunks = render_chunks(&old_lines, &new_lines); + let tmpl = FileDiffTemplate { chunks }; + Ok(Html(tmpl.render()?)) +} + +fn revid_from_enc(s: &str) -> RevisionId { + let decoded = percent_decode_str(s).decode_utf8_lossy().into_owned(); + RevisionId::from(decoded.into_bytes()) +} + +/// Read a file from a tree as UTF-8 text split into lines (empty on missing +/// file or binary content). +fn read_file_lines(tree: &dyn Tree, path: &std::path::Path) -> String { + match tree.get_file_text(path) { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(_) => String::new(), + } +} + +/// HTML-escape a diff line and rewrite whitespace so a non-`
`
+/// container preserves leading indentation. Tabs are expanded to
+/// 8 spaces (matching Python's `str.expandtabs`) and every space
+/// becomes ` `. Matches `loggerhead.util.html_clean`.
+fn html_clean(s: &str) -> String {
+    // Escape HTML first, then substitute whitespace.
+    let mut escaped = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => escaped.push_str("&"),
+            '<' => escaped.push_str("<"),
+            '>' => escaped.push_str(">"),
+            '"' => escaped.push_str("""),
+            '\'' => escaped.push_str("'"),
+            _ => escaped.push(c),
+        }
+    }
+    // expandtabs: replace each `\t` with enough spaces to reach the
+    // next 8-column tab stop. We do a simple uniform expansion to
+    // 8 spaces — precise column tracking isn't needed for diff
+    // rendering.
+    let expanded = escaped.replace('\t', "        ");
+    expanded.replace(' ', " ")
+}
+
+fn render_chunks(old: &str, new: &str) -> Vec {
+    let diff = TextDiff::from_lines(old, new);
+    let mut chunks = Vec::new();
+    for group in diff.grouped_ops(3) {
+        if group.is_empty() {
+            continue;
+        }
+        let first = &group[0];
+        let last = &group[group.len() - 1];
+        let old_start = first.as_tag_tuple().1.start + 1;
+        let new_start = first.as_tag_tuple().2.start + 1;
+        let old_len = last.as_tag_tuple().1.end - first.as_tag_tuple().1.start;
+        let new_len = last.as_tag_tuple().2.end - first.as_tag_tuple().2.start;
+        let header = format!("@@ -{old_start},{old_len} +{new_start},{new_len} @@");
+        let mut lines = Vec::new();
+        for op in group {
+            for change in diff.iter_changes(&op) {
+                let kind = match change.tag() {
+                    ChangeTag::Equal => "context",
+                    ChangeTag::Delete => "delete",
+                    ChangeTag::Insert => "insert",
+                };
+                let raw = change.value();
+                let trimmed = raw.trim_end_matches('\n');
+                lines.push(DiffLine {
+                    old_lineno: change.old_index().map(|i| i + 1),
+                    new_lineno: change.new_index().map(|i| i + 1),
+                    kind,
+                    text: html_clean(trimmed),
+                });
+            }
+        }
+        chunks.push(Chunk { header, lines });
+    }
+    chunks
+}
diff --git a/src/controllers/inventory.rs b/src/controllers/inventory.rs
new file mode 100644
index 00000000..4277c921
--- /dev/null
+++ b/src/controllers/inventory.rs
@@ -0,0 +1,353 @@
+use std::path::{Path as StdPath, PathBuf};
+use std::sync::Arc;
+
+use askama::Template;
+use axum::extract::{Path, Query, State};
+use axum::response::Html;
+use breezyshim::branch::Branch;
+use breezyshim::repository::Repository;
+use breezyshim::revisionid::RevisionId;
+use breezyshim::tree::{Kind, Tree};
+use serde::Deserialize;
+
+use crate::app::AppState;
+use crate::breezy::open_branch;
+use crate::history::{Change, History};
+use crate::util::errors::{AppError, AppResult};
+use crate::util::fmt::{approximate_date, hide_email, utc_iso};
+
+#[derive(Debug, Deserialize, Default)]
+pub struct FilesQuery {
+    /// `filename` (default), `date`, or `size`. Matches Python loggerhead.
+    pub sort: Option,
+}
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+enum Sort {
+    Filename,
+    Date,
+    Size,
+}
+
+impl Sort {
+    fn from_str(s: Option<&str>) -> Self {
+        match s {
+            Some("date") => Sort::Date,
+            Some("size") => Sort::Size,
+            _ => Sort::Filename,
+        }
+    }
+    fn as_str(&self) -> &'static str {
+        match self {
+            Sort::Filename => "filename",
+            Sort::Date => "date",
+            Sort::Size => "size",
+        }
+    }
+}
+
+#[derive(Template)]
+#[template(path = "inventory.html")]
+struct InventoryTemplate {
+    // base
+    nick: String,
+    fileview_active: bool,
+    url_prefix: String,
+    // page
+    revno: String,
+    revid_hex: String,
+    path: String,
+    #[allow(dead_code)]
+    path_display: String,
+    parent_path: Option,
+    /// Segments along `path`, each with a clickable URL. e.g.
+    /// `/files/3/src/controllers/` → [("src", "/files/3/src"),
+    /// ("controllers", "/files/3/src/controllers")].
+    breadcrumbs: Vec,
+    tip_change: Option,
+    entries: Vec,
+    /// Current sort mode, as the query param string ("filename",
+    /// "date", "size"). The template uses this to style the active
+    /// column header.
+    sort: String,
+}
+
+struct Crumb {
+    name: String,
+    href: String,
+}
+
+#[allow(dead_code)]
+struct ChangeView {
+    revno: String,
+    committer: String,
+    utc_iso: String,
+    short_message: String,
+    message: String,
+}
+
+impl From for ChangeView {
+    fn from(c: Change) -> Self {
+        ChangeView {
+            revno: c.revno,
+            committer: hide_email(&c.committer),
+            utc_iso: utc_iso(c.timestamp, c.timezone),
+            short_message: c.short_message,
+            message: c.message,
+        }
+    }
+}
+
+struct Entry {
+    name: String,
+    href: String,
+    #[allow(dead_code)]
+    kind: &'static str,
+    is_dir: bool,
+    size: Option,
+    last_revno: String,
+    last_revid_hex: String,
+    last_committer: String,
+    last_relative: String,
+    last_message: String,
+    /// Unix timestamp of the last-changed revision, used for the
+    /// ?sort=date mode. Zero when we couldn't resolve the change.
+    last_timestamp: f64,
+}
+
+pub async fn show_root(
+    State(state): State>,
+    Query(q): Query,
+) -> AppResult> {
+    render(state, None, String::new(), q).await
+}
+
+pub async fn show_rev(
+    State(state): State>,
+    Path(revno): Path,
+    Query(q): Query,
+) -> AppResult> {
+    render(state, Some(revno), String::new(), q).await
+}
+
+pub async fn show_rev_path(
+    State(state): State>,
+    Path((revno, path)): Path<(String, String)>,
+    Query(q): Query,
+) -> AppResult> {
+    render(state, Some(revno), path, q).await
+}
+
+async fn render(
+    state: Arc,
+    revno_req: Option,
+    path: String,
+    q: FilesQuery,
+) -> AppResult> {
+    let state_for_tmpl = state.clone();
+    let sort_for_tmpl = Sort::from_str(q.sort.as_deref()).as_str().to_string();
+    let (nick, revno, revid_hex, tip_change, entries, path_display, parent_path, normalized) =
+        tokio::task::spawn_blocking(move || -> AppResult<_> {
+            let branch = open_branch(&state.root)?;
+            let _lock = branch.lock_read()?;
+            let whole = state.load_whole_history(&branch)?;
+            let history = History::from_whole(&branch, (*whole).clone())?;
+            let revid = match revno_req.as_deref() {
+                Some(r) => history
+                    .fix_revid(r)
+                    .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?,
+                None => history.last_revid.clone(),
+            };
+            let revno = history.whole.get_revno(&revid);
+            let repo = branch.repository();
+            let tree = repo.revision_tree(&revid)?;
+
+            let normalized = path.trim_matches('/').to_string();
+            let list_path = if normalized.is_empty() {
+                PathBuf::from("")
+            } else {
+                PathBuf::from(&normalized)
+            };
+            if !normalized.is_empty() {
+                if !tree.has_filename(&list_path) {
+                    return Err(AppError::NotFound(format!("{normalized} not found")));
+                }
+                if !matches!(tree.kind(&list_path)?, Kind::Directory) {
+                    return Err(AppError::Other(format!("{normalized} is not a directory")));
+                }
+            }
+
+            // Fetch revision information for tip, to show in the info box.
+            let tip_change_rec = history
+                .get_changes(&branch, std::slice::from_ref(&revid))?
+                .pop();
+
+            // Walk children, gathering per-entry last-changed revids.
+            let from_dir = if normalized.is_empty() {
+                None
+            } else {
+                Some(list_path.as_path())
+            };
+            let iter = tree.list_files(Some(false), from_dir, Some(false), Some(false))?;
+            let mut raw: Vec<(String, Kind, Option, RevisionId)> = Vec::new();
+            for item in iter {
+                let (rel, _v, kind, entry) = item?;
+                let size = match &entry {
+                    breezyshim::tree::TreeEntry::File { size, .. } => Some(*size),
+                    _ => None,
+                };
+                let name = rel
+                    .file_name()
+                    .map(|n| n.to_string_lossy().into_owned())
+                    .unwrap_or_else(|| rel.to_string_lossy().into_owned());
+                let full = if normalized.is_empty() {
+                    name.clone()
+                } else {
+                    format!("{normalized}/{name}")
+                };
+                let child_revid = tree
+                    .get_file_revision(StdPath::new(&full))
+                    .unwrap_or_else(|_| revid.clone());
+                raw.push((name, kind, size, child_revid));
+            }
+
+            // Batch-fetch the changes for all the unique revids involved.
+            let unique: std::collections::HashSet =
+                raw.iter().map(|(_, _, _, r)| r.clone()).collect();
+            let unique_vec: Vec<_> = unique.into_iter().collect();
+            let changes = history.get_changes(&branch, &unique_vec)?;
+            let mut change_by_id: std::collections::HashMap =
+                std::collections::HashMap::new();
+            for c in changes {
+                change_by_id.insert(c.revid.clone(), c);
+            }
+
+            let mut entries: Vec = raw
+                .into_iter()
+                .map(|(name, kind, size, child_revid)| {
+                    let is_dir = matches!(kind, Kind::Directory);
+                    let kind_str = match kind {
+                        Kind::File => "file",
+                        Kind::Directory => "directory",
+                        Kind::Symlink => "symlink",
+                        Kind::TreeReference => "tree-reference",
+                    };
+                    let full = if normalized.is_empty() {
+                        name.clone()
+                    } else {
+                        format!("{normalized}/{name}")
+                    };
+                    let href = if is_dir {
+                        format!("{}/files/{}/{}", state.url_prefix, revno, full)
+                    } else {
+                        format!("{}/view/{}/{}", state.url_prefix, revno, full)
+                    };
+                    let ch = change_by_id.get(&child_revid);
+                    Entry {
+                        name,
+                        href,
+                        kind: kind_str,
+                        is_dir,
+                        size,
+                        last_revno: ch
+                            .map(|c| c.revno.clone())
+                            .unwrap_or_else(|| "?".to_string()),
+                        last_revid_hex: String::from_utf8_lossy(child_revid.as_bytes())
+                            .into_owned(),
+                        last_committer: ch.map(|c| hide_email(&c.committer)).unwrap_or_default(),
+                        last_relative: ch
+                            .map(|c| approximate_date(c.timestamp))
+                            .unwrap_or_default(),
+                        last_message: ch.map(|c| c.short_message.clone()).unwrap_or_default(),
+                        last_timestamp: ch.map(|c| c.timestamp).unwrap_or(0.0),
+                    }
+                })
+                .collect();
+            // Apply the requested sort. For date: newest first. For
+            // size: largest first, ignoring directories (size None).
+            // For filename (default): alphabetical, dirs first.
+            let sort = Sort::from_str(q.sort.as_deref());
+            match sort {
+                Sort::Date => entries.sort_by(|a, b| {
+                    b.last_timestamp
+                        .partial_cmp(&a.last_timestamp)
+                        .unwrap_or(std::cmp::Ordering::Equal)
+                        .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
+                }),
+                Sort::Size => entries.sort_by(|a, b| {
+                    // Put directories at the bottom of size sort; among
+                    // files, largest first.
+                    b.is_dir
+                        .cmp(&a.is_dir)
+                        .reverse()
+                        .then(b.size.unwrap_or(0).cmp(&a.size.unwrap_or(0)))
+                        .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
+                }),
+                Sort::Filename => entries.sort_by(|a, b| {
+                    b.is_dir
+                        .cmp(&a.is_dir)
+                        .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
+                }),
+            }
+
+            let path_display = if normalized.is_empty() {
+                "/".to_string()
+            } else {
+                format!("/{normalized}")
+            };
+            let parent_path = if normalized.is_empty() {
+                None
+            } else {
+                Some(
+                    StdPath::new(&normalized)
+                        .parent()
+                        .map(|p| p.to_string_lossy().into_owned())
+                        .unwrap_or_default(),
+                )
+            };
+            let revid_hex = String::from_utf8_lossy(revid.as_bytes()).into_owned();
+            Ok::<_, AppError>((
+                history.nick,
+                revno,
+                revid_hex,
+                tip_change_rec.map(ChangeView::from),
+                entries,
+                path_display,
+                parent_path,
+                normalized,
+            ))
+        })
+        .await??;
+
+    // Build path-descent breadcrumbs from the normalized path.
+    let mut breadcrumbs = Vec::new();
+    if !normalized.is_empty() {
+        let mut acc = String::new();
+        for segment in normalized.split('/').filter(|s| !s.is_empty()) {
+            if !acc.is_empty() {
+                acc.push('/');
+            }
+            acc.push_str(segment);
+            breadcrumbs.push(Crumb {
+                name: segment.to_string(),
+                href: format!("{}/files/{}/{}", state_for_tmpl.url_prefix, revno, acc),
+            });
+        }
+    }
+
+    let tmpl = InventoryTemplate {
+        nick,
+        fileview_active: true,
+        url_prefix: state_for_tmpl.url_prefix.clone(),
+        revno,
+        revid_hex,
+        path: normalized,
+        path_display,
+        parent_path,
+        breadcrumbs,
+        tip_change,
+        entries,
+        sort: sort_for_tmpl,
+    };
+    Ok(Html(tmpl.render()?))
+}
diff --git a/src/controllers/json.rs b/src/controllers/json.rs
new file mode 100644
index 00000000..939f3ffb
--- /dev/null
+++ b/src/controllers/json.rs
@@ -0,0 +1,517 @@
+//! `/+json/...` variants of the HTML-rendering controllers.
+//!
+//! Python loggerhead gates this behind `supports_json` on each
+//! controller. Our approach is more explicit: each JSON endpoint is a
+//! parallel thin handler that calls into the same underlying
+//! data-gathering as the HTML counterpart, but serialises a purpose-
+//! built serde struct instead of running an Askama template.
+
+use std::path::{Path as StdPath, PathBuf};
+use std::sync::Arc;
+
+use axum::extract::{Path, Query, State};
+use axum::Json;
+use breezyshim::branch::Branch;
+use breezyshim::repository::Repository;
+use breezyshim::revisionid::RevisionId;
+use breezyshim::tree::{Kind, Tree};
+use percent_encoding::percent_decode_str;
+use serde::Serialize;
+use similar::{ChangeTag, TextDiff};
+
+use crate::app::AppState;
+use crate::breezy::open_branch;
+use crate::controllers::changelog::ChangelogQuery;
+use crate::controllers::revision::RevisionQuery;
+use crate::history::{FileChangeKind, History};
+use crate::util::errors::{AppError, AppResult};
+
+/// JSON body for `GET /+json/changes` (and its `/:revno` variant).
+#[derive(Serialize)]
+pub struct ChangesJson {
+    pub nick: String,
+    pub start_revno: String,
+    pub page_size: usize,
+    pub next_start_revno: Option,
+    pub prev_start_revno: Option,
+    pub changes: Vec,
+}
+
+#[derive(Serialize)]
+pub struct ChangeEntry {
+    pub revno: String,
+    pub revid: String,
+    pub committer: String,
+    pub timestamp: f64,
+    pub message: String,
+    pub tags: Vec,
+    pub bugs: Vec,
+    pub foreign: Option,
+    pub parents: Vec,
+    pub is_merge: bool,
+}
+
+#[derive(Serialize)]
+pub struct ParentEntry {
+    pub revno: String,
+    pub revid: String,
+}
+
+#[derive(Serialize)]
+pub struct FileChangeEntry {
+    pub kind: &'static str,
+    pub path: String,
+    pub old_path: Option,
+}
+
+/// `GET /+json/changes` — JSON log page.
+pub async fn changes(
+    State(state): State>,
+    Query(q): Query,
+) -> AppResult> {
+    changes_render(state, None, q).await
+}
+
+/// `GET /+json/changes/:revno` — JSON log starting at `revno`.
+pub async fn changes_from(
+    State(state): State>,
+    Path(revno): Path,
+    Query(q): Query,
+) -> AppResult> {
+    changes_render(state, Some(revno), q).await
+}
+
+const PAGE_SIZE: usize = 20;
+
+async fn changes_render(
+    state: Arc,
+    start_ref: Option,
+    q: ChangelogQuery,
+) -> AppResult> {
+    let filter_path = q.filter_path.clone();
+    let out = tokio::task::spawn_blocking(move || -> AppResult {
+        let branch = open_branch(&state.root)?;
+        let _lock = branch.lock_read()?;
+        let whole = state.load_whole_history(&branch)?;
+        let history = History::from_whole(&branch, (*whole).clone())?;
+        let start_revid = match start_ref.as_deref().or(q.start_revid.as_deref()) {
+            Some(r) => history
+                .fix_revid(r)
+                .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?,
+            None => history.last_revid.clone(),
+        };
+        let mut mainline = history.mainline_from(&history.last_revid);
+        if let Some(fp) = filter_path.as_deref() {
+            if !fp.is_empty() {
+                let repo = branch.repository();
+                let p = PathBuf::from(fp);
+                mainline.retain(|rid| {
+                    if rid.is_null() {
+                        return false;
+                    }
+                    match repo.revision_tree(rid) {
+                        Ok(tree) => tree.get_file_revision(&p).ok().as_ref() == Some(rid),
+                        Err(_) => false,
+                    }
+                });
+            }
+        }
+        let start_pos = mainline.iter().position(|r| *r == start_revid).unwrap_or(0);
+        let end_exclusive = (start_pos + PAGE_SIZE).min(mainline.len());
+        let page: Vec = mainline[start_pos..end_exclusive].to_vec();
+        let next_start_revno = mainline
+            .get(end_exclusive)
+            .map(|r| history.whole.get_revno(r));
+        let prev_start_revno = if start_pos >= PAGE_SIZE {
+            mainline
+                .get(start_pos - PAGE_SIZE)
+                .map(|r| history.whole.get_revno(r))
+        } else if start_pos > 0 {
+            Some(history.whole.get_revno(&mainline[0]))
+        } else {
+            None
+        };
+
+        let changes = history.get_changes(&branch, &page)?;
+        let entries: Vec = changes
+            .into_iter()
+            .map(|c| {
+                let is_merge = c.parents.len() > 1;
+                ChangeEntry {
+                    revno: c.revno,
+                    revid: String::from_utf8_lossy(c.revid.as_bytes()).into_owned(),
+                    committer: c.committer,
+                    timestamp: c.timestamp,
+                    message: c.message,
+                    tags: c.tags,
+                    bugs: c.bugs,
+                    foreign: c.foreign.map(|f| ForeignEntry {
+                        abbreviation: f.abbreviation,
+                        foreign_revid: f.foreign_revid,
+                    }),
+                    parents: c
+                        .parents
+                        .into_iter()
+                        .map(|(rid, revno)| ParentEntry {
+                            revno,
+                            revid: String::from_utf8_lossy(rid.as_bytes()).into_owned(),
+                        })
+                        .collect(),
+                    is_merge,
+                }
+            })
+            .collect();
+
+        Ok(ChangesJson {
+            nick: history.nick,
+            start_revno: history.whole.get_revno(&start_revid),
+            page_size: PAGE_SIZE,
+            next_start_revno,
+            prev_start_revno,
+            changes: entries,
+        })
+    })
+    .await??;
+    Ok(Json(out))
+}
+
+/// JSON body for `GET /+json/revision/:revid`.
+#[derive(Serialize)]
+pub struct RevisionJson {
+    pub revid: String,
+    pub revno: String,
+    pub committer: String,
+    pub timestamp: f64,
+    pub message: String,
+    pub parents: Vec,
+    pub tags: Vec,
+    pub bugs: Vec,
+    /// `{ "abbreviation": "git", "foreign_revid": "" }` when
+    /// this revision came from a non-bzr VCS; null otherwise.
+    pub foreign: Option,
+    pub file_changes: Vec,
+    /// If the request included `compare_revid`, this is the revno of
+    /// the comparison base; otherwise null.
+    pub compare_revno: Option,
+}
+
+#[derive(Serialize)]
+pub struct ForeignEntry {
+    pub abbreviation: String,
+    pub foreign_revid: String,
+}
+
+pub async fn revision(
+    State(state): State>,
+    Path(idref): Path,
+    Query(q): Query,
+) -> AppResult> {
+    let idref = percent_decode_str(&idref).decode_utf8_lossy().into_owned();
+    let out = tokio::task::spawn_blocking(move || -> AppResult {
+        let branch = open_branch(&state.root)?;
+        let _lock = branch.lock_read()?;
+        let whole = state.load_whole_history(&branch)?;
+        let history = History::from_whole(&branch, (*whole).clone())?;
+        let revid = history
+            .fix_revid(&idref)
+            .ok_or_else(|| AppError::NotFound(format!("no revision {idref}")))?;
+        if !history.whole.index.contains_key(&revid) {
+            return Err(AppError::NotFound(format!(
+                "revision {idref} not in branch"
+            )));
+        }
+        let change = history
+            .get_changes(&branch, std::slice::from_ref(&revid))?
+            .into_iter()
+            .next()
+            .ok_or_else(|| AppError::NotFound("revision data missing".into()))?;
+        let (file_changes, compare_revno) = match q.compare_revid.as_deref() {
+            Some(cr) => {
+                let base = history
+                    .fix_revid(cr)
+                    .ok_or_else(|| AppError::NotFound(format!("no revision {cr}")))?;
+                let diffs = history.file_changes_between(&branch, &base, &revid)?;
+                (diffs, Some(history.whole.get_revno(&base)))
+            }
+            None => (history.get_file_changes(&branch, &revid)?, None),
+        };
+        Ok(RevisionJson {
+            revid: String::from_utf8_lossy(change.revid.as_bytes()).into_owned(),
+            revno: change.revno,
+            committer: change.committer,
+            timestamp: change.timestamp,
+            message: change.message,
+            parents: change
+                .parents
+                .into_iter()
+                .map(|(rid, revno)| ParentEntry {
+                    revno,
+                    revid: String::from_utf8_lossy(rid.as_bytes()).into_owned(),
+                })
+                .collect(),
+            tags: change.tags,
+            bugs: change.bugs,
+            foreign: change.foreign.map(|f| ForeignEntry {
+                abbreviation: f.abbreviation,
+                foreign_revid: f.foreign_revid,
+            }),
+            file_changes: file_changes
+                .into_iter()
+                .map(|f| FileChangeEntry {
+                    kind: match f.kind {
+                        FileChangeKind::Added => "added",
+                        FileChangeKind::Removed => "removed",
+                        FileChangeKind::Modified => "modified",
+                        FileChangeKind::Renamed => "renamed",
+                        FileChangeKind::Copied => "copied",
+                        FileChangeKind::KindChanged => "kind-changed",
+                    },
+                    path: f.path,
+                    old_path: f.old_path,
+                })
+                .collect(),
+            compare_revno,
+        })
+    })
+    .await??;
+    Ok(Json(out))
+}
+
+/// JSON body for `GET /+json/files[/:revno[/*path]]`.
+#[derive(Serialize)]
+pub struct FilesJson {
+    pub revno: String,
+    pub revid: String,
+    pub path: String,
+    pub entries: Vec,
+}
+
+#[derive(Serialize)]
+pub struct FileEntry {
+    pub name: String,
+    pub kind: String,
+    pub size: Option,
+    pub last_revno: String,
+    pub last_revid: String,
+}
+
+pub async fn files_root(State(state): State>) -> AppResult> {
+    files_render(state, None, String::new()).await
+}
+
+pub async fn files_rev(
+    State(state): State>,
+    Path(revno): Path,
+) -> AppResult> {
+    files_render(state, Some(revno), String::new()).await
+}
+
+pub async fn files_rev_path(
+    State(state): State>,
+    Path((revno, path)): Path<(String, String)>,
+) -> AppResult> {
+    files_render(state, Some(revno), path).await
+}
+
+async fn files_render(
+    state: Arc,
+    revno_req: Option,
+    path: String,
+) -> AppResult> {
+    let out = tokio::task::spawn_blocking(move || -> AppResult {
+        let branch = open_branch(&state.root)?;
+        let _lock = branch.lock_read()?;
+        let whole = state.load_whole_history(&branch)?;
+        let history = History::from_whole(&branch, (*whole).clone())?;
+        let revid = match revno_req.as_deref() {
+            Some(r) => history
+                .fix_revid(r)
+                .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?,
+            None => history.last_revid.clone(),
+        };
+        let revno = history.whole.get_revno(&revid);
+        let repo = branch.repository();
+        let tree = repo.revision_tree(&revid)?;
+
+        let normalized = path.trim_matches('/').to_string();
+        let list_path = if normalized.is_empty() {
+            PathBuf::from("")
+        } else {
+            PathBuf::from(&normalized)
+        };
+        if !normalized.is_empty() {
+            if !tree.has_filename(&list_path) {
+                return Err(AppError::NotFound(format!("{normalized} not found")));
+            }
+            if !matches!(tree.kind(&list_path)?, Kind::Directory) {
+                return Err(AppError::Other(format!("{normalized} is not a directory")));
+            }
+        }
+        let from_dir = if normalized.is_empty() {
+            None
+        } else {
+            Some(list_path.as_path())
+        };
+
+        let iter = tree.list_files(Some(false), from_dir, Some(false), Some(false))?;
+        let mut raw: Vec<(String, Kind, Option, RevisionId)> = Vec::new();
+        for item in iter {
+            let (rel, _v, kind, entry) = item?;
+            let size = match &entry {
+                breezyshim::tree::TreeEntry::File { size, .. } => Some(*size),
+                _ => None,
+            };
+            let name = rel
+                .file_name()
+                .map(|n| n.to_string_lossy().into_owned())
+                .unwrap_or_else(|| rel.to_string_lossy().into_owned());
+            let full = if normalized.is_empty() {
+                name.clone()
+            } else {
+                format!("{normalized}/{name}")
+            };
+            let child_revid = tree
+                .get_file_revision(StdPath::new(&full))
+                .unwrap_or_else(|_| revid.clone());
+            raw.push((name, kind, size, child_revid));
+        }
+        let unique_revids: std::collections::HashSet =
+            raw.iter().map(|(_, _, _, r)| r.clone()).collect();
+        let unique_vec: Vec<_> = unique_revids.into_iter().collect();
+        let change_by_id: std::collections::HashMap = history
+            .get_changes(&branch, &unique_vec)?
+            .into_iter()
+            .map(|c| (c.revid.clone(), c.revno))
+            .collect();
+
+        let mut entries: Vec = raw
+            .into_iter()
+            .map(|(name, kind, size, child_revid)| FileEntry {
+                name,
+                kind: match kind {
+                    Kind::File => "file".into(),
+                    Kind::Directory => "directory".into(),
+                    Kind::Symlink => "symlink".into(),
+                    Kind::TreeReference => "tree-reference".into(),
+                },
+                size,
+                last_revno: change_by_id
+                    .get(&child_revid)
+                    .cloned()
+                    .unwrap_or_else(|| "?".into()),
+                last_revid: String::from_utf8_lossy(child_revid.as_bytes()).into_owned(),
+            })
+            .collect();
+        entries.sort_by(|a, b| {
+            (b.kind == "directory")
+                .cmp(&(a.kind == "directory"))
+                .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
+        });
+
+        Ok(FilesJson {
+            revno,
+            revid: String::from_utf8_lossy(revid.as_bytes()).into_owned(),
+            path: normalized,
+            entries,
+        })
+    })
+    .await??;
+    Ok(Json(out))
+}
+
+/// JSON body for `GET /+json/+filediff/:new_revid/:old_revid/*path`.
+#[derive(Serialize)]
+pub struct FileDiffJson {
+    pub chunks: Vec,
+}
+
+#[derive(Serialize)]
+pub struct DiffChunk {
+    pub header: String,
+    pub lines: Vec,
+}
+
+#[derive(Serialize)]
+pub struct DiffLine {
+    pub old_lineno: Option,
+    pub new_lineno: Option,
+    pub kind: &'static str,
+    pub text: String,
+}
+
+pub async fn filediff(
+    State(state): State>,
+    Path((new_revid_enc, old_revid_enc, path_enc)): Path<(String, String, String)>,
+) -> AppResult> {
+    let new_revid = RevisionId::from(
+        percent_decode_str(&new_revid_enc)
+            .decode_utf8_lossy()
+            .into_owned()
+            .into_bytes(),
+    );
+    let old_revid = RevisionId::from(
+        percent_decode_str(&old_revid_enc)
+            .decode_utf8_lossy()
+            .into_owned()
+            .into_bytes(),
+    );
+    let path = percent_decode_str(&path_enc)
+        .decode_utf8_lossy()
+        .into_owned();
+    let out = tokio::task::spawn_blocking(move || -> AppResult {
+        let branch = open_branch(&state.root)?;
+        let _lock = branch.lock_read()?;
+        let repo = branch.repository();
+        let new_tree = repo.revision_tree(&new_revid)?;
+        let old_tree = repo.revision_tree(&old_revid)?;
+        let p = StdPath::new(&path);
+        let new_text = new_tree
+            .get_file_text(p)
+            .map(|b| String::from_utf8_lossy(&b).into_owned())
+            .unwrap_or_default();
+        let old_text = old_tree
+            .get_file_text(p)
+            .map(|b| String::from_utf8_lossy(&b).into_owned())
+            .unwrap_or_default();
+
+        let diff = TextDiff::from_lines(&old_text, &new_text);
+        let mut chunks: Vec = Vec::new();
+        for group in diff.grouped_ops(3) {
+            if group.is_empty() {
+                continue;
+            }
+            let first = &group[0];
+            let last = &group[group.len() - 1];
+            let old_start = first.as_tag_tuple().1.start + 1;
+            let new_start = first.as_tag_tuple().2.start + 1;
+            let old_len = last.as_tag_tuple().1.end - first.as_tag_tuple().1.start;
+            let new_len = last.as_tag_tuple().2.end - first.as_tag_tuple().2.start;
+            let header = format!("@@ -{old_start},{old_len} +{new_start},{new_len} @@");
+            let mut lines = Vec::new();
+            for op in group {
+                for change in diff.iter_changes(&op) {
+                    let kind = match change.tag() {
+                        ChangeTag::Equal => "context",
+                        ChangeTag::Delete => "delete",
+                        ChangeTag::Insert => "insert",
+                    };
+                    let text = change
+                        .value()
+                        .to_string()
+                        .trim_end_matches('\n')
+                        .to_string();
+                    lines.push(DiffLine {
+                        old_lineno: change.old_index().map(|i| i + 1),
+                        new_lineno: change.new_index().map(|i| i + 1),
+                        kind,
+                        text,
+                    });
+                }
+            }
+            chunks.push(DiffChunk { header, lines });
+        }
+        Ok(FileDiffJson { chunks })
+    })
+    .await??;
+    Ok(Json(out))
+}
diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs
new file mode 100644
index 00000000..c2432fc8
--- /dev/null
+++ b/src/controllers/mod.rs
@@ -0,0 +1,13 @@
+pub mod annotate;
+pub mod atom;
+pub mod changelog;
+pub mod diff;
+pub mod directory;
+pub mod download;
+pub mod filediff;
+pub mod inventory;
+pub mod json;
+pub mod revision;
+pub mod revlog;
+pub mod search;
+pub mod view;
diff --git a/src/controllers/revision.rs b/src/controllers/revision.rs
new file mode 100644
index 00000000..944e8356
--- /dev/null
+++ b/src/controllers/revision.rs
@@ -0,0 +1,291 @@
+use std::sync::Arc;
+
+use askama::Template;
+use axum::extract::{Path, Query, State};
+use axum::response::Html;
+use breezyshim::branch::Branch;
+use chrono::{FixedOffset, TimeZone};
+use percent_encoding::percent_decode_str;
+use serde::Deserialize;
+
+use crate::app::AppState;
+use crate::breezy::open_branch;
+use crate::history::{Change, FileChange, FileChangeKind, History};
+use crate::util::errors::{AppError, AppResult};
+use crate::util::fmt::{hide_email, utc_iso};
+
+/// Query parameters accepted by `/revision/:revid` (and /:revid/*path).
+#[derive(Debug, Default, Deserialize)]
+pub struct RevisionQuery {
+    /// Log-context anchor: "we're viewing this revision inside a log
+    /// that starts at start_revid". Preserved through all navigation
+    /// links on the page so the user can return to /changes with the
+    /// same window.
+    pub start_revid: Option,
+    /// "Compare with another revision" mode: the user clicked the
+    /// compare link on this revno, so we should render every other
+    /// revision link with `compare_revid=` attached.
+    pub remember: Option,
+    /// Active diff-against base. When set, file_changes and per-file
+    /// diff links are computed against this revision rather than the
+    /// first-parent of the displayed revision.
+    pub compare_revid: Option,
+}
+
+#[derive(Template)]
+#[template(path = "revision.html")]
+struct RevisionTemplate {
+    // shared base fields
+    nick: String,
+    fileview_active: bool,
+    url_prefix: String,
+    // page-specific
+    revno: String,
+    revid_hex: String,
+    author: String,
+    #[allow(dead_code)]
+    committer: String,
+    utc_iso: String,
+    #[allow(dead_code)]
+    date: String,
+    message: String,
+    /// Bug URLs attached to this revision (from the `bugs` revision
+    /// property). Rendered as clickable links above the message.
+    bugs: Vec,
+    /// Foreign-VCS metadata shown alongside the bzr revid (e.g. the
+    /// git SHA-1 for a git-backed branch).
+    foreign: Option,
+    parents: Vec,
+    added: Vec,
+    removed: Vec,
+    modified: Vec,
+    renamed: Vec,
+    /// JSON map `{ "diff-N": "//" }` consumed
+    /// by `static/javascript/diff.js` to build `/+filediff/...` URLs.
+    link_data: String,
+    /// JSON map `{ "": "diff-N" }` for anchor → diff-box lookup.
+    path_to_id: String,
+    /// Revno we're currently comparing against (if any), for the
+    /// "viewing diff vs revision X" banner.
+    compare_revno: Option,
+    /// Revno stashed for the "remember" mechanism — displayed in the
+    /// "Click another revision to compare with N" banner.
+    remember_revno: Option,
+    /// Query-string suffix to append to revision-navigation links so
+    /// start_revid / remember / compare_revid are preserved.
+    nav_qs: String,
+}
+
+struct ParentView {
+    revno: String,
+    #[allow(dead_code)]
+    revid_hex: String,
+}
+
+struct ForeignView {
+    abbreviation: String,
+    foreign_revid: String,
+}
+
+struct FileChangeView {
+    path: String,
+    old_path: Option,
+}
+
+impl From for FileChangeView {
+    fn from(c: FileChange) -> Self {
+        FileChangeView {
+            path: c.path,
+            old_path: c.old_path,
+        }
+    }
+}
+
+/// `GET /revision/:revid` — render the revision page.
+pub async fn show(
+    State(state): State>,
+    Path(idref): Path,
+    Query(q): Query,
+) -> AppResult> {
+    render(state, idref, q).await
+}
+
+/// `GET /revision/:revid/*path` — same page; the path is used by the
+/// anchor in the URL, which Python loggerhead links to from the file
+/// list inside each revision view. Our template already assigns
+/// `id=""` to each diff box.
+pub async fn show_with_path(
+    State(state): State>,
+    Path((idref, _path)): Path<(String, String)>,
+    Query(q): Query,
+) -> AppResult> {
+    render(state, idref, q).await
+}
+
+async fn render(state: Arc, idref: String, q: RevisionQuery) -> AppResult> {
+    let idref = percent_decode_str(&idref).decode_utf8_lossy().into_owned();
+
+    let state2 = state.clone();
+    let compare_ref = q.compare_revid.clone();
+    let (nick, change, file_changes, compare_revno): (
+        String,
+        Change,
+        Vec,
+        Option,
+    ) = tokio::task::spawn_blocking(move || -> AppResult<_> {
+        let branch = open_branch(&state2.root)?;
+        let _lock = branch.lock_read()?;
+        let whole = state2.load_whole_history(&branch)?;
+        let history = History::from_whole(&branch, (*whole).clone())?;
+        let revid = history
+            .fix_revid(&idref)
+            .ok_or_else(|| AppError::NotFound(format!("no revision {idref}")))?;
+        if !history.whole.index.contains_key(&revid) {
+            return Err(AppError::NotFound(format!(
+                "revision {idref} not in branch"
+            )));
+        }
+        let changes = history.get_changes(&branch, std::slice::from_ref(&revid))?;
+        let change = changes
+            .into_iter()
+            .next()
+            .ok_or_else(|| AppError::NotFound("revision data missing".into()))?;
+
+        let (file_changes, compare_revno) = match compare_ref.as_deref() {
+            Some(cr) => {
+                let base = history
+                    .fix_revid(cr)
+                    .ok_or_else(|| AppError::NotFound(format!("no revision {cr}")))?;
+                let diffs = history.file_changes_between(&branch, &base, &revid)?;
+                (diffs, Some(history.whole.get_revno(&base)))
+            }
+            None => (history.get_file_changes(&branch, &revid)?, None),
+        };
+        Ok::<_, AppError>((history.nick, change, file_changes, compare_revno))
+    })
+    .await??;
+
+    let tz = FixedOffset::east_opt(change.timezone).unwrap_or(FixedOffset::east_opt(0).unwrap());
+    let date = tz
+        .timestamp_opt(change.timestamp as i64, 0)
+        .single()
+        .map(|d| d.format("%Y-%m-%d %H:%M:%S %z").to_string())
+        .unwrap_or_default();
+
+    let mut added = Vec::new();
+    let mut removed = Vec::new();
+    let mut modified = Vec::new();
+    let mut renamed = Vec::new();
+    for f in file_changes {
+        let view = FileChangeView::from(f.clone());
+        match f.kind {
+            FileChangeKind::Added => added.push(view),
+            FileChangeKind::Removed => removed.push(view),
+            FileChangeKind::Modified => modified.push(view),
+            FileChangeKind::Renamed | FileChangeKind::Copied => renamed.push(view),
+            FileChangeKind::KindChanged => modified.push(view),
+        }
+    }
+
+    // Build the JSON maps consumed by static/javascript/diff.js.
+    // `link_data["diff-N"]` is the `//` fragment that
+    // diff.js uses to build /+filediff URLs; `path_to_id` is the
+    // inverse anchor lookup. Each element is percent-encoded the same
+    // way Python's util.dq wraps it.
+    let new_revid_enc = percent_encoding::utf8_percent_encode(
+        &String::from_utf8_lossy(change.revid.as_bytes()),
+        percent_encoding::NON_ALPHANUMERIC,
+    )
+    .to_string();
+    // Diff base for per-file diff URLs: the compare_revid if we were
+    // asked to compare, otherwise the first parent.
+    let old_revid_enc = match q.compare_revid.as_deref() {
+        Some(cr) => percent_encoding::utf8_percent_encode(cr, percent_encoding::NON_ALPHANUMERIC)
+            .to_string(),
+        None => change
+            .parents
+            .first()
+            .map(|(p, _)| {
+                percent_encoding::utf8_percent_encode(
+                    &String::from_utf8_lossy(p.as_bytes()),
+                    percent_encoding::NON_ALPHANUMERIC,
+                )
+                .to_string()
+            })
+            .unwrap_or_default(),
+    };
+    let mut link_obj = serde_json::Map::new();
+    let mut path_obj = serde_json::Map::new();
+    for (i, f) in modified.iter().enumerate() {
+        let id = format!("diff-{i}");
+        link_obj.insert(
+            id.clone(),
+            serde_json::Value::String(format!("{}/{}/{}", new_revid_enc, old_revid_enc, f.path)),
+        );
+        path_obj.insert(f.path.clone(), serde_json::Value::String(id));
+    }
+    let link_data = serde_json::Value::Object(link_obj).to_string();
+    let path_to_id = serde_json::Value::Object(path_obj).to_string();
+
+    // Build the query string to preserve on navigation links. We
+    // preserve start_revid always; remember/compare only when they
+    // apply to where we're heading (the template conditionalises
+    // which of the two to emit at each link site).
+    let mut nav_params: Vec<(&str, String)> = Vec::new();
+    if let Some(s) = q.start_revid.as_deref().filter(|s| !s.is_empty()) {
+        nav_params.push(("start_revid", s.to_string()));
+    }
+    let nav_qs = if nav_params.is_empty() {
+        String::new()
+    } else {
+        let parts: Vec = nav_params
+            .iter()
+            .map(|(k, v)| {
+                format!(
+                    "{k}={}",
+                    percent_encoding::utf8_percent_encode(v, percent_encoding::NON_ALPHANUMERIC)
+                )
+            })
+            .collect();
+        format!("?{}", parts.join("&"))
+    };
+
+    // Resolve remember/compare to their display revnos if set.
+    let remember_revno = q.remember.as_deref().map(|r| r.to_string());
+
+    let tmpl = RevisionTemplate {
+        nick,
+        fileview_active: false,
+        url_prefix: state.url_prefix.clone(),
+        revno: change.revno,
+        revid_hex: String::from_utf8_lossy(change.revid.as_bytes()).into_owned(),
+        author: hide_email(&change.committer),
+        utc_iso: utc_iso(change.timestamp, change.timezone),
+        committer: change.committer,
+        date,
+        message: change.message,
+        bugs: change.bugs,
+        foreign: change.foreign.map(|f| ForeignView {
+            abbreviation: f.abbreviation,
+            foreign_revid: f.foreign_revid,
+        }),
+        parents: change
+            .parents
+            .into_iter()
+            .map(|(p, revno)| ParentView {
+                revno,
+                revid_hex: String::from_utf8_lossy(p.as_bytes()).into_owned(),
+            })
+            .collect(),
+        added,
+        removed,
+        modified,
+        renamed,
+        link_data,
+        path_to_id,
+        compare_revno,
+        remember_revno,
+        nav_qs,
+    };
+    Ok(Html(tmpl.render()?))
+}
diff --git a/src/controllers/revlog.rs b/src/controllers/revlog.rs
new file mode 100644
index 00000000..f4647cf7
--- /dev/null
+++ b/src/controllers/revlog.rs
@@ -0,0 +1,96 @@
+use std::sync::Arc;
+
+use askama::Template;
+use axum::extract::{Path, State};
+use axum::response::Html;
+use breezyshim::branch::Branch;
+use percent_encoding::percent_decode_str;
+
+use crate::app::AppState;
+use crate::breezy::open_branch;
+use crate::history::{FileChangeKind, History};
+use crate::util::errors::{AppError, AppResult};
+use crate::util::fmt::hide_email;
+
+#[derive(Template)]
+#[template(path = "revlog.html")]
+struct RevLogTemplate {
+    url_prefix: String,
+    author: String,
+    parents: Vec,
+    bugs: Vec,
+    file_changes: Vec,
+}
+
+struct ParentView {
+    revno: String,
+}
+
+struct FileChangeEntry {
+    kind: &'static str,
+    path: String,
+}
+
+/// GET /+revlog/:revid — HTML fragment for the "expand a row"
+/// interaction on the changelog page. The rendered fragment is
+/// consumed by `static/javascript/custom.js::Collapsible._load_finished`,
+/// which discards the first line (using `data.split('\n').splice(0, 1)`)
+/// and inserts the rest as HTML — so the template starts with a
+/// leading blank line deliberately. Matches the shape of Python
+/// loggerhead's `revlog.pt`.
+pub async fn show(
+    State(state): State>,
+    Path(revid_enc): Path,
+) -> AppResult> {
+    let idref = percent_decode_str(&revid_enc)
+        .decode_utf8_lossy()
+        .into_owned();
+    let url_prefix = state.url_prefix.clone();
+    let tmpl = tokio::task::spawn_blocking(move || -> AppResult {
+        let branch = open_branch(&state.root)?;
+        let _lock = branch.lock_read()?;
+        let whole = state.load_whole_history(&branch)?;
+        let history = History::from_whole(&branch, (*whole).clone())?;
+        let revid = history
+            .fix_revid(&idref)
+            .ok_or_else(|| AppError::NotFound(format!("no revision {idref}")))?;
+        if !history.whole.index.contains_key(&revid) {
+            return Err(AppError::NotFound(format!(
+                "revision {idref} not in branch"
+            )));
+        }
+        let change = history
+            .get_changes(&branch, std::slice::from_ref(&revid))?
+            .into_iter()
+            .next()
+            .ok_or_else(|| AppError::NotFound("revision data missing".into()))?;
+        let file_changes = history.get_file_changes(&branch, &revid)?;
+        Ok(RevLogTemplate {
+            url_prefix: url_prefix.clone(),
+            author: hide_email(&change.committer),
+            parents: change
+                .parents
+                .into_iter()
+                .map(|(_, revno)| ParentView { revno })
+                .collect(),
+            bugs: change.bugs,
+            file_changes: file_changes
+                .into_iter()
+                .map(|f| FileChangeEntry {
+                    kind: match f.kind {
+                        FileChangeKind::Added => "added",
+                        FileChangeKind::Removed => "removed",
+                        FileChangeKind::Modified => "modified",
+                        FileChangeKind::Renamed => "renamed",
+                        FileChangeKind::Copied => "copied",
+                        FileChangeKind::KindChanged => "kind-changed",
+                    },
+                    path: f.path,
+                })
+                .collect(),
+        })
+    })
+    .await??;
+
+    Ok(Html(tmpl.render()?))
+}
diff --git a/src/controllers/search.rs b/src/controllers/search.rs
new file mode 100644
index 00000000..4e1c480b
--- /dev/null
+++ b/src/controllers/search.rs
@@ -0,0 +1,104 @@
+use std::sync::Arc;
+
+use askama::Template;
+use axum::extract::{Query, State};
+use axum::response::Html;
+use breezyshim::branch::Branch;
+use breezyshim::revisionid::RevisionId;
+use breezyshim::search::{self, Hit, SearchError};
+use serde::Deserialize;
+
+use crate::app::AppState;
+use crate::breezy::open_branch;
+use crate::history::History;
+use crate::util::errors::{AppError, AppResult};
+
+#[derive(Deserialize, Default)]
+pub struct SearchQuery {
+    #[serde(default)]
+    pub q: Option,
+}
+
+#[derive(Template)]
+#[template(path = "search.html")]
+struct SearchTemplate {
+    // base
+    nick: String,
+    fileview_active: bool,
+    url_prefix: String,
+    // page
+    query: String,
+    /// True iff the `breezy.plugins.search` plugin is importable AND the
+    /// branch has been indexed.
+    available: bool,
+    results: Vec,
+}
+
+struct ResultRow {
+    revno: String,
+    short_message: String,
+}
+
+/// GET /search?q=… — search across commit messages / file contents
+/// using the `bzr-search` plugin if it's installed and the branch is
+/// indexed; otherwise render a "search unavailable" notice.
+pub async fn show(
+    State(state): State>,
+    Query(q): Query,
+) -> AppResult> {
+    let query = q.q.unwrap_or_default();
+    let query_for_task = query.clone();
+    let state_for_task = state.clone();
+
+    let (nick, available, results) = tokio::task::spawn_blocking(move || -> AppResult<_> {
+        let branch = open_branch(&state_for_task.root)?;
+        let _lock = branch.lock_read()?;
+        let whole = state_for_task.load_whole_history(&branch)?;
+        let history = History::from_whole(&branch, (*whole).clone())?;
+
+        if query_for_task.is_empty() {
+            // Just render the form.
+            return Ok::<_, AppError>((history.nick, search::is_available(), Vec::new()));
+        }
+
+        let hits = match search::search(&branch, &query_for_task) {
+            Ok(h) => h,
+            Err(SearchError::Unavailable) | Err(SearchError::NoIndex) => {
+                return Ok((history.nick, false, Vec::new()));
+            }
+            Err(SearchError::Other(e)) => return Err(AppError::from(e)),
+        };
+
+        // De-duplicate to the set of revids; expand to rows with revno/msg.
+        let mut seen = std::collections::HashSet::new();
+        let revids: Vec = hits
+            .into_iter()
+            .map(|h| match h {
+                Hit::Revision(r) => r,
+                Hit::FileText { revision, .. } => revision,
+            })
+            .filter(|r| seen.insert(r.clone()))
+            .collect();
+
+        let changes = history.get_changes(&branch, &revids)?;
+        let results: Vec = changes
+            .into_iter()
+            .map(|c| ResultRow {
+                revno: c.revno,
+                short_message: c.short_message,
+            })
+            .collect();
+        Ok((history.nick, true, results))
+    })
+    .await??;
+
+    let tmpl = SearchTemplate {
+        nick,
+        fileview_active: false,
+        url_prefix: state.url_prefix.clone(),
+        query,
+        available,
+        results,
+    };
+    Ok(Html(tmpl.render()?))
+}
diff --git a/src/controllers/view.rs b/src/controllers/view.rs
new file mode 100644
index 00000000..17b3f4c3
--- /dev/null
+++ b/src/controllers/view.rs
@@ -0,0 +1,145 @@
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use askama::Template;
+use axum::extract::{Path, State};
+use axum::response::{Html, IntoResponse, Redirect, Response};
+use breezyshim::branch::Branch;
+use breezyshim::repository::Repository;
+use breezyshim::tree::{Kind, Tree};
+
+use crate::app::AppState;
+use crate::breezy::open_branch;
+use crate::highlight::highlight;
+use crate::history::History;
+use crate::util::errors::{AppError, AppResult};
+
+#[derive(Template)]
+#[template(path = "view.html")]
+struct ViewTemplate {
+    // shared base
+    nick: String,
+    fileview_active: bool,
+    url_prefix: String,
+    // page-specific
+    revno: String,
+    path: String,
+    lines: Vec,
+    #[allow(dead_code)]
+    background: Option,
+    is_binary: bool,
+}
+
+struct Line {
+    n: usize,
+    html: String,
+}
+
+/// One of two outcomes when looking up a /view target.
+enum Lookup {
+    File {
+        nick: String,
+        content: Vec,
+        revno: String,
+    },
+    /// Target is a directory; redirect to `/files//`.
+    Directory { revno: String },
+}
+
+/// `GET /view/:revno/*path` — view file at a specific revision.
+/// Use `/view/head:/path` for the branch tip.
+pub async fn show(
+    State(state): State>,
+    Path((revno, path)): Path<(String, String)>,
+) -> AppResult {
+    render(state, Some(revno), path).await
+}
+
+async fn render(
+    state: Arc,
+    revno_req: Option,
+    path: String,
+) -> AppResult {
+    let path_norm = path.trim_matches('/').to_string();
+    if path_norm.is_empty() {
+        return Err(AppError::Other("no filename provided".into()));
+    }
+    let path_for_task = path_norm.clone();
+    let url_prefix = state.url_prefix.clone();
+    let state_for_redirect = state.clone();
+
+    let outcome = tokio::task::spawn_blocking(move || -> AppResult {
+        let branch = open_branch(&state.root)?;
+        let _lock = branch.lock_read()?;
+        let whole = state.load_whole_history(&branch)?;
+        let history = History::from_whole(&branch, (*whole).clone())?;
+        let revid = match revno_req.as_deref() {
+            Some(r) => history
+                .fix_revid(r)
+                .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?,
+            None => history.last_revid.clone(),
+        };
+        let revno = history.whole.get_revno(&revid);
+        let repo = branch.repository();
+        let tree = repo.revision_tree(&revid)?;
+        let p = PathBuf::from(&path_for_task);
+        if !tree.has_filename(&p) {
+            return Err(AppError::NotFound(format!("{path_for_task} not found")));
+        }
+        match tree.kind(&p) {
+            Ok(Kind::File) => {}
+            Ok(Kind::Directory) => return Ok(Lookup::Directory { revno }),
+            Ok(_) => {
+                return Err(AppError::Other(format!(
+                    "{path_for_task} is not a regular file"
+                )))
+            }
+            Err(e) => return Err(AppError::from(e)),
+        }
+        let bytes = tree.get_file_text(&p)?;
+        Ok(Lookup::File {
+            nick: history.nick,
+            content: bytes,
+            revno,
+        })
+    })
+    .await??;
+
+    let (nick, content, revno) = match outcome {
+        Lookup::File {
+            nick,
+            content,
+            revno,
+        } => (nick, content, revno),
+        Lookup::Directory { revno } => {
+            let target = state_for_redirect.url(&format!("/files/{revno}/{path_norm}"));
+            return Ok(Redirect::permanent(&target).into_response());
+        }
+    };
+
+    let (lines, background, is_binary) = match std::str::from_utf8(&content) {
+        Ok(text) => {
+            let hl = highlight(&path_norm, text);
+            let ls: Vec = hl
+                .lines
+                .into_iter()
+                .enumerate()
+                .map(|(i, html)| Line { n: i + 1, html })
+                .collect();
+            (ls, hl.background, false)
+        }
+        Err(_) => (Vec::new(), None, true),
+    };
+
+    let tmpl = ViewTemplate {
+        nick,
+        fileview_active: true,
+        url_prefix,
+        revno,
+        path: path_norm,
+        lines,
+        background,
+        is_binary,
+    };
+    Ok(Html(tmpl.render()?).into_response())
+}
diff --git a/src/highlight.rs b/src/highlight.rs
new file mode 100644
index 00000000..73886587
--- /dev/null
+++ b/src/highlight.rs
@@ -0,0 +1,134 @@
+//! Syntax highlighting via [`syntect`], replacing the Python loggerhead's
+//! Pygments wrapper in `loggerhead/highlight.py`.
+
+use std::sync::OnceLock;
+
+use syntect::highlighting::{Theme, ThemeSet};
+use syntect::html::{
+    append_highlighted_html_for_styled_line, start_highlighted_html_snippet, IncludeBackground,
+};
+use syntect::parsing::{SyntaxReference, SyntaxSet};
+use syntect::util::LinesWithEndings;
+
+/// Cap matching loggerhead's 512KB threshold — above this we return escaped
+/// plain text instead of paying the highlighting cost.
+const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024;
+
+fn assets() -> &'static (SyntaxSet, Theme) {
+    static CELL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
+    CELL.get_or_init(|| {
+        let ss = SyntaxSet::load_defaults_newlines();
+        let ts = ThemeSet::load_defaults();
+        let theme = ts.themes["InspiredGitHub"].clone();
+        (ss, theme)
+    })
+}
+
+/// Pick a syntax by filename; fall back to plain text.
+fn syntax_for<'a>(ss: &'a SyntaxSet, filename: &str, first_line: &str) -> &'a SyntaxReference {
+    ss.find_syntax_for_file(filename)
+        .ok()
+        .flatten()
+        .or_else(|| ss.find_syntax_by_first_line(first_line))
+        .unwrap_or_else(|| ss.find_syntax_plain_text())
+}
+
+/// Result of highlighting: HTML fragments, one per source line, without the
+/// outer `
`. The caller is responsible for wrapping in `
` or a
+/// table of (line number, content) cells.
+pub struct Highlighted {
+    /// One ``-wrapped HTML string per source line.
+    pub lines: Vec,
+    /// Suggested background color (rgba) for wrapping, from the theme.
+    pub background: Option,
+}
+
+/// Highlight `content` as the language inferred from `filename`. If the file
+/// is larger than [`MAX_HIGHLIGHT_BYTES`] or syntect fails, returns escaped
+/// plain text (one HTML-escaped line per input line).
+pub fn highlight(filename: &str, content: &str) -> Highlighted {
+    if content.len() > MAX_HIGHLIGHT_BYTES {
+        return plain(content);
+    }
+    let (ss, theme) = assets();
+    let first_line = content.lines().next().unwrap_or("");
+    let syntax = syntax_for(ss, filename, first_line);
+    let mut hl = syntect::easy::HighlightLines::new(syntax, theme);
+
+    let mut out = Vec::new();
+    let (_prelude, bg) = start_highlighted_html_snippet(theme);
+    for line in LinesWithEndings::from(content) {
+        let mut piece = String::new();
+        let regions = match hl.highlight_line(line, ss) {
+            Ok(r) => r,
+            Err(_) => return plain(content),
+        };
+        if append_highlighted_html_for_styled_line(®ions, IncludeBackground::No, &mut piece)
+            .is_err()
+        {
+            return plain(content);
+        }
+        // Strip the trailing newline that `LinesWithEndings` keeps so the
+        // renderer can lay lines out in its own table.
+        let piece = piece.trim_end_matches('\n').to_string();
+        out.push(piece);
+    }
+    Highlighted {
+        lines: out,
+        background: Some(format!("rgb({}, {}, {})", bg.r, bg.g, bg.b)),
+    }
+}
+
+fn plain(content: &str) -> Highlighted {
+    let lines = content.lines().map(html_escape::encode_safe_str).collect();
+    Highlighted {
+        lines,
+        background: None,
+    }
+}
+
+/// Very small subset of HTML escaping — enough for file-view safety.
+mod html_escape {
+    pub fn encode_safe_str(s: &str) -> String {
+        let mut out = String::with_capacity(s.len());
+        for c in s.chars() {
+            match c {
+                '&' => out.push_str("&"),
+                '<' => out.push_str("<"),
+                '>' => out.push_str(">"),
+                '"' => out.push_str("""),
+                '\'' => out.push_str("'"),
+                _ => out.push(c),
+            }
+        }
+        out
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn plain_text_escapes() {
+        let h = highlight("README", "a < b\n1 & 2\n");
+        assert_eq!(h.lines.len(), 2);
+        // Syntect can also emit HTML for plain text; just check it's valid-ish.
+        assert!(h.lines[0].contains("a ") || h.lines[0].contains("<"));
+    }
+
+    #[test]
+    fn rust_keywords_get_spans() {
+        let src = "fn main() {}\n";
+        let h = highlight("main.rs", src);
+        assert!(h.lines[0].contains(",
+    /// Revisions that list this revision among their parents.
+    pub children: Vec,
+}
+
+/// The result of computing the whole-branch history graph.
+///
+/// `entries` is laid out in merge-sort order (tip first). `index` gives the
+/// position of each revid in `entries` for O(1) lookup.
+#[derive(Debug, Clone)]
+pub struct WholeHistory {
+    pub entries: Vec,
+    pub index: HashMap,
+    /// Unix timestamp of the branch tip, for Last-Modified / 304
+    /// responses. `None` for an empty branch.
+    pub tip_timestamp: Option,
+}
+
+impl WholeHistory {
+    /// Compute the whole-history graph for a branch, mirroring
+    /// `wholehistory.compute_whole_history_data`.
+    pub fn compute(branch: &dyn Branch) -> Result {
+        let repo = branch.repository();
+        let last_revid = branch.last_revision();
+        let graph: Graph = repo.get_graph();
+
+        // Build parent map from iter_ancestry, dropping ghost entries.
+        let mut parent_map: HashMap> = HashMap::new();
+        for entry in graph.iter_ancestry(std::slice::from_ref(&last_revid))? {
+            let (node, parents) = entry?;
+            if let Some(ps) = parents {
+                parent_map.insert(node, ps);
+            }
+        }
+        // Drop NULL_REVISION and any references to nodes not present in the
+        // map (_strip_NULL_ghosts).
+        let null = RevisionId::null();
+        parent_map.remove(&null);
+        let present: std::collections::HashSet = parent_map.keys().cloned().collect();
+        for parents in parent_map.values_mut() {
+            parents.retain(|p| present.contains(p));
+        }
+
+        let sorted: Vec> = if last_revid.is_null() {
+            Vec::new()
+        } else {
+            merge_sort(&parent_map, &last_revid)?
+        };
+
+        let mut entries: Vec = Vec::with_capacity(sorted.len());
+        let mut index: HashMap = HashMap::with_capacity(sorted.len());
+        for e in sorted {
+            let parents = parent_map.get(&e.node).cloned().unwrap_or_default();
+            let pos = entries.len();
+            index.insert(e.node.clone(), pos);
+            let revno_str = e.revno_str();
+            entries.push(RevInfo {
+                sequence: e.sequence,
+                revid: e.node,
+                merge_depth: e.merge_depth,
+                revno: revno_str,
+                end_of_merge: e.end_of_merge,
+                parents,
+                children: Vec::new(),
+            });
+        }
+
+        // Second pass: compute children, skipping mainline entries (matches
+        // wholehistory.py's `merge_depth == 0` skip).
+        for i in 0..entries.len() {
+            if entries[i].merge_depth == 0 {
+                continue;
+            }
+            let revid = entries[i].revid.clone();
+            let parents = entries[i].parents.clone();
+            for parent in parents {
+                if let Some(&pi) = index.get(&parent) {
+                    if !entries[pi].children.contains(&revid) {
+                        entries[pi].children.push(revid.clone());
+                    }
+                }
+            }
+        }
+
+        // Tip timestamp for Last-Modified. Fetch the tip's Revision
+        // once; cheap compared to the merge-sort we just did.
+        let tip_timestamp = if last_revid.is_null() {
+            None
+        } else {
+            repo.get_revision(&last_revid).ok().map(|r| r.timestamp)
+        };
+
+        Ok(WholeHistory {
+            entries,
+            index,
+            tip_timestamp,
+        })
+    }
+
+    pub fn len(&self) -> usize {
+        self.entries.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.entries.is_empty()
+    }
+
+    /// Look up the dotted revno for a revision id; returns `"unknown"` for
+    /// missing (ghost) revisions, matching `History.get_revno`.
+    pub fn get_revno(&self, revid: &RevisionId) -> String {
+        match self.index.get(revid) {
+            Some(&i) => self.entries[i].revno.clone(),
+            None => "unknown".to_string(),
+        }
+    }
+}
+
+/// One entry in a revision's file-change list.
+#[derive(Debug, Clone)]
+pub struct FileChange {
+    pub kind: FileChangeKind,
+    /// Canonical path for display: new path for add/modify/rename-target,
+    /// old path for remove.
+    pub path: String,
+    /// When the change is a rename (or copy), this is the prior path.
+    pub old_path: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FileChangeKind {
+    Added,
+    Removed,
+    Modified,
+    Renamed,
+    Copied,
+    KindChanged,
+}
+
+/// A revision plus the rendered fields needed by the changelog template.
+#[derive(Debug, Clone)]
+pub struct Change {
+    pub revid: RevisionId,
+    pub revno: String,
+    pub committer: String,
+    pub timestamp: f64,
+    pub timezone: i32,
+    pub message: String,
+    pub short_message: String,
+    pub parents: Vec<(RevisionId, String)>, // (revid, revno)
+    pub tags: Vec,
+    /// Bug URLs from the `bugs` revision property. Each entry is a
+    /// URL (first whitespace-delimited token of a line). Rendered as
+    /// clickable links on the revision page.
+    pub bugs: Vec,
+    /// If the branch is git- / hg- / svn-backed, the foreign VCS
+    /// abbreviation (e.g. `"git"`) and the native foreign revid
+    /// (e.g. the git SHA-1) displayed alongside the bzr revid.
+    pub foreign: Option,
+}
+
+/// Alias re-export so controllers can refer to the type through the
+/// crate-local `history::ForeignInfo`.
+pub use breezyshim::foreign::ForeignInfo;
+
+/// Branch-scoped history object, analogous to `loggerhead.history.History`.
+pub struct History {
+    pub last_revid: RevisionId,
+    pub nick: String,
+    pub whole: WholeHistory,
+    pub branch_tags: HashMap>,
+}
+
+impl History {
+    /// Resolve a URL identifier (either a dotted revno like `"3"` or
+    /// `"1.2.3"`, the literal `"head:"`, or a raw revid) to a concrete
+    /// `RevisionId`. Mirrors Python `History.fix_revid`.
+    pub fn fix_revid(&self, raw: &str) -> Option {
+        if raw == "head:" {
+            return Some(self.last_revid.clone());
+        }
+        // A dotted revno consists of digits and dots only.
+        if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_digit() || c == '.') {
+            // Find the revid with this revno by scanning the whole-history
+            // entries. For small-to-medium branches this is fine; for very
+            // large ones we could build a revno→revid index at graph time.
+            for e in &self.whole.entries {
+                if e.revno == raw {
+                    return Some(e.revid.clone());
+                }
+            }
+            return None;
+        }
+        // Otherwise treat as a raw revid.
+        Some(RevisionId::from(raw.as_bytes().to_vec()))
+    }
+
+    /// Build a History. Caller must hold a read lock on the branch.
+    pub fn new(branch: &dyn Branch) -> Result {
+        let whole = WholeHistory::compute(branch)?;
+        Self::from_whole(branch, whole)
+    }
+
+    /// Build a History reusing a pre-computed `WholeHistory`.
+    pub fn from_whole(branch: &dyn Branch, whole: WholeHistory) -> Result {
+        let last_revid = branch.last_revision();
+        let nick = branch
+            .get_config()
+            .get_nickname()
+            .unwrap_or_else(|_| "".to_string());
+        let reverse_tags = branch
+            .tags()
+            .ok()
+            .and_then(|t| t.get_reverse_tag_dict().ok())
+            .unwrap_or_default();
+        let branch_tags: HashMap> = reverse_tags
+            .into_iter()
+            .map(|(rid, tags)| {
+                let mut v: Vec = tags.into_iter().collect();
+                v.sort();
+                (rid, v)
+            })
+            .collect();
+        Ok(History {
+            last_revid,
+            nick,
+            whole,
+            branch_tags,
+        })
+    }
+
+    pub fn has_revisions(&self) -> bool {
+        !self.last_revid.is_null()
+    }
+
+    /// Return the full merge-sorted list of revisions reachable from
+    /// `start`, in the same order as `whole.entries` (topological from
+    /// the branch tip down) but restricted to the ancestors of `start`.
+    /// Each entry carries `merge_depth`, so a consumer that wants to
+    /// render a merge-graph indentation can read it off.
+    pub fn merge_sorted_from(&self, start: &RevisionId) -> Vec {
+        let Some(&start_idx) = self.whole.index.get(start) else {
+            return Vec::new();
+        };
+
+        // Walk the DAG from `start`, collecting all ancestors (not
+        // just the mainline). We iterate by BFS over whole.index so
+        // each revision is visited once.
+        let mut reachable = std::collections::HashSet::new();
+        let mut queue = std::collections::VecDeque::new();
+        queue.push_back(start.clone());
+        while let Some(rid) = queue.pop_front() {
+            if !reachable.insert(rid.clone()) {
+                continue;
+            }
+            if let Some(&i) = self.whole.index.get(&rid) {
+                for p in &self.whole.entries[i].parents {
+                    if !reachable.contains(p) {
+                        queue.push_back(p.clone());
+                    }
+                }
+            }
+        }
+
+        // Emit entries in whole.entries order (which is merge-sort
+        // order from the branch tip), starting at start_idx — anything
+        // before `start_idx` is a descendant of `start` and should be
+        // skipped.
+        self.whole.entries[start_idx..]
+            .iter()
+            .filter(|e| reachable.contains(&e.revid))
+            .cloned()
+            .collect()
+    }
+
+    /// Yield revisions along the mainline starting at `start`, walking
+    /// first-parent pointers. Matches `get_revids_from(None, start)`.
+    pub fn mainline_from(&self, start: &RevisionId) -> Vec {
+        let mut out = Vec::new();
+        let mut cur = start.clone();
+        while !cur.is_null() {
+            out.push(cur.clone());
+            let idx = match self.whole.index.get(&cur) {
+                Some(&i) => i,
+                None => break,
+            };
+            let parents = &self.whole.entries[idx].parents;
+            if parents.is_empty() {
+                break;
+            }
+            cur = parents[0].clone();
+        }
+        out
+    }
+
+    /// Compute the per-file change list between `revid` and its first
+    /// parent, using breezy's `InterTree.compare()`. For root commits the
+    /// source tree is the null tree, so everything shows up as added.
+    pub fn get_file_changes(
+        &self,
+        branch: &dyn Branch,
+        revid: &RevisionId,
+    ) -> Result, AppError> {
+        let parents = self
+            .whole
+            .index
+            .get(revid)
+            .map(|&i| self.whole.entries[i].parents.clone())
+            .unwrap_or_default();
+        let base = parents.first().cloned().unwrap_or_else(RevisionId::null);
+        self.file_changes_between(branch, &base, revid)
+    }
+
+    /// Compute the per-file change list between two arbitrary revisions.
+    /// Used for the `?compare_revid=…` mode on the revision page.
+    pub fn file_changes_between(
+        &self,
+        branch: &dyn Branch,
+        base: &RevisionId,
+        new: &RevisionId,
+    ) -> Result, AppError> {
+        use breezyshim::intertree;
+        let repo = branch.repository();
+        let old_tree = repo.revision_tree(base)?;
+        let new_tree = repo.revision_tree(new)?;
+        let inter = intertree::get(&old_tree, &new_tree);
+        let delta = inter.compare();
+
+        let mut out = Vec::new();
+        let path_of = |c: &breezyshim::tree::TreeChange, prefer_new: bool| -> String {
+            let (old, new) = (&c.path.0, &c.path.1);
+            let chosen = if prefer_new {
+                new.as_ref().or(old.as_ref())
+            } else {
+                old.as_ref().or(new.as_ref())
+            };
+            chosen
+                .map(|p| p.to_string_lossy().into_owned())
+                .unwrap_or_default()
+        };
+
+        for c in &delta.added {
+            out.push(FileChange {
+                kind: FileChangeKind::Added,
+                path: path_of(c, true),
+                old_path: None,
+            });
+        }
+        for c in &delta.removed {
+            out.push(FileChange {
+                kind: FileChangeKind::Removed,
+                path: path_of(c, false),
+                old_path: None,
+            });
+        }
+        for c in &delta.modified {
+            out.push(FileChange {
+                kind: FileChangeKind::Modified,
+                path: path_of(c, true),
+                old_path: None,
+            });
+        }
+        for c in &delta.renamed {
+            out.push(FileChange {
+                kind: FileChangeKind::Renamed,
+                path: path_of(c, true),
+                old_path: Some(path_of(c, false)),
+            });
+        }
+        for c in &delta.copied {
+            out.push(FileChange {
+                kind: FileChangeKind::Copied,
+                path: path_of(c, true),
+                old_path: Some(path_of(c, false)),
+            });
+        }
+        for c in &delta.kind_changed {
+            out.push(FileChange {
+                kind: FileChangeKind::KindChanged,
+                path: path_of(c, true),
+                old_path: None,
+            });
+        }
+        Ok(out)
+    }
+
+    /// Fetch revision objects for the given revids and produce display
+    /// `Change` records. Ghost / null entries are skipped.
+    pub fn get_changes(
+        &self,
+        branch: &dyn Branch,
+        revids: &[RevisionId],
+    ) -> Result, AppError> {
+        let repo = branch.repository();
+        let non_null: Vec = revids.iter().filter(|r| !r.is_null()).cloned().collect();
+        let mut out = Vec::with_capacity(non_null.len());
+        for revid in non_null {
+            let rev = repo.get_revision(&revid)?;
+            let (message, short_message) = clean_message(&rev.message);
+            let parents: Vec<(RevisionId, String)> = rev
+                .parent_ids
+                .iter()
+                .map(|p| (p.clone(), self.whole.get_revno(p)))
+                .collect();
+            let tags = self.branch_tags.get(&revid).cloned().unwrap_or_default();
+            // Extract bug URLs from the `bugs` revision property.
+            // Each line is ` `; we take the URL and
+            // drop anything empty.
+            let bugs: Vec = rev
+                .properties
+                .get("bugs")
+                .map(|raw| {
+                    raw.lines()
+                        .filter_map(|line| line.split_whitespace().next().map(String::from))
+                        .filter(|s| !s.is_empty())
+                        .collect()
+                })
+                .unwrap_or_default();
+            let foreign = breezyshim::foreign::parse_foreign_revid(&rev.revision_id);
+            out.push(Change {
+                revid: rev.revision_id,
+                revno: self.whole.get_revno(&revid),
+                committer: rev.committer,
+                timestamp: rev.timestamp,
+                timezone: rev.timezone,
+                message,
+                short_message,
+                parents,
+                tags,
+                bugs,
+                foreign,
+            });
+        }
+        Ok(out)
+    }
+}
+
+/// Lightly reflow / normalize a commit message and produce a short form,
+/// mirroring `history.clean_message`.
+fn clean_message(message: &str) -> (String, String) {
+    let trimmed = message.trim_start();
+    let lines: Vec<&str> = trimmed.lines().collect();
+    if lines.is_empty() {
+        return (String::new(), String::new());
+    }
+    let first = lines[0];
+    let short = if first.chars().count() > 60 {
+        let mut s: String = first.chars().take(60).collect();
+        s.push_str("...");
+        s
+    } else {
+        first.to_string()
+    };
+    (trimmed.to_string(), short)
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 00000000..256de05d
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,15 @@
+//! Loggerhead: a web viewer for Bazaar/Breezy branches.
+//!
+//! This crate provides the HTTP server, routing, templates, and VCS glue.
+//! VCS access is via [`breezyshim`], a PyO3 wrapper around the Python
+//! `breezy` library — all VCS calls are synchronous and must run on the
+//! blocking thread pool.
+
+pub mod app;
+pub mod breezy;
+pub mod cache;
+pub mod config;
+pub mod controllers;
+pub mod highlight;
+pub mod history;
+pub mod util;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 00000000..1b93e703
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,103 @@
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::Router;
+use clap::Parser;
+use tracing_subscriber::{EnvFilter, FmtSubscriber};
+
+use loggerhead::app::{build_router, AppState};
+use loggerhead::cache::RevInfoDiskCache;
+use loggerhead::config::Args;
+
+/// Normalize a `--prefix` value: empty stays empty; otherwise a leading `/`
+/// is ensured and any trailing `/` stripped, so it composes cleanly into
+/// URLs as `{prefix}{path}`.
+fn normalize_prefix(raw: &str) -> String {
+    let trimmed = raw.trim_end_matches('/');
+    if trimmed.is_empty() {
+        return String::new();
+    }
+    if trimmed.starts_with('/') {
+        trimmed.to_string()
+    } else {
+        format!("/{trimmed}")
+    }
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    let args = Args::parse();
+
+    // Precedence: RUST_LOG env var → --log-level flag → info.
+    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
+        args.log_level
+            .as_deref()
+            .unwrap_or("info")
+            .parse::()
+            .unwrap_or_else(|_| EnvFilter::new("info"))
+    });
+    FmtSubscriber::builder().with_env_filter(filter).init();
+
+    if let Some(dir) = &args.log_folder {
+        tracing::info!(path = ?dir, "--log-folder accepted but file logging is not yet implemented; logging to stderr");
+    }
+
+    // Matches Python's "--trunk-dir is only valid with --user-dirs"
+    // and "--user-dirs requires --trunk-dir" validations.
+    if args.trunk_dir.is_some() && !args.user_dirs {
+        anyhow::bail!("--trunk-dir is only valid with --user-dirs");
+    }
+    if args.user_dirs && args.trunk_dir.is_none() {
+        anyhow::bail!("--user-dirs requires --trunk-dir");
+    }
+
+    // Initialize breezy/Python once on the main thread.
+    breezyshim::init();
+
+    let disk_cache = match &args.cachepath {
+        Some(p) => match RevInfoDiskCache::open(p) {
+            Ok(c) => Some(Arc::new(c)),
+            Err(e) => {
+                tracing::warn!(path = ?p, error = %e, "disabling disk cache");
+                None
+            }
+        },
+        None => None,
+    };
+
+    let addr = SocketAddr::new(args.host, args.port);
+    let static_dir = args.static_dir.clone().unwrap_or_else(|| {
+        // Default to the in-tree `static/` dir so a checkout "just works"
+        // without a separate install step.
+        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static")
+    });
+    if !static_dir.is_dir() {
+        tracing::warn!(path = ?static_dir, "static asset directory not found; /static/* will 404");
+    }
+
+    let prefix = normalize_prefix(&args.prefix);
+
+    let state = Arc::new(AppState::new(
+        args.root.clone(),
+        disk_cache,
+        args.export_tarballs,
+        static_dir,
+        args.user_dirs,
+        args.trunk_dir.clone(),
+        prefix.clone(),
+    ));
+
+    let inner = build_router(state);
+    let router = if prefix.is_empty() {
+        inner
+    } else {
+        // Serve the same app under `/…` when deployed behind a
+        // reverse proxy that forwards the original path.
+        Router::new().nest(&prefix, inner)
+    };
+
+    tracing::info!(%addr, root = %args.root, prefix = %args.prefix, "loggerhead starting");
+    let listener = tokio::net::TcpListener::bind(addr).await?;
+    axum::serve(listener, router).await?;
+    Ok(())
+}
diff --git a/src/util/errors.rs b/src/util/errors.rs
new file mode 100644
index 00000000..dc2722e0
--- /dev/null
+++ b/src/util/errors.rs
@@ -0,0 +1,84 @@
+use askama::Template;
+use axum::http::{header, StatusCode};
+use axum::response::{Html, IntoResponse, Response};
+
+/// Error type surfaced by controllers and returned to axum.
+#[derive(Debug, thiserror::Error)]
+pub enum AppError {
+    #[error("branch not found: {0}")]
+    NotFound(String),
+
+    #[error("breezy error: {0}")]
+    Breezy(Box),
+
+    #[error("template render: {0}")]
+    Template(#[from] askama::Error),
+
+    #[error("join error: {0}")]
+    Join(#[from] tokio::task::JoinError),
+
+    #[error("url parse: {0}")]
+    Url(#[from] url::ParseError),
+
+    #[error("{0}")]
+    Other(String),
+}
+
+#[derive(Template)]
+#[template(path = "error.html")]
+struct ErrorTemplate {
+    // base-template fields
+    nick: String,
+    #[allow(dead_code)]
+    fileview_active: bool,
+    url_prefix: String,
+    // page
+    error_title: String,
+    error_description: String,
+}
+
+impl IntoResponse for AppError {
+    fn into_response(self) -> Response {
+        let status = match &self {
+            AppError::NotFound(_) => StatusCode::NOT_FOUND,
+            _ => StatusCode::INTERNAL_SERVER_ERROR,
+        };
+        let title = match &self {
+            AppError::NotFound(_) => "Not Found".to_string(),
+            AppError::Breezy(_) => "Breezy error".to_string(),
+            AppError::Template(_) => "Template rendering error".to_string(),
+            AppError::Join(_) => "Task join error".to_string(),
+            AppError::Url(_) => "Invalid URL".to_string(),
+            AppError::Other(_) => "Error".to_string(),
+        };
+        let description = self.to_string();
+        tracing::error!(error = %self, "request failed");
+
+        let tmpl = ErrorTemplate {
+            nick: String::new(),
+            fileview_active: false,
+            url_prefix: String::new(),
+            error_title: title,
+            error_description: description.clone(),
+        };
+        match tmpl.render() {
+            Ok(body) => (
+                status,
+                [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
+                Html(body),
+            )
+                .into_response(),
+            // If rendering the error template itself fails, fall back to
+            // plain text so at least something lands on the wire.
+            Err(_) => (status, description).into_response(),
+        }
+    }
+}
+
+pub type AppResult = Result;
+
+impl From for AppError {
+    fn from(e: breezyshim::error::Error) -> Self {
+        AppError::Breezy(Box::new(e))
+    }
+}
diff --git a/src/util/fmt.rs b/src/util/fmt.rs
new file mode 100644
index 00000000..046b99cb
--- /dev/null
+++ b/src/util/fmt.rs
@@ -0,0 +1,83 @@
+//! Small formatters used by templates: author-display scrubbing, relative
+//! dates, etc. Keep this dumb/stateless so templates can call through.
+
+use chrono::{DateTime, FixedOffset, TimeZone, Utc};
+
+/// Strip the `` part from a `"Name "` committer string,
+/// matching Python loggerhead's `util.hide_email`.
+pub fn hide_email(author: &str) -> String {
+    match author.find('<') {
+        Some(i) => author[..i].trim().to_string(),
+        None => author.trim().to_string(),
+    }
+}
+
+/// Render a Breezy timestamp+timezone as the "2026-04-21 17:09:44 UTC" form
+/// Python loggerhead uses as the `` tooltip.
+pub fn utc_iso(timestamp: f64, timezone: i32) -> String {
+    let tz = FixedOffset::east_opt(timezone).unwrap_or(FixedOffset::east_opt(0).unwrap());
+    match tz.timestamp_opt(timestamp as i64, 0).single() {
+        Some(dt) => dt
+            .with_timezone(&Utc)
+            .format("%Y-%m-%d %H:%M:%S UTC")
+            .to_string(),
+        None => String::new(),
+    }
+}
+
+/// Render a Breezy timestamp as a human-readable relative-time string,
+/// mirroring Python loggerhead's `util.approximate_date`:
+///   "just now", "N minutes ago", "N hours ago", "yesterday at ...",
+///   "N days ago", "YYYY-MM-DD".
+pub fn approximate_date(timestamp: f64) -> String {
+    let Some(dt) = DateTime::::from_timestamp(timestamp as i64, 0) else {
+        return String::new();
+    };
+    let now = Utc::now();
+    let delta = now - dt;
+    let secs = delta.num_seconds();
+    if secs < 60 {
+        "just now".to_string()
+    } else if secs < 3600 {
+        let m = secs / 60;
+        format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
+    } else if secs < 86_400 {
+        let h = secs / 3600;
+        format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
+    } else if secs < 7 * 86_400 {
+        let d = secs / 86_400;
+        format!("{d} day{} ago", if d == 1 { "" } else { "s" })
+    } else {
+        dt.format("%Y-%m-%d").to_string()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn hide_email_strips_angle_block() {
+        assert_eq!(
+            hide_email("Jelmer Vernooij "),
+            "Jelmer Vernooij"
+        );
+    }
+
+    #[test]
+    fn hide_email_passthrough_when_no_angle() {
+        assert_eq!(hide_email("Anonymous"), "Anonymous");
+    }
+
+    #[test]
+    fn approximate_date_just_now() {
+        let now = Utc::now().timestamp() as f64;
+        assert_eq!(approximate_date(now), "just now");
+    }
+
+    #[test]
+    fn approximate_date_hours_ago() {
+        let t = (Utc::now() - chrono::Duration::hours(3)).timestamp() as f64;
+        assert_eq!(approximate_date(t), "3 hours ago");
+    }
+}
diff --git a/src/util/mod.rs b/src/util/mod.rs
new file mode 100644
index 00000000..9bc50363
--- /dev/null
+++ b/src/util/mod.rs
@@ -0,0 +1,2 @@
+pub mod errors;
+pub mod fmt;
diff --git a/loggerhead/static/css/diff.css b/static/css/diff.css
similarity index 100%
rename from loggerhead/static/css/diff.css
rename to static/css/diff.css
diff --git a/loggerhead/static/css/files.css b/static/css/files.css
similarity index 100%
rename from loggerhead/static/css/files.css
rename to static/css/files.css
diff --git a/loggerhead/static/css/global.css b/static/css/global.css
similarity index 100%
rename from loggerhead/static/css/global.css
rename to static/css/global.css
diff --git a/loggerhead/static/css/highlight.css b/static/css/highlight.css
similarity index 100%
rename from loggerhead/static/css/highlight.css
rename to static/css/highlight.css
diff --git a/loggerhead/static/css/view.css b/static/css/view.css
similarity index 100%
rename from loggerhead/static/css/view.css
rename to static/css/view.css
diff --git a/loggerhead/static/images/bg_Tabs.gif b/static/images/bg_Tabs.gif
similarity index 100%
rename from loggerhead/static/images/bg_Tabs.gif
rename to static/images/bg_Tabs.gif
diff --git a/loggerhead/static/images/bg_infobox.gif b/static/images/bg_infobox.gif
similarity index 100%
rename from loggerhead/static/images/bg_infobox.gif
rename to static/images/bg_infobox.gif
diff --git a/loggerhead/static/images/bg_menuTabs.gif b/static/images/bg_menuTabs.gif
similarity index 100%
rename from loggerhead/static/images/bg_menuTabs.gif
rename to static/images/bg_menuTabs.gif
diff --git a/loggerhead/static/images/bg_search_input.gif b/static/images/bg_search_input.gif
similarity index 100%
rename from loggerhead/static/images/bg_search_input.gif
rename to static/images/bg_search_input.gif
diff --git a/loggerhead/static/images/bg_submenuTabs.gif b/static/images/bg_submenuTabs.gif
similarity index 100%
rename from loggerhead/static/images/bg_submenuTabs.gif
rename to static/images/bg_submenuTabs.gif
diff --git a/loggerhead/static/images/deleteCode.gif b/static/images/deleteCode.gif
similarity index 100%
rename from loggerhead/static/images/deleteCode.gif
rename to static/images/deleteCode.gif
diff --git a/loggerhead/static/images/favicon.ico b/static/images/favicon.ico
similarity index 100%
rename from loggerhead/static/images/favicon.ico
rename to static/images/favicon.ico
diff --git a/loggerhead/static/images/favicon.png b/static/images/favicon.png
similarity index 100%
rename from loggerhead/static/images/favicon.png
rename to static/images/favicon.png
diff --git a/loggerhead/static/images/ico_branch.gif b/static/images/ico_branch.gif
similarity index 100%
rename from loggerhead/static/images/ico_branch.gif
rename to static/images/ico_branch.gif
diff --git a/loggerhead/static/images/ico_bug.png b/static/images/ico_bug.png
similarity index 100%
rename from loggerhead/static/images/ico_bug.png
rename to static/images/ico_bug.png
diff --git a/loggerhead/static/images/ico_committer.gif b/static/images/ico_committer.gif
similarity index 100%
rename from loggerhead/static/images/ico_committer.gif
rename to static/images/ico_committer.gif
diff --git a/loggerhead/static/images/ico_description.gif b/static/images/ico_description.gif
similarity index 100%
rename from loggerhead/static/images/ico_description.gif
rename to static/images/ico_description.gif
diff --git a/loggerhead/static/images/ico_diff.gif b/static/images/ico_diff.gif
similarity index 100%
rename from loggerhead/static/images/ico_diff.gif
rename to static/images/ico_diff.gif
diff --git a/loggerhead/static/images/ico_file.gif b/static/images/ico_file.gif
similarity index 100%
rename from loggerhead/static/images/ico_file.gif
rename to static/images/ico_file.gif
diff --git a/loggerhead/static/images/ico_file_download.gif b/static/images/ico_file_download.gif
similarity index 100%
rename from loggerhead/static/images/ico_file_download.gif
rename to static/images/ico_file_download.gif
diff --git a/loggerhead/static/images/ico_file_flecha.gif b/static/images/ico_file_flecha.gif
similarity index 100%
rename from loggerhead/static/images/ico_file_flecha.gif
rename to static/images/ico_file_flecha.gif
diff --git a/loggerhead/static/images/ico_file_modify.gif b/static/images/ico_file_modify.gif
similarity index 100%
rename from loggerhead/static/images/ico_file_modify.gif
rename to static/images/ico_file_modify.gif
diff --git a/loggerhead/static/images/ico_folder.gif b/static/images/ico_folder.gif
similarity index 100%
rename from loggerhead/static/images/ico_folder.gif
rename to static/images/ico_folder.gif
diff --git a/loggerhead/static/images/ico_folder.png b/static/images/ico_folder.png
similarity index 100%
rename from loggerhead/static/images/ico_folder.png
rename to static/images/ico_folder.png
diff --git a/loggerhead/static/images/ico_folder_up.gif b/static/images/ico_folder_up.gif
similarity index 100%
rename from loggerhead/static/images/ico_folder_up.gif
rename to static/images/ico_folder_up.gif
diff --git a/loggerhead/static/images/ico_link.gif b/static/images/ico_link.gif
similarity index 100%
rename from loggerhead/static/images/ico_link.gif
rename to static/images/ico_link.gif
diff --git a/loggerhead/static/images/ico_mergefrom.gif b/static/images/ico_mergefrom.gif
similarity index 100%
rename from loggerhead/static/images/ico_mergefrom.gif
rename to static/images/ico_mergefrom.gif
diff --git a/loggerhead/static/images/ico_mergeto.gif b/static/images/ico_mergeto.gif
similarity index 100%
rename from loggerhead/static/images/ico_mergeto.gif
rename to static/images/ico_mergeto.gif
diff --git a/loggerhead/static/images/ico_planilla.gif b/static/images/ico_planilla.gif
similarity index 100%
rename from loggerhead/static/images/ico_planilla.gif
rename to static/images/ico_planilla.gif
diff --git a/loggerhead/static/images/ico_rss.gif b/static/images/ico_rss.gif
similarity index 100%
rename from loggerhead/static/images/ico_rss.gif
rename to static/images/ico_rss.gif
diff --git a/loggerhead/static/images/ico_tag.gif b/static/images/ico_tag.gif
similarity index 100%
rename from loggerhead/static/images/ico_tag.gif
rename to static/images/ico_tag.gif
diff --git a/loggerhead/static/images/ico_time.gif b/static/images/ico_time.gif
similarity index 100%
rename from loggerhead/static/images/ico_time.gif
rename to static/images/ico_time.gif
diff --git a/loggerhead/static/images/newCode.gif b/static/images/newCode.gif
similarity index 100%
rename from loggerhead/static/images/newCode.gif
rename to static/images/newCode.gif
diff --git a/loggerhead/static/images/notification-private.png b/static/images/notification-private.png
similarity index 100%
rename from loggerhead/static/images/notification-private.png
rename to static/images/notification-private.png
diff --git a/loggerhead/static/images/spinner.gif b/static/images/spinner.gif
similarity index 100%
rename from loggerhead/static/images/spinner.gif
rename to static/images/spinner.gif
diff --git a/loggerhead/static/images/treeCollapsed.png b/static/images/treeCollapsed.png
similarity index 100%
rename from loggerhead/static/images/treeCollapsed.png
rename to static/images/treeCollapsed.png
diff --git a/loggerhead/static/images/treeDiff.png b/static/images/treeDiff.png
similarity index 100%
rename from loggerhead/static/images/treeDiff.png
rename to static/images/treeDiff.png
diff --git a/loggerhead/static/images/treeExpanded.png b/static/images/treeExpanded.png
similarity index 100%
rename from loggerhead/static/images/treeExpanded.png
rename to static/images/treeExpanded.png
diff --git a/loggerhead/static/javascript/changelog.js b/static/javascript/changelog.js
similarity index 100%
rename from loggerhead/static/javascript/changelog.js
rename to static/javascript/changelog.js
diff --git a/loggerhead/static/javascript/custom.js b/static/javascript/custom.js
similarity index 100%
rename from loggerhead/static/javascript/custom.js
rename to static/javascript/custom.js
diff --git a/loggerhead/static/javascript/diff.js b/static/javascript/diff.js
similarity index 100%
rename from loggerhead/static/javascript/diff.js
rename to static/javascript/diff.js
diff --git a/loggerhead/static/javascript/jquery.min.js b/static/javascript/jquery.min.js
similarity index 100%
rename from loggerhead/static/javascript/jquery.min.js
rename to static/javascript/jquery.min.js
diff --git a/templates/annotate.html b/templates/annotate.html
new file mode 100644
index 00000000..8d6049a7
--- /dev/null
+++ b/templates/annotate.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+{% block title %}{{ nick }} : annotate {{ path }} at revision {{ revno }}{% endblock %}
+
+{% block head_extras %}
+
+
+{% endblock %}
+
+{% block heading %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+ +{% for l in lines %} + + + + + +{% endfor %} +
{{ l.revno }}
{{ l.n }}
{{ l.text }}
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 00000000..85c16263 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,55 @@ + + + + + +{% block title %}{{ nick }}{% endblock %} + + + + + +{% block head_extras %}{% endblock %} + + + + +

+{{ nick }} +

+ +{% block menu %} + + +{% endblock %} + +
+
+ +{% block heading %}{% endblock %} + +{% block content %}{% endblock %} + + +
+ + diff --git a/templates/changelog.html b/templates/changelog.html new file mode 100644 index 00000000..8f8af0d9 --- /dev/null +++ b/templates/changelog.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : changes{% endblock %} + +{% block head_extras %} + + + +{% endblock %} + +{% block heading %} +
+To get this branch, use:
+bzr branch {{ served_url }} +
+{% if !filter_path.is_empty() %} + +{% endif %} +{% endblock %} + +{% block content %} +{% if changes.is_empty() %} +

No revisions.

+{% else %} + +

expand all expand all

+ + + + + + + + +{% if show_tag_col %}{% endif %} + + + + + +{% for c in changes %} + + + + + + +{% if show_tag_col %}{% endif %} + + + + +{% endfor %} +
Rev SummaryAuthorsTagsDateDiffFiles
+
+
+
+ {% if c.is_merge %}merge {% endif %}{{ c.short_message }} +
+ +
{{ c.author }}{{ c.tags }}{{ c.relative_date }}DiffFiles
+ +{% if prev_page_url.is_some() || next_page_url.is_some() %} + +{% endif %} +{% endif %} +{% endblock %} diff --git a/templates/directory.html b/templates/directory.html new file mode 100644 index 00000000..06af5c30 --- /dev/null +++ b/templates/directory.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ nick }}{% endblock %} +{% block menu %}{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} + + + + + + +{% for e in entries %} + + + + + +{% endfor %} +
NameLast committerLast commit
{{ e.name }}{% if !e.is_branch %}/{% endif %}{% if e.is_branch %}{{ e.last_committer }}{% endif %}{% if e.is_branch %}{{ e.last_date }}{% endif %}
+{% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 00000000..929652d5 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : error{% endblock %} +{% block menu %}{% endblock %} + +{% block heading %} +

{{ nick }} : error

+{% endblock %} + +{% block content %} +
+

{{ error_title }}

+

{{ error_description }}

+
+

+{% endblock %} diff --git a/templates/filediff.html b/templates/filediff.html new file mode 100644 index 00000000..e6e5e72e --- /dev/null +++ b/templates/filediff.html @@ -0,0 +1,22 @@ +
+ {% for chunk in chunks %} + {% if !loop.first %} +
+
+
+
+
+
+ {% endif %} +
+ {% for line in chunk.lines %} +
+
{% match line.old_lineno %}{% when Some with (n) %}{{ n }}{% when None %} {% endmatch %}
+
{% match line.new_lineno %}{% when Some with (n) %}{{ n }}{% when None %} {% endmatch %}
+
{% if line.text.is_empty() %} {% else %}{{ line.text|safe }}{% endif %}
+
+
+ {% endfor %} +
+ {% endfor %} +
diff --git a/templates/inventory.html b/templates/inventory.html new file mode 100644 index 00000000..5570837f --- /dev/null +++ b/templates/inventory.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : files for revision {{ revno }}{% endblock %} + +{% block head_extras %} + +{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} +
+
+To get this branch, use:
+bzr branch {{ nick }} +
+ + + +{% match tip_change %}{% when Some with (c) %} +
+
+
    +
  • Committer: {{ c.committer }}
  • +
  • Date: {{ c.utc_iso }}
  • +
  • Revision ID: {{ revid_hex }}
  • +
+
+
{{ c.message }}
+
+
+
+{% when None %}{% endmatch %} + + + + + + + + + + + + + +{% match parent_path %}{% when Some with (p) %} + + + +{% when None %}{% endmatch %} + +{% for e in entries %} + + + + + + + + + + +{% endfor %} +
Filename{% if sort == "filename" %} ▼{% endif %}Latest RevLast Changed{% if sort == "date" %} ▼{% endif %}CommitterCommentSize{% if sort == "size" %} ▼{% endif %}
..
+ + + + {{ e.name }}{% if e.is_dir %}/{% endif %} +{{ e.last_revno }}{{ e.last_relative }}{{ e.last_committer }}{{ e.last_message }}{% match e.size %}{% when Some with (s) %}{{ s }} bytes{% when None %}{% endmatch %} + {% if !e.is_dir %} + View + {% endif %} + + {% if !e.is_dir %} + Download File + {% endif %} +
+
+{% endblock %} diff --git a/templates/revision.html b/templates/revision.html new file mode 100644 index 00000000..98917cee --- /dev/null +++ b/templates/revision.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : revision {{ revno }}{% endblock %} + +{% block head_extras %} + + + +{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} +

Viewing all changes in revision {{ revno }}.

+ + + +{% match compare_revno %} + {% when Some with (cr) %} +

+ Showing diff vs revision {{ cr }}. + Stop comparing. +

+ {% when None %} +{% endmatch %} + +{% match remember_revno %} + {% when Some with (rr) %} +

+ Click another revision to compare with revision {{ rr }}. + Cancel. +

+ {% when None %} +{% endmatch %} + +
+
+
    +
  • Committer: {{ author }}
  • +
  • Date: {{ utc_iso }}
  • +{% if !parents.is_empty() %} +
  • Parent: + {% for p in parents %} + {% match remember_revno %} + {% when Some with (rr) %}{{ p.revno }} + {% when None %}{{ p.revno }} + {% endmatch %} + {% if !loop.last %}, {% endif %} + {% endfor %} +
  • +{% endif %} +
  • Revision ID: {{ revid_hex }}
  • +{% match foreign %} + {% when Some with (f) %} +
  • {{ f.abbreviation }} commit: {{ f.foreign_revid }}
  • + {% when None %} +{% endmatch %} +
+ +
+ +{% if !bugs.is_empty() %} +{% for b in bugs %} + +{% endfor %} +{% endif %} + +
{{ message }}
+
+ +
    +{% if !added.is_empty() %} +
      +
    • added:
    • +{% for f in added %} +
    • {{ f.path }}
    • +{% endfor %} +
    +{% endif %} +{% if !removed.is_empty() %} +
      +
    • removed:
    • +{% for f in removed %} +
    • {{ f.path }}
    • +{% endfor %} +
    +{% endif %} +{% if !modified.is_empty() %} +
      +
    • files modified:
    • +{% for f in modified %} +
    • {{ f.path }}
    • +{% endfor %} +
    +{% endif %} +{% if !renamed.is_empty() %} +
      +
    • renamed:
    • +{% for f in renamed %} +
    • {% match f.old_path %}{% when Some with (o) %}{{ o }} → {{ f.path }}{% when None %}{{ f.path }}{% endmatch %}
    • +{% endfor %} +
    +{% endif %} +
+
+
+ +

expand all expand all

+ +

Show diffs side-by-side

+

added added

+

removed removed

+
Lines of Context:
+
+ +
+{% for f in modified %} + +{% endfor %} +
+{% endblock %} diff --git a/templates/revlog.html b/templates/revlog.html new file mode 100644 index 00000000..3f500ebb --- /dev/null +++ b/templates/revlog.html @@ -0,0 +1,23 @@ + +
+
    + {% for p in parents %} +
  • + + {{ p.revno }} + +
  • + {% endfor %} +
  • {{ author }}
  • + {% if !bugs.is_empty() %} + {% for b in bugs %} +
  • {{ b }}
  • + {% endfor %} + {% endif %} + {% for f in file_changes %} +
  • + {{ f.kind }}: {{ f.path }} +
  • + {% endfor %} +
+
diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 00000000..25d89039 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : search{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} +
+ + +
+{% if !available %} +

+Search is not available in this loggerhead build. The breezy.plugins.search plugin must be installed and the branch indexed. +

+{% else %} +{% if results.is_empty() %} +

No results.

+{% else %} +
    + {% for r in results %} +
  • {{ r.revno }} — {{ r.short_message }}
  • + {% endfor %} +
+{% endif %} +{% endif %} +{% endblock %} diff --git a/templates/view.html b/templates/view.html new file mode 100644 index 00000000..b5c04fa2 --- /dev/null +++ b/templates/view.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : contents of {{ path }} at revision {{ revno }}{% endblock %} + +{% block head_extras %} + + +{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} + + +
+{% if is_binary %} +

(binary file, not displayed)

+{% else %} + + + + + +
+
{% for l in lines %}{{ l.n }}
+{% endfor %}
+
+
{% for l in lines %}{{ l.html|safe }}
+{% endfor %}
+
+{% endif %} +
+{% endblock %} diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 07ac8e86..00000000 --- a/tox.ini +++ /dev/null @@ -1,14 +0,0 @@ -[tox] -envlist = py35,py36,py37,py38,py39,py310 -skipsdist=True - -[testenv] -deps = - -rrequirements.txt - . -commands = brz selftest -v breezy.plugins.loggerhead --strict -setenv = - py35: VIRTUALENV_DOWNLOAD = 0 - py35: VIRTUALENV_PIP = 20.3.4 - BRZ_PLUGIN_PATH=-user:-site - BRZ_PLUGINS_AT = loggerhead@{toxinidir}