├── .buildkite ├── docker │ └── rust │ │ └── Dockerfile └── pipeline.yaml ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .gitmodules ├── .license-header ├── .rustfmt.toml ├── CONTRIBUTING.md ├── COPYING ├── Cargo.toml ├── DCO ├── DEVELOPMENT.md ├── LICENSE ├── ci ├── advisory ├── build-test ├── docs ├── fmt ├── gh │ └── build-test ├── lint ├── nix │ ├── fmt │ └── run └── run ├── deny.toml ├── docs └── denotational-design.md ├── nix ├── sources.json └── sources.nix ├── rust-toolchain ├── scripts ├── license-headers ├── setup-branches.sh └── update-git-platinum.sh ├── shell.nix ├── source ├── Cargo.toml └── src │ ├── branch.rs │ ├── commit.rs │ ├── error.rs │ ├── lib.rs │ ├── object.rs │ ├── object │ ├── blob.rs │ └── tree.rs │ ├── oid.rs │ ├── person.rs │ ├── revision.rs │ ├── syntax.rs │ └── tag.rs └── surf ├── Cargo.toml ├── README.md ├── benches └── last_commit.rs ├── build.rs ├── data ├── README.md ├── git-platinum.tgz └── mock-branches.txt ├── examples └── diff.rs └── src ├── diff.rs ├── diff └── git.rs ├── file_system.rs ├── file_system ├── directory.rs ├── error.rs ├── path.rs └── path │ └── unsound.rs ├── lib.rs ├── nonempty.rs ├── tree.rs ├── vcs.rs └── vcs ├── git.rs └── git ├── branch.rs ├── commit.rs ├── error.rs ├── ext.rs ├── namespace.rs ├── reference.rs ├── reference └── glob.rs ├── repo.rs ├── stats.rs └── tag.rs /.buildkite/docker/rust/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:buster-slim 2 | 3 | # System packages 4 | RUN set -eux; \ 5 | apt-get update; \ 6 | apt-get install -y --no-install-recommends \ 7 | ca-certificates \ 8 | curl \ 9 | gcc \ 10 | git \ 11 | libc6-dev \ 12 | libssl-dev \ 13 | make \ 14 | pkg-config \ 15 | ; \ 16 | apt-get autoremove; \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | # Rust toolchain 20 | # Make sure this is in sync with rust-toolchain! 21 | ENV RUST_VERSION=nightly-2021-03-25 \ 22 | CARGO_HOME=/usr/local/cargo \ 23 | PATH=/usr/local/cargo/bin:$PATH \ 24 | RUSTUP_HOME=/usr/local/rustup \ 25 | RUSTUP_VERSION=1.23.1 \ 26 | RUSTUP_SHA512=bb28e24b47fb017ee5377c8064296595f3aed9753e1412666220cee33c1b6c99b73fa141a38679a6f1528f97b2b17d453a915962c63f4bde103329358ed53c16 27 | 28 | RUN set -eux; \ 29 | curl -LOf "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/x86_64-unknown-linux-gnu/rustup-init"; \ 30 | echo "${RUSTUP_SHA512} *rustup-init" | sha512sum -c -; \ 31 | chmod +x rustup-init; \ 32 | ./rustup-init -y --no-modify-path --profile minimal --default-toolchain $RUST_VERSION; \ 33 | rm rustup-init; \ 34 | chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \ 35 | rustup --version; \ 36 | cargo --version; \ 37 | rustc --version; \ 38 | rustup component add clippy rustfmt; \ 39 | cargo install cargo-deny; \ 40 | rm -rf /usr/local/cargo/registry; \ 41 | rm /usr/local/cargo/.package-cache; 42 | 43 | VOLUME /cache 44 | ENV CARGO_HOME=/cache/cargo 45 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | DOCKER_IMAGE: "gcr.io/opensourcecoin/radicle-surf-build@sha256:0b46adec76130f9fb2feab221d87f793f565a65b2b171f0dbd546e4320a6475c" 3 | DOCKER_FILE: .buildkite/docker/rust/Dockerfile 4 | 5 | steps: 6 | - label: "Build, lint, test" 7 | commands: 8 | - "ci/run" 9 | agents: 10 | production: "true" 11 | platform: "linux" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | jobs: 4 | fmt: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - uses: actions-rs/toolchain@v1 9 | with: 10 | profile: minimal 11 | toolchain: nightly 12 | components: rustfmt 13 | - uses: Swatinem/rust-cache@v1 14 | - run: ./ci/fmt 15 | shell: bash 16 | 17 | lint: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@master 21 | - uses: actions-rs/toolchain@v1 22 | with: 23 | profile: minimal 24 | toolchain: stable 25 | components: clippy 26 | - uses: Swatinem/rust-cache@v1 27 | - run: ./ci/lint 28 | shell: bash 29 | 30 | docs: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@master 34 | - uses: actions-rs/toolchain@v1 35 | with: 36 | profile: minimal 37 | toolchain: stable 38 | - uses: Swatinem/rust-cache@v1 39 | - run: ./ci/docs 40 | shell: bash 41 | 42 | cargo-deny: 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | checks: 47 | - advisories 48 | - bans licenses sources 49 | continue-on-error: ${{ matrix.checks == 'advisories' }} 50 | steps: 51 | - uses: actions/checkout@v2 52 | - uses: EmbarkStudios/cargo-deny-action@v1 53 | with: 54 | command: check ${{ matrix.checks }} 55 | 56 | linux: 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | toolchain: 61 | - stable 62 | - nightly 63 | continue-on-error: ${{ matrix.toolchain == 'nightly' }} 64 | steps: 65 | - uses: actions/checkout@master 66 | - uses: actions-rs/toolchain@v1 67 | with: 68 | profile: minimal 69 | toolchain: ${{ matrix.toolchain }} 70 | - uses: Swatinem/rust-cache@v1 71 | - run: ./ci/gh/build-test 72 | shell: bash 73 | 74 | macos: 75 | runs-on: macos-latest 76 | steps: 77 | - uses: actions/checkout@master 78 | - uses: actions-rs/toolchain@v1 79 | with: 80 | profile: minimal 81 | toolchain: stable 82 | - uses: Swatinem/rust-cache@v1 83 | - run: ./ci/gh/build-test 84 | shell: bash 85 | 86 | windows: 87 | runs-on: windows-latest 88 | continue-on-error: true 89 | permissions: write-all 90 | steps: 91 | - uses: actions/checkout@master 92 | - uses: actions-rs/toolchain@v1 93 | with: 94 | profile: minimal 95 | toolchain: stable 96 | - uses: Swatinem/rust-cache@v1 97 | - run: ./ci/gh/build-test 98 | shell: bash 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # JetBrains IDE project files (Intellij, CLion, etc.) 13 | .idea 14 | *.iml 15 | 16 | # Workdir for data generated by scripts. 17 | /.workdir 18 | 19 | # This is an inner git repo unpacked (if necessary) from the archive. 20 | # Git doesn't like nested repos, and we don't really want to use submodules. 21 | /surf/data/git-platinum 22 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radicle-dev/radicle-surf/b85d2183d786e5fa447aab9d2f420a32f1061bfa/.gitmodules -------------------------------------------------------------------------------- /.license-header: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | 3 | comment_width = 80 4 | wrap_comments = true 5 | hard_tabs = false 6 | tab_spaces = 4 7 | imports_layout = "HorizontalVertical" 8 | imports_granularity = "Crate" 9 | 10 | newline_style = "Unix" 11 | use_small_heuristics = "Default" 12 | 13 | reorder_imports = true 14 | reorder_modules = true 15 | 16 | remove_nested_parens = true 17 | 18 | fn_args_layout = "Tall" 19 | 20 | edition = "2018" 21 | 22 | match_block_trailing_comma = true 23 | 24 | merge_derives = true 25 | 26 | use_try_shorthand = false 27 | use_field_init_shorthand = false 28 | 29 | force_explicit_abi = true 30 | 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thank you for your interest in contributing to this project! 2 | 3 | Before you submit your first patch, please take a moment to review the following 4 | guidelines: 5 | 6 | ## Certificate of Origin 7 | 8 | By contributing to this project, you agree to the [Developer Certificate of 9 | Origin (DCO)][dco]. This document was created by the Linux Kernel community 10 | and is a simple statement that you, as a contributor, have the legal right to 11 | make the contribution. 12 | 13 | In order to show your agreement with the DCO you should include at the end of 14 | the commit message, the following line: 15 | 16 | Signed-off-by: John Doe 17 | 18 | using your real name and email. 19 | 20 | This can be done easily using `git commit -s`. 21 | 22 | ### Fixing the DCO 23 | 24 | If you did not sign-off one or more of your commits then it is not all for not. 25 | The crowd at `src-d` have a [wonderful guide][fixing-dco] on how to remedy this 26 | situation. 27 | 28 | ## License Header 29 | 30 | As part of our license, we must include a license header at the top of each 31 | source file. The template for this header can be found [here][header-template]. 32 | If you are creating a new file in the repository you will have to add this 33 | header to the file. 34 | 35 | ## Modifying the specs 36 | 37 | When any of the spec files is modified, e.g. one from `docs/spec/sections`, 38 | the docs need to be re-rendered and the files in `docs/spec/out` need to be 39 | updated. To do this, run `scripts/render-docs`. 40 | 41 | [dco]: ./DCO 42 | [fixing-dco]: https://docs.github.com/en/free-pro-team@latest/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository 43 | [header-template]: ./.license-header-template 44 | 45 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "source", 4 | "surf", 5 | ] 6 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | 2 | Developer Certificate of Origin 3 | 4 | By making your contribution, you are (1) making the declaration set 5 | out in the Linux Foundation’s Developer Certificate of Origin 6 | version 1.1 as set out below, in which the “open source licence indicated 7 | in the file” is GPLv3 with the Radicle Linking Exception, and (2) granting the 8 | additional permission referred to in the Radicle Linking Exception to 9 | downstream recipients of current and future versions of radicle-surf 10 | released by the Radicle Foundation. 11 | 12 | Developer Certificate of Origin 13 | Version 1.1 14 | 15 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 16 | 1 Letterman Drive 17 | Suite D4700 18 | San Francisco, CA, 94129 19 | 20 | Everyone is permitted to copy and distribute verbatim copies of this 21 | license document, but changing it is not allowed. 22 | 23 | Developer's Certificate of Origin 1.1 24 | 25 | By making a contribution to this project, I certify that: (a) The contribution 26 | was created in whole or in part by me and I have the right to submit it under 27 | the open source license indicated in the file; or 28 | 29 | (b) The contribution is based upon previous work that, to the best of my 30 | knowledge, is covered under an appropriate open source license and I have the 31 | right under that license to submit that work with modifications, whether created 32 | in whole or in part by me, under the same open source license (unless I am 33 | permitted to submit under a different license), as indicated in the file; or 34 | 35 | (c) The contribution was provided directly to me by some other person who 36 | certified (a), (b) or (c) and I have not modified it. 37 | 38 | (d) I understand and agree that this project and the contribution are public and 39 | that a record of the contribution (including all personal information I submit 40 | with it, including my sign-off) is maintained indefinitely and may be 41 | redistributed consistent with this project or the open source license(s) 42 | involved. 43 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | 2 | # Radicle Surfing 🏄 3 | 4 | Thanks for wanting to contribute to `radicle-surf`! 5 | 6 | # Licensing 7 | 8 | We are [GPL-3.0-or-later](./LICENSE) licensed project. To keep in compliance with this we must 9 | add a [license header](./.license-header) to any new files added. This is checked on each run of CI. 10 | 11 | ## Building & Testing 🏗️ 12 | 13 | We try to make development as seemless as possible so we can get down to the real work. We supply 14 | the toolchain via the `rust-toolchain` file, and the formatting rules `.rustmt.toml` file. 15 | 16 | For the [Nix](https://nixos.org/) inclined there is a `default.nix` file to get all the necessary 17 | dependencies and it also uses the `rust-toolchain` file to pin to that version of Rust. 18 | 19 | You can build the project the usual way: 20 | ``` 21 | cargo build 22 | ``` 23 | 24 | To run all the tests: 25 | ``` 26 | cargo test 27 | ``` 28 | 29 | For the full list of checks that get executed in CI you can checkout the [ci/run](./ci/run) script. 30 | 31 | If any of this _isn't_ working, then let's work through it together and get it Working on Your 32 | Machine™. 33 | 34 | ## Structure 🏛️ 35 | 36 | The design of `radicle-surf` is to have an in-memory representation of a project's directory which 37 | can be generated by a VCS's backend. The directory system is modeled under `file_system`, the VCS 38 | functionality is naturally under `vcs`, and `diff` logic is held under `diff`. 39 | 40 | ``` 41 | src/ 42 | ├── diff 43 | ├── file_system 44 | └── vcs 45 | ``` 46 | 47 | ## Testing & Documentation 📚 48 | 49 | We ensure that the crate is well documented. `cargo clippy` will argue with you anytime a public 50 | facing piece of the library is undocumented. We should always provide an explanation of what 51 | something is or does, and also provide examples to allow our users to get up and running as quick 52 | and easy as possible. 53 | 54 | When writing documentation we should try provide one or two examples (if they make sense). This 55 | provides us with some simple unit tests as well as something our users can copy and paste for ease 56 | of development. 57 | 58 | If more tests are needed then we should add them under `mod tests` in the relevant module. We strive 59 | to find properties of our programs so that we can use tools like `proptest` to extensively prove our 60 | programs are correct. As well as this, we add unit tests to esnure the examples in our heads are 61 | correct, and testing out the ergonomics of our API first-hand. 62 | 63 | ## CI files 🤖 64 | 65 | Our CI infrastructure runs on Buildkite. The build process is run for every commit which is pushed 66 | to GitHub. 67 | 68 | All relevant configuration can be found here: 69 | 70 | ``` 71 | radicle-surf/.buildkite/ 72 | ├── docker 73 | │   ├── build 74 | │   │   └── Dockerfile 75 | │   └── rust-nightly 76 | │   └── Dockerfile 77 | └── pipeline.yaml 78 | ``` 79 | 80 | ## Releases 📅 81 | 82 | TODO: Once we get the API into a good shape we will keep track of releases via a `CHANGELOG.md` and 83 | tag the releases via `git tag`. 84 | -------------------------------------------------------------------------------- /ci/advisory: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eoux pipefail 3 | 4 | cargo deny --version 5 | cargo deny check advisories 6 | cargo deny check licenses 7 | cargo deny check bans 8 | cargo deny check sources 9 | -------------------------------------------------------------------------------- /ci/build-test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eoux pipefail 3 | 4 | cargo build --workspace 5 | GIT_FIXTURES=1 cargo test --workspace --features serialize 6 | -------------------------------------------------------------------------------- /ci/docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eoux pipefail 3 | 4 | RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D warnings" \ 5 | cargo doc --no-deps --workspace --document-private-items 6 | -------------------------------------------------------------------------------- /ci/fmt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eoux pipefail 3 | 4 | cargo +nightly fmt -- --check 5 | -------------------------------------------------------------------------------- /ci/gh/build-test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eoux pipefail 3 | 4 | cargo build --workspace 5 | GIT_FIXTURES=1 cargo test --workspace --features serialize --features gh-actions 6 | -------------------------------------------------------------------------------- /ci/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eoux pipefail 3 | 4 | cargo clippy --all-targets -- -D warnings 5 | -------------------------------------------------------------------------------- /ci/nix/fmt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eoux pipefail 3 | 4 | cargo fmt -- --check 5 | -------------------------------------------------------------------------------- /ci/nix/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eou pipefail 3 | 4 | ./ci/nix/fmt 5 | ./ci/lint 6 | ./ci/build-test 7 | ./ci/docs 8 | ./ci/advisory 9 | -------------------------------------------------------------------------------- /ci/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eou pipefail 3 | 4 | ./ci/fmt 5 | ./ci/lint 6 | ./ci/build-test 7 | ./ci/docs 8 | ./ci/advisory 9 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This section is considered when running `cargo deny check advisories` 2 | # More documentation for the advisories section can be found here: 3 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 4 | [advisories] 5 | # The path where the advisory database is cloned/fetched into 6 | db-path = "~/cargo/advisory-db" 7 | # The url of the advisory database to use 8 | db-urls = [ "https://github.com/rustsec/advisory-db" ] 9 | # The lint level for security vulnerabilities 10 | vulnerability = "deny" 11 | # The lint level for unmaintained crates 12 | unmaintained = "warn" 13 | # The lint level for crates that have been yanked from their source registry 14 | yanked = "warn" 15 | # The lint level for crates with security notices. Note that as of 16 | # 2019-12-17 there are no security notice advisories in 17 | # https://github.com/rustsec/advisory-db 18 | notice = "warn" 19 | # A list of advisory IDs to ignore. Note that ignored advisories will still 20 | # output a note when they are encountered. 21 | ignore = [ 22 | #"RUSTSEC-0000-0000", 23 | ] 24 | # Threshold for security vulnerabilities, any vulnerability with a CVSS score 25 | # lower than the range specified will be ignored. Note that ignored advisories 26 | # will still output a note when they are encountered. 27 | # * None - CVSS Score 0.0 28 | # * Low - CVSS Score 0.1 - 3.9 29 | # * Medium - CVSS Score 4.0 - 6.9 30 | # * High - CVSS Score 7.0 - 8.9 31 | # * Critical - CVSS Score 9.0 - 10.0 32 | #severity-threshold = 33 | 34 | # This section is considered when running `cargo deny check licenses` 35 | # More documentation for the licenses section can be found here: 36 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 37 | [licenses] 38 | # The lint level for crates which do not have a detectable license 39 | unlicensed = "deny" 40 | # List of explictly allowed licenses 41 | # See https://spdx.org/licenses/ for list of possible licenses 42 | # [possible values: any SPDX 3.7 short identifier (+ optional exception)]. 43 | allow = [ 44 | "Apache-2.0", 45 | "BSD-2-Clause", 46 | "CC0-1.0", 47 | "GPL-2.0", 48 | "GPL-3.0", 49 | "MIT", 50 | "Unlicense", 51 | ] 52 | # List of explictly disallowed licenses 53 | # See https://spdx.org/licenses/ for list of possible licenses 54 | # [possible values: any SPDX 3.7 short identifier (+ optional exception)]. 55 | deny = [ 56 | # As per https://www.gnu.org/licenses/license-list.html#GPLIncompatibleLicenses 57 | "AGPL-1.0", 58 | # fails to parse: 59 | # "AFL-1.0", 60 | # "AFL-1.2", 61 | # "AFL-2.0", 62 | # "AFL-2.1", 63 | # "AFL-3.0", 64 | "Apache-1.0", 65 | "Apache-1.1", 66 | "APSL-2.0", 67 | "BitTorrent-1.0", 68 | "BitTorrent-1.1", 69 | "BSD-4-Clause", 70 | "CECILL-B", 71 | "CECILL-C", 72 | "CDDL-1.0", 73 | "CDDL-1.1", 74 | "CNRI-Python", 75 | "CPAL-1.0", 76 | "CPL-1.0", 77 | "Condor-1.1", 78 | "EPL-1.0", 79 | "EPL-2.0", 80 | "EUPL-1.1", 81 | "EUPL-1.2", 82 | "gnuplot", 83 | "IPL-1.0", 84 | "LPPL-1.3a", 85 | "LPPL-1.2", 86 | "LPL-1.02", 87 | "MS-PL", 88 | "MS-RL", 89 | "MPL-1.1", 90 | "NOSL", 91 | "NPL-1.0", 92 | "NPL-1.1", 93 | "Nokia", 94 | "OpenSSL", 95 | "PHP-3.01", 96 | "QPL-1.0", 97 | "RPSL-1.0", 98 | "SISSL", 99 | "SPL-1.0", 100 | "xinetd", 101 | "YPL-1.1", 102 | "Zend-2.0", 103 | "Zimbra-1.3", 104 | "ZPL-1.1" 105 | ] 106 | # Lint level for licenses considered copyleft 107 | copyleft = "allow" 108 | # Blanket approval or denial for OSI-approved or FSF Free/Libre licenses 109 | # * both - The license will be approved if it is both OSI-approved *AND* FSF 110 | # * either - The license will be approved if it is either OSI-approved *OR* FSF 111 | # * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF 112 | # * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved 113 | # * neither - This predicate is ignored and the default lint level is used 114 | allow-osi-fsf-free = "both" 115 | # Lint level used when no other predicates are matched 116 | # 1. License isn't in the allow or deny lists 117 | # 2. License isn't copyleft 118 | # 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither" 119 | default = "deny" 120 | # The confidence threshold for detecting a license from license text. 121 | # The higher the value, the more closely the license text must be to the 122 | # canonical license text of a valid SPDX license file. 123 | # [possible values: any between 0.0 and 1.0]. 124 | confidence-threshold = 0.8 125 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 126 | # aren't accepted for every possible crate as with the normal allow list 127 | exceptions = [ 128 | # Each entry is the crate and version constraint, and its specific allow 129 | # list 130 | #{ allow = ["Zlib"], name = "adler32", version = "*" }, 131 | ] 132 | 133 | # Some crates don't have (easily) machine readable licensing information, 134 | # adding a clarification entry for it allows you to manually specify the 135 | # licensing information 136 | #[[licenses.clarify]] 137 | # The name of the crate the clarification applies to 138 | #name = "ring" 139 | # THe optional version constraint for the crate 140 | #version = "*" 141 | # The SPDX expression for the license requirements of the crate 142 | #expression = "MIT AND ISC AND OpenSSL" 143 | # One or more files in the crate's source used as the "source of truth" for 144 | # the license expression. If the contents match, the clarification will be used 145 | # when running the license check, otherwise the clarification will be ignored 146 | # and the crate will be checked normally, which may produce warnings or errors 147 | # depending on the rest of your configuration 148 | #license-files = [ 149 | # Each entry is a crate relative path, and the (opaque) hash of its contents 150 | #{ path = "LICENSE", hash = 0xbd0eed23 } 151 | #] 152 | 153 | [licenses.private] 154 | # If true, ignores workspace crates that aren't published, or are only 155 | # published to private registries 156 | ignore = false 157 | # One or more private registries that you might publish crates to, if a crate 158 | # is only published to private registries, and ignore is true, the crate will 159 | # not have its license(s) checked 160 | registries = [ 161 | #"https://sekretz.com/registry 162 | ] 163 | 164 | # This section is considered when running `cargo deny check bans`. 165 | # More documentation about the 'bans' section can be found here: 166 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 167 | [bans] 168 | # Lint level for when multiple versions of the same crate are detected 169 | multiple-versions = "warn" 170 | # The graph highlighting used when creating dotgraphs for crates 171 | # with multiple versions 172 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 173 | # * simplest-path - The path to the version with the fewest edges is highlighted 174 | # * all - Both lowest-version and simplest-path are used 175 | highlight = "all" 176 | # List of crates that are allowed. Use with care! 177 | allow = [ 178 | #{ name = "ansi_term", version = "=0.11.0" }, 179 | ] 180 | # List of crates to deny 181 | deny = [ 182 | # Each entry the name of a crate and a version range. If version is 183 | # not specified, all versions will be matched. 184 | #{ name = "ansi_term", version = "=0.11.0" }, 185 | ] 186 | # Certain crates/versions that will be skipped when doing duplicate detection. 187 | skip = [ 188 | #{ name = "ansi_term", version = "=0.11.0" }, 189 | ] 190 | # Similarly to `skip` allows you to skip certain crates during duplicate 191 | # detection. Unlike skip, it also includes the entire tree of transitive 192 | # dependencies starting at the specified crate, up to a certain depth, which is 193 | # by default infinite 194 | skip-tree = [ 195 | #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, 196 | ] 197 | 198 | # This section is considered when running `cargo deny check sources`. 199 | # More documentation about the 'sources' section can be found here: 200 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 201 | [sources] 202 | # Lint level for what to happen when a crate from a crate registry that is not 203 | # in the allow list is encountered 204 | unknown-registry = "deny" 205 | # Lint level for what to happen when a crate from a git repository that is not 206 | # in the allow list is encountered 207 | unknown-git = "deny" 208 | # List of URLs for allowed crate registries. Defaults to the crates.io index 209 | # if not specified. If it is specified but empty, no registries are allowed. 210 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 211 | # List of URLs for allowed Git repositories 212 | allow-git = [ 213 | ] 214 | 215 | -------------------------------------------------------------------------------- /docs/denotational-design.md: -------------------------------------------------------------------------------- 1 | # Design Documentation 2 | 3 | In this document we will describe the design of `radicle-surf`. The design of the system will rely 4 | heavily on [denotational design](todo) and use Haskell syntax (because types are easy to reason about, I'm sorry). 5 | 6 | `radicle-surf` is a system to describe a file-system in a VCS world. We have the concept of files and directories, 7 | but these objects can change over time while people iterate on them. Thus, it is a file-system within history and 8 | we, the user, are viewing the file-system at a particular snapshot. Alongside this, we will wish to take two snapshots 9 | and view their differences. 10 | 11 | The stream of consciousness that gave birth to this document started with thinking how the user would interact with 12 | the system, identifying the key components. This is captured in [User Flow](#user-flow). From there we found nouns that 13 | represent objects in our system and verbs that represent functions over those objects. This iteratively informed us as 14 | to what other actions we would need to supply. We would occassionally look at [GitHub](todo) and [Pijul Nest](todo) for 15 | inspiration, since we would like to imitate the features that they supply, and we ultimately want use one or both of 16 | these for our backends. 17 | 18 | ## User Flow 19 | 20 | For the user flow we imagined what it would be like if the user was using a [REPL](todo) to interact with `radicle-surf`. 21 | The general concept was that the user would enter the repository, build a view of the directory structure, and then 22 | interact with the directories and files from there (called `browse`). 23 | ```haskell 24 | repl :: IO () 25 | repl = do 26 | repo <- getRepo 27 | history <- getHistory label repo -- head is SHA1, tail is rest 28 | directory <- buildDirectory history 29 | 30 | forever browse directory 31 | ``` 32 | 33 | But then we thought about what happens when we are in `browse` but we would like to change the history and see that 34 | file or directory at a different snapshot. This was captured in the pseudo-code below: 35 | ```haskell 36 | src_foo_bar <- find... 37 | history' <- historyOf src_foo_bar 38 | ``` 39 | 40 | This information was enough for us to begin the [denotational design](#denotational-design) below. 41 | 42 | ## Denotational Design 43 | 44 | ```haskell 45 | -- A Label is a name for a directory or a file 46 | type Label 47 | μ Label = Text 48 | 49 | -- A Directory captures its own Label followed by 1 or more 50 | -- artefacts which can either be sub-directories or files. 51 | -- 52 | -- An example of "foo/bar.hs" structure: 53 | -- foo 54 | -- |-- bar.hs 55 | -- 56 | -- Would look like: 57 | -- @("foo", Right ("bar.hs", "module Banana ...") :| [])@ 58 | type Directory 59 | μ Directory = (Label, NonEmpty (Either Directory File)) 60 | 61 | -- DirectoryContents can either be the special IsRepo object, 62 | -- a Directory, or a File. 63 | type DirectoryContents 64 | μ DirectoryContents = IsRepo | Directory | File 65 | 66 | -- Opaque representation of repository state directories (e.g. `.git`, `.pijul`) 67 | -- Those are not browseable, but have to be present at the repo root 'Directory'. 68 | type IsRepo 69 | 70 | -- A Directory captures its own Label followed by 1 or more DirectoryContents 71 | -- 72 | -- An example of "foo/bar.hs" structure: 73 | -- foo 74 | -- |-- bar.hs 75 | -- 76 | -- Would look like: 77 | -- @("~", IsRepo :| [Directory ("foo", File ("bar.hs", "module Banana ..") :| [])] 78 | -- where IsRepo is the implicit root of the repository. 79 | type Directory 80 | μ Directory = (Label, NonEmpty DirectoryContents) 81 | 82 | -- A File is its Label and its contents 83 | type File 84 | μ File = (Label, ByteString) 85 | 86 | -- An enumeration of what file-system artefact we're looking at. 87 | -- Useful for listing a directory and denoting what the label is 88 | -- corresponding to. 89 | type SystemType 90 | μ SystemType 91 | = IsFile 92 | | IsDirectory 93 | 94 | -- A Chnage is an enumeration of how a file has changed. 95 | -- This is simply used for getting the difference between two 96 | -- directories. 97 | type Change 98 | 99 | -- Constructors of Change - think GADT 100 | AddLineToFile :: NonEmpty Label -> Location -> ByteString -> Change 101 | RemoveLineFromFile :: NonEmpty Label -> Location -> Change 102 | MoveFile :: NonEmpty Label -> NonEmpty Label -> Change 103 | CreateFile :: NonEmpty Label -> Change 104 | DeleteFile :: NonEmpty Label -> Change 105 | 106 | -- A Diff is a set of Changes that were made 107 | type Diff 108 | μ Diff = [Change] 109 | 110 | -- History is an ordered set of @a@s. The reason for it being 111 | -- polymorphic is that it allows us to choose what set artefact we 112 | -- want to carry around. 113 | -- 114 | -- For example: 115 | -- * In `git` this would be a `Commit`. 116 | -- * In `pijul` it would be a `Patch`. 117 | type History a 118 | μ History = NonEmpty a 119 | 120 | -- A Repo is a collection of multiple histories. 121 | -- This would essentially boil down to branches and tags. 122 | type Repo 123 | μ Repo a = [History a] 124 | 125 | -- A Snapshot is a way of converting a History into a Directory. 126 | -- In other words it gives us a snapshot of the history in the form of a directory. 127 | type Snapshot a 128 | μ Snapshot a = History a -> Directory 129 | 130 | -- For example, we have a `git` snapshot or a `pjul` snapshot. 131 | type Commit 132 | type GitSnapshot = Snapshot Commit 133 | 134 | type Patch 135 | type PijulSnapshot = Snapshot Patch 136 | 137 | -- This is piece de resistance of the design! It turns out, 138 | -- everything is just a Monad after all. 139 | -- 140 | -- Our code Browser is a stateful computation of what History 141 | -- we are currently working with and how to get a Snapshot of it. 142 | type Browser a b 143 | μ type Browser a b = ReaderT (Snapshot a) (State (History a) b) 144 | 145 | -- A function that will retrieve a repository given an 146 | -- identifier. In our case the identifier is opaque to the system. 147 | getRepo :: Repo -> Repo 148 | 149 | -- Find a particular History in the Repo. Again, how these things 150 | -- are equated and found is opaque, but we can think of them as 151 | -- branch or tag labels. 152 | getHistory :: Eq a => History a -> Repo a -> Maybe (History a) 153 | μ getHistory history repo = 154 | find history (μ repo) 155 | 156 | -- Find if a particular artefact occurs in 0 or more histories. 157 | findInHistories :: a -> [History a] -> [History a] 158 | μ findInHistories a histories = 159 | filterMaybe (findInHistory a) histories 160 | 161 | -- Find a particular artefact is in a history. 162 | findInHistory :: Eq a => a -> History a -> Maybe a 163 | μ findInHistory a history = find (== a) (μ history) 164 | 165 | -- A special Label that guarantees a starting point, i.e. ~ 166 | rootLabel :: Label 167 | μ rootLabel = "~" 168 | 169 | emptyRepoRoot :: Directory 170 | μ emptyRepoRoot = (rootLabel, IsRepo :| []) 171 | 172 | -- Get the difference between two directory views. 173 | diff :: Directory -> Directory -> Diff 174 | 175 | -- List the current file or directories in a given Directory view. 176 | listDirectory :: Directory -> [Label, SystemType] 177 | μ listDirectory directory = foldMap toLabel $ snd (μ directory) 178 | where 179 | toLabel content = case content of 180 | File (label, _) -> [(label, IsFile)] 181 | Directory (label, _) -> [(label, IsDirectory)] 182 | IsRepo -> [] 183 | 184 | fileName :: File -> Label 185 | μ fileName file = fst (μ file) 186 | 187 | findFile :: NonEmpty Label -> Directory -> Maybe File 188 | μ findFile (label :| labels) (Directory (label', contents)) = 189 | if label == label' then go labels contents else Nothing 190 | where 191 | findFileWithLabel :: Foldable f => Label -> f DirectoryContents -> Maybe File 192 | findFileWithLabel label = find (\artefact -> case content of 193 | File (fileLabel, _) -> fileLabel == label 194 | Directory _ -> False 195 | IsRepo -> False) 196 | 197 | go :: [Label] -> NonEmpty DirectoryContents -> Just File 198 | go [] _ = Nothing 199 | go [label] contents = findMaybe (fileWithLabel label) contents 200 | go (label:labels) contents = (go labels . snd) <$> find ((label ==) . fst) onlyDirectories contents 201 | 202 | onlyDirectories :: Foldable f => f DirectoryContents -> [Directory] 203 | μ onlyDirectories = fmapMaybe (\content -> case content of 204 | d@(Directory _) -> Just d 205 | File _ -> Nothing 206 | IsRepo -> Nothing) . toList 207 | 208 | getSubDirectories :: Directory -> [Directory] 209 | μ getSubDirectories directory = foldMap f $ snd (μ directory) 210 | where 211 | f :: DirectoryContents -> [Directory] 212 | f = \case 213 | d@(Directory _) -> [d] 214 | File _ -> [] 215 | IsRepo -> [] 216 | 217 | -- Definition elided 218 | findDirectory :: NonEmpty Label -> Directory -> Maybe Directory 219 | 220 | -- Definition elided 221 | fuzzyFind :: Label -> [Directory] 222 | 223 | -- A Git Snapshot is grabbing the HEAD commit of your History 224 | -- and turning it into a Directory 225 | gitSnapshot :: Snapshot Commit 226 | μ gitSnapshot commits = second (\root -> root <> getDirectoryPtr $ Nel.head commits) emptyRepoRoot 227 | 228 | -- Opaque and defined by the backend 229 | getDirectoryPtr :: Commit -> Directory 230 | 231 | -- A Pijul history is semantically applying the patches in a 232 | -- topological order and achieving the Directory view. 233 | pijulHistory :: Snapshot Patch 234 | μ pijulHistory = foldl pijulMagic emptyRepoRoot 235 | 236 | -- Opaque and defined by the backend 237 | pijulMagic :: Patch -> Directory -> Directory 238 | 239 | -- Get the current History we are working with. 240 | getHistory :: Browser a (History a) 241 | μ getHistory = get 242 | 243 | setHistory :: History a -> Browser a () 244 | μ setHistory = put 245 | 246 | -- Get the current Directory in the Browser 247 | getDirectory :: Browser a Directory 248 | μ getDirectory = do 249 | hist <- get 250 | applySnapshot <- ask 251 | pure $ applySnapshot hist 252 | 253 | -- We modify the history by changing the internal history state. 254 | switchHistory :: (History a -> History a) -> Browser a b 255 | μ switchHistory f = modify f 256 | 257 | -- | Find the suffix of a History. 258 | findSuffix :: Eq a => a -> History a -> Maybe (History a) 259 | μ findSuffix a = nonEmpty . Nel.dropWhile (/= a) 260 | 261 | -- View the history up to a given point by supplying a function to modify 262 | -- the state. If this operation fails, then the default value is used. 263 | viewAt :: (History a -> Maybe (History a)) -> History a -> Browser a b 264 | μ viewAt f def = switchHistory (fromMaybe def . f) 265 | ``` 266 | -------------------------------------------------------------------------------- /nix/sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "niv": { 3 | "branch": "master", 4 | "description": "Easy dependency management for Nix projects", 5 | "homepage": "https://github.com/nmattia/niv", 6 | "owner": "nmattia", 7 | "repo": "niv", 8 | "rev": "82e5cd1ad3c387863f0545d7591512e76ab0fc41", 9 | "sha256": "090l219mzc0gi33i3psgph6s2pwsc8qy4lyrqjdj4qzkvmaj65a7", 10 | "type": "tarball", 11 | "url": "https://github.com/nmattia/niv/archive/82e5cd1ad3c387863f0545d7591512e76ab0fc41.tar.gz", 12 | "url_template": "https://github.com///archive/.tar.gz" 13 | }, 14 | "nixpkgs": { 15 | "branch": "release-21.11", 16 | "description": "Nix Packages collection", 17 | "homepage": "", 18 | "owner": "NixOS", 19 | "repo": "nixpkgs", 20 | "rev": "573603b7fdb9feb0eb8efc16ee18a015c667ab1b", 21 | "sha256": "1z7mxyw5yk1gcsjs5wl8zm4klxg4xngljgrsnpl579l998205qym", 22 | "type": "tarball", 23 | "url": "https://github.com/NixOS/nixpkgs/archive/573603b7fdb9feb0eb8efc16ee18a015c667ab1b.tar.gz", 24 | "url_template": "https://github.com///archive/.tar.gz" 25 | }, 26 | "rust-overlay": { 27 | "branch": "master", 28 | "description": "Pure and reproducible nix overlay for binary distributed rust toolchains", 29 | "homepage": "", 30 | "owner": "oxalica", 31 | "repo": "rust-overlay", 32 | "rev": "1a133f54a0229af8310879eac2c4a82c0576a0b9", 33 | "sha256": "0hab9wfr3shcb4z508gjc3y9slnxdjhfnmqhpvrkx4v2gq7pkm8n", 34 | "type": "tarball", 35 | "url": "https://github.com/oxalica/rust-overlay/archive/1a133f54a0229af8310879eac2c4a82c0576a0b9.tar.gz", 36 | "url_template": "https://github.com///archive/.tar.gz" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /nix/sources.nix: -------------------------------------------------------------------------------- 1 | # This file has been generated by Niv. 2 | 3 | let 4 | 5 | # 6 | # The fetchers. fetch_ fetches specs of type . 7 | # 8 | 9 | fetch_file = pkgs: name: spec: 10 | let 11 | name' = sanitizeName name + "-src"; 12 | in 13 | if spec.builtin or true then 14 | builtins_fetchurl { inherit (spec) url sha256; name = name'; } 15 | else 16 | pkgs.fetchurl { inherit (spec) url sha256; name = name'; }; 17 | 18 | fetch_tarball = pkgs: name: spec: 19 | let 20 | name' = sanitizeName name + "-src"; 21 | in 22 | if spec.builtin or true then 23 | builtins_fetchTarball { name = name'; inherit (spec) url sha256; } 24 | else 25 | pkgs.fetchzip { name = name'; inherit (spec) url sha256; }; 26 | 27 | fetch_git = name: spec: 28 | let 29 | ref = 30 | if spec ? ref then spec.ref else 31 | if spec ? branch then "refs/heads/${spec.branch}" else 32 | if spec ? tag then "refs/tags/${spec.tag}" else 33 | abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!"; 34 | in 35 | builtins.fetchGit { url = spec.repo; inherit (spec) rev; inherit ref; }; 36 | 37 | fetch_local = spec: spec.path; 38 | 39 | fetch_builtin-tarball = name: throw 40 | ''[${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`. 41 | $ niv modify ${name} -a type=tarball -a builtin=true''; 42 | 43 | fetch_builtin-url = name: throw 44 | ''[${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`. 45 | $ niv modify ${name} -a type=file -a builtin=true''; 46 | 47 | # 48 | # Various helpers 49 | # 50 | 51 | # https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695 52 | sanitizeName = name: 53 | ( 54 | concatMapStrings (s: if builtins.isList s then "-" else s) 55 | ( 56 | builtins.split "[^[:alnum:]+._?=-]+" 57 | ((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name) 58 | ) 59 | ); 60 | 61 | # The set of packages used when specs are fetched using non-builtins. 62 | mkPkgs = sources: system: 63 | let 64 | sourcesNixpkgs = 65 | import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { inherit system; }; 66 | hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath; 67 | hasThisAsNixpkgsPath = == ./.; 68 | in 69 | if builtins.hasAttr "nixpkgs" sources 70 | then sourcesNixpkgs 71 | else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then 72 | import {} 73 | else 74 | abort 75 | '' 76 | Please specify either (through -I or NIX_PATH=nixpkgs=...) or 77 | add a package called "nixpkgs" to your sources.json. 78 | ''; 79 | 80 | # The actual fetching function. 81 | fetch = pkgs: name: spec: 82 | 83 | if ! builtins.hasAttr "type" spec then 84 | abort "ERROR: niv spec ${name} does not have a 'type' attribute" 85 | else if spec.type == "file" then fetch_file pkgs name spec 86 | else if spec.type == "tarball" then fetch_tarball pkgs name spec 87 | else if spec.type == "git" then fetch_git name spec 88 | else if spec.type == "local" then fetch_local spec 89 | else if spec.type == "builtin-tarball" then fetch_builtin-tarball name 90 | else if spec.type == "builtin-url" then fetch_builtin-url name 91 | else 92 | abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}"; 93 | 94 | # If the environment variable NIV_OVERRIDE_${name} is set, then use 95 | # the path directly as opposed to the fetched source. 96 | replace = name: drv: 97 | let 98 | saneName = stringAsChars (c: if isNull (builtins.match "[a-zA-Z0-9]" c) then "_" else c) name; 99 | ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}"; 100 | in 101 | if ersatz == "" then drv else 102 | # this turns the string into an actual Nix path (for both absolute and 103 | # relative paths) 104 | if builtins.substring 0 1 ersatz == "/" then /. + ersatz else /. + builtins.getEnv "PWD" + "/${ersatz}"; 105 | 106 | # Ports of functions for older nix versions 107 | 108 | # a Nix version of mapAttrs if the built-in doesn't exist 109 | mapAttrs = builtins.mapAttrs or ( 110 | f: set: with builtins; 111 | listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set)) 112 | ); 113 | 114 | # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295 115 | range = first: last: if first > last then [] else builtins.genList (n: first + n) (last - first + 1); 116 | 117 | # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257 118 | stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1)); 119 | 120 | # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269 121 | stringAsChars = f: s: concatStrings (map f (stringToCharacters s)); 122 | concatMapStrings = f: list: concatStrings (map f list); 123 | concatStrings = builtins.concatStringsSep ""; 124 | 125 | # https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331 126 | optionalAttrs = cond: as: if cond then as else {}; 127 | 128 | # fetchTarball version that is compatible between all the versions of Nix 129 | builtins_fetchTarball = { url, name ? null, sha256 }@attrs: 130 | let 131 | inherit (builtins) lessThan nixVersion fetchTarball; 132 | in 133 | if lessThan nixVersion "1.12" then 134 | fetchTarball ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; })) 135 | else 136 | fetchTarball attrs; 137 | 138 | # fetchurl version that is compatible between all the versions of Nix 139 | builtins_fetchurl = { url, name ? null, sha256 }@attrs: 140 | let 141 | inherit (builtins) lessThan nixVersion fetchurl; 142 | in 143 | if lessThan nixVersion "1.12" then 144 | fetchurl ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; })) 145 | else 146 | fetchurl attrs; 147 | 148 | # Create the final "sources" from the config 149 | mkSources = config: 150 | mapAttrs ( 151 | name: spec: 152 | if builtins.hasAttr "outPath" spec 153 | then abort 154 | "The values in sources.json should not have an 'outPath' attribute" 155 | else 156 | spec // { outPath = replace name (fetch config.pkgs name spec); } 157 | ) config.sources; 158 | 159 | # The "config" used by the fetchers 160 | mkConfig = 161 | { sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null 162 | , sources ? if isNull sourcesFile then {} else builtins.fromJSON (builtins.readFile sourcesFile) 163 | , system ? builtins.currentSystem 164 | , pkgs ? mkPkgs sources system 165 | }: rec { 166 | # The sources, i.e. the attribute set of spec name to spec 167 | inherit sources; 168 | 169 | # The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers 170 | inherit pkgs; 171 | }; 172 | 173 | in 174 | mkSources (mkConfig {}) // { __functor = _: settings: mkSources (mkConfig settings); } 175 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | stable 2 | -------------------------------------------------------------------------------- /scripts/license-headers: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Iterates over all rust source files and ensures they start with the license 4 | # header as per the `.license-header` file at the root of the repository. 5 | 6 | set -euo pipefail 7 | IFS=$'\n' 8 | 9 | shopt -s globstar extglob 10 | for file in */+(src|tests|examples)/**/*.rs 11 | do 12 | rustfmt --config license_template_path=".license-header" --color never --quiet --check "$file" || { 13 | sed -i -e '1r.license-header' -e '1{h;d}' -e '2{x;G}' "$file" 14 | } 15 | done 16 | -------------------------------------------------------------------------------- /scripts/setup-branches.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Ensure that we have heads/dev and we're on the master branch 5 | git submodule foreach "git checkout dev" 6 | git submodule foreach "git checkout master" 7 | 8 | # Ensure that we have the mock branches set up in the submodule 9 | input="./data/mock-branches.txt" 10 | while IFS= read -r line 11 | do 12 | IFS=, read -a pair <<< $line 13 | echo "Creating branch ${pair[0]}" 14 | git -C data/git-platinum/ update-ref ${pair[0]} ${pair[1]} 15 | done < "$input" 16 | -------------------------------------------------------------------------------- /scripts/update-git-platinum.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Verify that the script is run from project root. 5 | BASE=$(basename $(pwd)) 6 | 7 | if [ "${BASE}" != "radicle-surf" ] 8 | then 9 | echo "ERROR: this script should be run from the root of radicle-surf" 10 | exit 1 11 | fi 12 | 13 | TARBALL_PATH=surf/data/git-platinum.tgz 14 | WORKDIR=.workdir 15 | PLATINUM_REPO="$WORKDIR/git-platinum" 16 | 17 | # Create the workdir if needed. 18 | mkdir -p $WORKDIR 19 | 20 | # This is here in case the last script run failed and it never cleaned up. 21 | rm -rf "$PLATINUM_REPO" 22 | 23 | # Clone an up-to-date version of git-platinum. 24 | git clone https://github.com/radicle-dev/git-platinum.git "$PLATINUM_REPO" 25 | git -C "$PLATINUM_REPO" checkout dev 26 | 27 | # Add the necessary refs. 28 | input="./surf/data/mock-branches.txt" 29 | while IFS= read -r line 30 | do 31 | IFS=, read -a pair <<< $line 32 | echo "Creating branch ${pair[0]}" 33 | git -C "$PLATINUM_REPO" update-ref ${pair[0]} ${pair[1]} 34 | done < "$input" 35 | 36 | # Update the archive. 37 | tar -czf $WORKDIR/git-platinum.tgz -C $WORKDIR git-platinum 38 | mv $WORKDIR/git-platinum.tgz $TARBALL_PATH 39 | 40 | # Clean up. 41 | rm -rf "$PLATINUM_REPO" 42 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { sources ? import ./nix/sources.nix 2 | , pkgs ? import sources.nixpkgs { 3 | overlays = [ (import sources.rust-overlay) ]; 4 | } 5 | }: 6 | let 7 | stable = pkgs.rust-bin.stable.latest.default; 8 | rust = stable.override { 9 | extensions = [ "rust-src" "rust-analysis" ]; 10 | }; 11 | in 12 | with pkgs; 13 | mkShell { 14 | name = "radicle-surf"; 15 | buildInputs = [ 16 | clang 17 | cargo-deny 18 | cargo-expand 19 | cargo-watch 20 | # gnuplot for benchmark purposes 21 | gnuplot 22 | lld 23 | pkgconfig 24 | openssl 25 | pkgs.rust-bin.nightly."2021-12-02".rustfmt 26 | ripgrep 27 | rust 28 | zlib 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /source/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "radicle-source" 3 | description = "A high level API for browsing source files" 4 | version = "0.4.0" 5 | authors = ["The Radicle Team "] 6 | edition = "2018" 7 | homepage = "https://github.com/radicle-dev/radicle-surf" 8 | repository = "https://github.com/radicle-dev/radicle-surf" 9 | license = "GPL-3.0-or-later" 10 | 11 | [features] 12 | syntax = ["syntect"] 13 | 14 | [dependencies] 15 | base64 = "0.13" 16 | log = "0.4" 17 | lazy_static = "1.4" 18 | nonempty = "0.6" 19 | serde = { version = "1.0", features = [ "derive" ] } 20 | syntect = { version = "4.2", optional = true } 21 | thiserror = "1.0" 22 | 23 | [dependencies.git2] 24 | version = ">= 0.12" 25 | default-features = false 26 | features = [] 27 | 28 | [dependencies.radicle-surf] 29 | version = "^0.8.0" 30 | features = ["serialize"] 31 | path = "../surf" 32 | -------------------------------------------------------------------------------- /source/src/branch.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::fmt; 19 | 20 | use serde::{Deserialize, Serialize}; 21 | 22 | use radicle_surf::vcs::git::{self, Browser, RefScope}; 23 | 24 | use crate::error::Error; 25 | 26 | /// Branch name representation. 27 | #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] 28 | pub struct Branch(pub(crate) String); 29 | 30 | impl From for Branch { 31 | fn from(name: String) -> Self { 32 | Self(name) 33 | } 34 | } 35 | 36 | impl From for Branch { 37 | fn from(branch: git::Branch) -> Self { 38 | Self(branch.name.to_string()) 39 | } 40 | } 41 | 42 | impl fmt::Display for Branch { 43 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 44 | write!(f, "{}", self.0) 45 | } 46 | } 47 | 48 | /// Given a project id to a repo returns the list of branches. 49 | /// 50 | /// # Errors 51 | /// 52 | /// Will return [`Error`] if the project doesn't exist or the surf interaction 53 | /// fails. 54 | pub fn branches(browser: &Browser<'_>, filter: RefScope) -> Result, Error> { 55 | let mut branches = browser 56 | .list_branches(filter)? 57 | .into_iter() 58 | .map(|b| Branch(b.name.name().to_string())) 59 | .collect::>(); 60 | 61 | branches.sort(); 62 | 63 | Ok(branches) 64 | } 65 | 66 | /// Information about a locally checked out repository. 67 | #[derive(Deserialize, Serialize)] 68 | pub struct LocalState { 69 | /// List of branches. 70 | branches: Vec, 71 | } 72 | 73 | /// Given a path to a repo returns the list of branches and if it is managed by 74 | /// coco. 75 | /// 76 | /// # Errors 77 | /// 78 | /// Will return [`Error`] if the repository doesn't exist. 79 | pub fn local_state(repo_path: &str, default_branch: &str) -> Result { 80 | let repo = git2::Repository::open(repo_path).map_err(git::error::Error::from)?; 81 | let first_branch = repo 82 | .branches(Some(git2::BranchType::Local)) 83 | .map_err(git::error::Error::from)? 84 | .filter_map(|branch_result| { 85 | let (branch, _) = branch_result.ok()?; 86 | let name = branch.name().ok()?; 87 | name.map(String::from) 88 | }) 89 | .min() 90 | .ok_or(Error::NoBranches)?; 91 | 92 | let repo = git::Repository::new(repo_path)?; 93 | 94 | let browser = match Browser::new(&repo, git::Branch::local(default_branch)) { 95 | Ok(browser) => browser, 96 | Err(_) => Browser::new(&repo, git::Branch::local(&first_branch))?, 97 | }; 98 | 99 | let mut branches = browser 100 | .list_branches(RefScope::Local)? 101 | .into_iter() 102 | .map(|b| Branch(b.name.name().to_string())) 103 | .collect::>(); 104 | 105 | branches.sort(); 106 | 107 | Ok(LocalState { branches }) 108 | } 109 | -------------------------------------------------------------------------------- /source/src/commit.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::convert::TryFrom as _; 19 | 20 | use serde::{ 21 | ser::{SerializeStruct as _, Serializer}, 22 | Serialize, 23 | }; 24 | 25 | use radicle_surf::{ 26 | diff, 27 | vcs::git::{self, Browser, Rev}, 28 | }; 29 | 30 | use crate::{branch::Branch, error::Error, person::Person, revision::Revision}; 31 | 32 | /// Commit statistics. 33 | #[derive(Clone, Serialize)] 34 | pub struct Stats { 35 | /// Additions. 36 | pub additions: u64, 37 | /// Deletions. 38 | pub deletions: u64, 39 | } 40 | 41 | /// Representation of a changeset between two revs. 42 | #[derive(Clone, Serialize)] 43 | pub struct Commit { 44 | /// The commit header. 45 | pub header: Header, 46 | /// The change statistics for this commit. 47 | pub stats: Stats, 48 | /// The changeset introduced by this commit. 49 | pub diff: diff::Diff, 50 | /// The list of branches this commit belongs to. 51 | pub branches: Vec, 52 | } 53 | 54 | /// Representation of a code commit. 55 | #[derive(Clone)] 56 | pub struct Header { 57 | /// Identifier of the commit in the form of a sha1 hash. Often referred to 58 | /// as oid or object id. 59 | pub sha1: git2::Oid, 60 | /// The author of the commit. 61 | pub author: Person, 62 | /// The summary of the commit message body. 63 | pub summary: String, 64 | /// The entire commit message body. 65 | pub message: String, 66 | /// The committer of the commit. 67 | pub committer: Person, 68 | /// The recorded time of the committer signature. This is a convenience 69 | /// alias until we expose the actual author and commiter signatures. 70 | pub committer_time: git2::Time, 71 | } 72 | 73 | impl Header { 74 | /// Returns the commit description text. This is the text after the one-line 75 | /// summary. 76 | #[must_use] 77 | pub fn description(&self) -> &str { 78 | self.message 79 | .strip_prefix(&self.summary) 80 | .unwrap_or(&self.message) 81 | .trim() 82 | } 83 | } 84 | 85 | impl From<&git::Commit> for Header { 86 | fn from(commit: &git::Commit) -> Self { 87 | Self { 88 | sha1: commit.id, 89 | author: Person { 90 | name: commit.author.name.clone(), 91 | email: commit.author.email.clone(), 92 | }, 93 | summary: commit.summary.clone(), 94 | message: commit.message.clone(), 95 | committer: Person { 96 | name: commit.committer.name.clone(), 97 | email: commit.committer.email.clone(), 98 | }, 99 | committer_time: commit.committer.time, 100 | } 101 | } 102 | } 103 | 104 | impl Serialize for Header { 105 | fn serialize(&self, serializer: S) -> Result 106 | where 107 | S: Serializer, 108 | { 109 | let mut state = serializer.serialize_struct("Header", 6)?; 110 | state.serialize_field("sha1", &self.sha1.to_string())?; 111 | state.serialize_field("author", &self.author)?; 112 | state.serialize_field("summary", &self.summary)?; 113 | state.serialize_field("description", &self.description())?; 114 | state.serialize_field("committer", &self.committer)?; 115 | state.serialize_field("committerTime", &self.committer_time.seconds())?; 116 | state.end() 117 | } 118 | } 119 | 120 | /// A selection of commit headers and their statistics. 121 | #[derive(Serialize)] 122 | pub struct Commits { 123 | /// The commit headers 124 | pub headers: Vec
, 125 | /// The statistics for the commit headers 126 | pub stats: radicle_surf::vcs::git::Stats, 127 | } 128 | 129 | /// Retrieves a [`Commit`]. 130 | /// 131 | /// # Errors 132 | /// 133 | /// Will return [`Error`] if the project doesn't exist or the surf interaction 134 | /// fails. 135 | pub fn commit(browser: &mut Browser<'_>, sha1: git2::Oid) -> Result { 136 | browser.commit(sha1)?; 137 | 138 | let history = browser.get(); 139 | let commit = history.first(); 140 | 141 | let diff = if let Some(parent) = commit.parents.first() { 142 | browser.diff(*parent, sha1)? 143 | } else { 144 | browser.initial_diff(sha1)? 145 | }; 146 | 147 | let mut deletions = 0; 148 | let mut additions = 0; 149 | 150 | for file in &diff.modified { 151 | if let diff::FileDiff::Plain { ref hunks } = file.diff { 152 | for hunk in hunks.iter() { 153 | for line in &hunk.lines { 154 | match line { 155 | diff::LineDiff::Addition { .. } => additions += 1, 156 | diff::LineDiff::Deletion { .. } => deletions += 1, 157 | _ => {}, 158 | } 159 | } 160 | } 161 | } 162 | } 163 | 164 | for file in &diff.created { 165 | if let diff::FileDiff::Plain { ref hunks } = file.diff { 166 | for hunk in hunks.iter() { 167 | for line in &hunk.lines { 168 | if let diff::LineDiff::Addition { .. } = line { 169 | additions += 1 170 | } 171 | } 172 | } 173 | } 174 | } 175 | 176 | for file in &diff.deleted { 177 | if let diff::FileDiff::Plain { ref hunks } = file.diff { 178 | for hunk in hunks.iter() { 179 | for line in &hunk.lines { 180 | if let diff::LineDiff::Deletion { .. } = line { 181 | deletions += 1 182 | } 183 | } 184 | } 185 | } 186 | } 187 | 188 | let branches = browser 189 | .revision_branches(sha1)? 190 | .into_iter() 191 | .map(Branch::from) 192 | .collect(); 193 | 194 | Ok(Commit { 195 | header: Header::from(commit), 196 | stats: Stats { 197 | additions, 198 | deletions, 199 | }, 200 | diff, 201 | branches, 202 | }) 203 | } 204 | 205 | /// Retrieves the [`Header`] for the given `sha1`. 206 | /// 207 | /// # Errors 208 | /// 209 | /// Will return [`Error`] if the project doesn't exist or the surf interaction 210 | /// fails. 211 | pub fn header(browser: &mut Browser<'_>, sha1: git2::Oid) -> Result { 212 | browser.commit(sha1)?; 213 | 214 | let history = browser.get(); 215 | let commit = history.first(); 216 | 217 | Ok(Header::from(commit)) 218 | } 219 | 220 | /// Retrieves the [`Commit`] history for the given `revision`. 221 | /// 222 | /// # Errors 223 | /// 224 | /// Will return [`Error`] if the project doesn't exist or the surf interaction 225 | /// fails. 226 | pub fn commits

( 227 | browser: &mut Browser<'_>, 228 | maybe_revision: Option>, 229 | ) -> Result 230 | where 231 | P: ToString, 232 | { 233 | let maybe_revision = maybe_revision.map(Rev::try_from).transpose()?; 234 | 235 | if let Some(revision) = maybe_revision { 236 | browser.rev(revision)?; 237 | } 238 | 239 | let headers = browser.get().iter().map(Header::from).collect(); 240 | let stats = browser.get_stats()?; 241 | 242 | Ok(Commits { headers, stats }) 243 | } 244 | -------------------------------------------------------------------------------- /source/src/error.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use radicle_surf::{file_system, git}; 19 | 20 | /// An error occurred when interacting with [`radicle_surf`] for browsing source 21 | /// code. 22 | #[derive(Debug, thiserror::Error)] 23 | pub enum Error { 24 | /// We expect at least one [`crate::revision::Revisions`] when looking at a 25 | /// project, however the computation found none. 26 | #[error( 27 | "while trying to get user revisions we could not find any, there should be at least one" 28 | )] 29 | EmptyRevisions, 30 | 31 | /// An error occurred during a [`radicle_surf::file_system`] operation. 32 | #[error(transparent)] 33 | FileSystem(#[from] file_system::Error), 34 | 35 | /// An error occurred during a [`radicle_surf::git`] operation. 36 | #[error(transparent)] 37 | Git(#[from] git::error::Error), 38 | 39 | /// When trying to query a repositories branches, but there are none. 40 | #[error("the repository has no branches")] 41 | NoBranches, 42 | 43 | /// Trying to find a file path which could not be found. 44 | #[error("the path '{0}' was not found")] 45 | PathNotFound(file_system::Path), 46 | } 47 | -------------------------------------------------------------------------------- /source/src/lib.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | //! Source code related functionality. 19 | 20 | /// To avoid incompatible versions of `radicle-surf`, `radicle-source` 21 | /// re-exports the package under the `surf` alias. 22 | pub use radicle_surf as surf; 23 | 24 | pub mod branch; 25 | pub use branch::{branches, local_state, Branch, LocalState}; 26 | 27 | pub mod commit; 28 | pub use commit::{commit, commits, Commit}; 29 | 30 | pub mod error; 31 | pub use error::Error; 32 | 33 | pub mod object; 34 | pub use object::{blob, tree, Blob, BlobContent, Info, ObjectType, Tree}; 35 | 36 | pub mod oid; 37 | pub use oid::Oid; 38 | 39 | pub mod person; 40 | pub use person::Person; 41 | 42 | pub mod revision; 43 | pub use revision::Revision; 44 | 45 | #[cfg(feature = "syntax")] 46 | pub mod syntax; 47 | #[cfg(feature = "syntax")] 48 | pub use syntax::SYNTAX_SET; 49 | 50 | pub mod tag; 51 | pub use tag::{tags, Tag}; 52 | -------------------------------------------------------------------------------- /source/src/object.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use serde::{ 19 | ser::{SerializeStruct as _, Serializer}, 20 | Serialize, 21 | }; 22 | 23 | pub mod blob; 24 | pub use blob::{blob, Blob, BlobContent}; 25 | 26 | pub mod tree; 27 | pub use tree::{tree, Tree, TreeEntry}; 28 | 29 | use crate::commit; 30 | 31 | /// Git object types. 32 | /// 33 | /// `shafiul.github.io/gitbook/1_the_git_object_model.html` 34 | #[derive(Debug, Eq, Ord, PartialOrd, PartialEq)] 35 | pub enum ObjectType { 36 | /// References a list of other trees and blobs. 37 | Tree, 38 | /// Used to store file data. 39 | Blob, 40 | } 41 | 42 | impl Serialize for ObjectType { 43 | fn serialize(&self, serializer: S) -> Result 44 | where 45 | S: Serializer, 46 | { 47 | match self { 48 | Self::Blob => serializer.serialize_unit_variant("ObjectType", 0, "BLOB"), 49 | Self::Tree => serializer.serialize_unit_variant("ObjectType", 1, "TREE"), 50 | } 51 | } 52 | } 53 | 54 | /// Set of extra information we carry for blob and tree objects returned from 55 | /// the API. 56 | pub struct Info { 57 | /// Name part of an object. 58 | pub name: String, 59 | /// The type of the object. 60 | pub object_type: ObjectType, 61 | /// The last commmit that touched this object. 62 | pub last_commit: Option, 63 | } 64 | 65 | impl Serialize for Info { 66 | fn serialize(&self, serializer: S) -> Result 67 | where 68 | S: Serializer, 69 | { 70 | let mut state = serializer.serialize_struct("Info", 3)?; 71 | state.serialize_field("name", &self.name)?; 72 | state.serialize_field("objectType", &self.object_type)?; 73 | state.serialize_field("lastCommit", &self.last_commit)?; 74 | state.end() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /source/src/object/blob.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::{ 19 | convert::TryFrom as _, 20 | str::{self, FromStr as _}, 21 | }; 22 | 23 | use serde::{ 24 | ser::{SerializeStruct as _, Serializer}, 25 | Serialize, 26 | }; 27 | 28 | use radicle_surf::{ 29 | file_system, 30 | vcs::git::{Browser, Rev}, 31 | }; 32 | 33 | use crate::{ 34 | commit, 35 | error::Error, 36 | object::{Info, ObjectType}, 37 | revision::Revision, 38 | }; 39 | 40 | #[cfg(feature = "syntax")] 41 | use crate::syntax; 42 | 43 | /// File data abstraction. 44 | pub struct Blob { 45 | /// Actual content of the file, if the content is ASCII. 46 | pub content: BlobContent, 47 | /// Extra info for the file. 48 | pub info: Info, 49 | /// Absolute path to the object from the root of the repo. 50 | pub path: String, 51 | } 52 | 53 | impl Blob { 54 | /// Indicates if the content of the [`Blob`] is binary. 55 | #[must_use] 56 | pub fn is_binary(&self) -> bool { 57 | matches!(self.content, BlobContent::Binary(_)) 58 | } 59 | 60 | /// Indicates if the content of the [`Blob`] is HTML. 61 | #[must_use] 62 | pub const fn is_html(&self) -> bool { 63 | matches!(self.content, BlobContent::Html(_)) 64 | } 65 | } 66 | 67 | impl Serialize for Blob { 68 | fn serialize(&self, serializer: S) -> Result 69 | where 70 | S: Serializer, 71 | { 72 | let mut state = serializer.serialize_struct("Blob", 5)?; 73 | state.serialize_field("binary", &self.is_binary())?; 74 | state.serialize_field("html", &self.is_html())?; 75 | state.serialize_field("content", &self.content)?; 76 | state.serialize_field("info", &self.info)?; 77 | state.serialize_field("path", &self.path)?; 78 | state.end() 79 | } 80 | } 81 | 82 | /// Variants of blob content. 83 | #[derive(PartialEq)] 84 | pub enum BlobContent { 85 | /// Content is plain text and can be passed as a string. 86 | Plain(String), 87 | /// Content is syntax-highlighted HTML. 88 | /// 89 | /// Note that is necessary to enable the `syntax` feature flag for this 90 | /// variant to be constructed. Use `highlighting::blob`, instead of 91 | /// [`blob`] to get highlighted content. 92 | Html(String), 93 | /// Content is binary and needs special treatment. 94 | Binary(Vec), 95 | } 96 | 97 | impl Serialize for BlobContent { 98 | fn serialize(&self, serializer: S) -> Result 99 | where 100 | S: Serializer, 101 | { 102 | match self { 103 | Self::Plain(content) | Self::Html(content) => serializer.serialize_str(content), 104 | Self::Binary(bytes) => { 105 | let encoded = base64::encode(bytes); 106 | serializer.serialize_str(&encoded) 107 | }, 108 | } 109 | } 110 | } 111 | 112 | /// Returns the [`Blob`] for a file at `revision` under `path`. 113 | /// 114 | /// # Errors 115 | /// 116 | /// Will return [`Error`] if the project doesn't exist or a surf interaction 117 | /// fails. 118 | pub fn blob

( 119 | browser: &mut Browser, 120 | maybe_revision: Option>, 121 | path: &str, 122 | ) -> Result 123 | where 124 | P: ToString, 125 | { 126 | make_blob(browser, maybe_revision, path, content) 127 | } 128 | 129 | fn make_blob( 130 | browser: &mut Browser, 131 | maybe_revision: Option>, 132 | path: &str, 133 | content: C, 134 | ) -> Result 135 | where 136 | P: ToString, 137 | C: FnOnce(&[u8]) -> BlobContent, 138 | { 139 | let maybe_revision = maybe_revision.map(Rev::try_from).transpose()?; 140 | if let Some(revision) = maybe_revision { 141 | browser.rev(revision)?; 142 | } 143 | 144 | let root = browser.get_directory()?; 145 | let p = file_system::Path::from_str(path)?; 146 | 147 | let file = root 148 | .find_file(p.clone()) 149 | .ok_or_else(|| Error::PathNotFound(p.clone()))?; 150 | 151 | let mut commit_path = file_system::Path::root(); 152 | commit_path.append(p.clone()); 153 | 154 | let last_commit = browser 155 | .last_commit(commit_path)? 156 | .map(|c| commit::Header::from(&c)); 157 | let (_rest, last) = p.split_last(); 158 | 159 | let content = content(&file.contents); 160 | 161 | Ok(Blob { 162 | content, 163 | info: Info { 164 | name: last.to_string(), 165 | object_type: ObjectType::Blob, 166 | last_commit, 167 | }, 168 | path: path.to_string(), 169 | }) 170 | } 171 | 172 | /// Return a [`BlobContent`] given a byte slice. 173 | fn content(content: &[u8]) -> BlobContent { 174 | match str::from_utf8(content) { 175 | Ok(utf8) => BlobContent::Plain(utf8.to_owned()), 176 | Err(_) => BlobContent::Binary(content.to_owned()), 177 | } 178 | } 179 | 180 | #[cfg(feature = "syntax")] 181 | pub mod highlighting { 182 | use super::*; 183 | 184 | /// Returns the [`Blob`] for a file at `revision` under `path`. 185 | /// 186 | /// # Errors 187 | /// 188 | /// Will return [`Error`] if the project doesn't exist or a surf interaction 189 | /// fails. 190 | pub fn blob

( 191 | browser: &mut Browser, 192 | maybe_revision: Option>, 193 | path: &str, 194 | theme: Option<&str>, 195 | ) -> Result 196 | where 197 | P: ToString, 198 | { 199 | make_blob(browser, maybe_revision, path, |contents| { 200 | content(path, contents, theme) 201 | }) 202 | } 203 | 204 | /// Return a [`BlobContent`] given a file path, content and theme. Attempts 205 | /// to perform syntax highlighting when the theme is `Some`. 206 | fn content(path: &str, content: &[u8], theme_name: Option<&str>) -> BlobContent { 207 | let content = match str::from_utf8(content) { 208 | Ok(content) => content, 209 | Err(_) => return BlobContent::Binary(content.to_owned()), 210 | }; 211 | 212 | match theme_name { 213 | None => BlobContent::Plain(content.to_owned()), 214 | Some(theme) => syntax::highlight(path, content, theme) 215 | .map_or_else(|| BlobContent::Plain(content.to_owned()), BlobContent::Html), 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /source/src/object/tree.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::{convert::TryFrom as _, str::FromStr as _}; 19 | 20 | use serde::{ 21 | ser::{SerializeStruct as _, Serializer}, 22 | Serialize, 23 | }; 24 | 25 | use radicle_surf::{ 26 | file_system, 27 | vcs::git::{Browser, Rev}, 28 | }; 29 | 30 | use crate::{ 31 | commit, 32 | error::Error, 33 | object::{Info, ObjectType}, 34 | revision::Revision, 35 | }; 36 | 37 | /// Result of a directory listing, carries other trees and blobs. 38 | pub struct Tree { 39 | /// Absolute path to the tree object from the repo root. 40 | pub path: String, 41 | /// Entries listed in that tree result. 42 | pub entries: Vec, 43 | /// Extra info for the tree object. 44 | pub info: Info, 45 | } 46 | 47 | impl Serialize for Tree { 48 | fn serialize(&self, serializer: S) -> Result 49 | where 50 | S: Serializer, 51 | { 52 | let mut state = serializer.serialize_struct("Tree", 3)?; 53 | state.serialize_field("path", &self.path)?; 54 | state.serialize_field("entries", &self.entries)?; 55 | state.serialize_field("info", &self.info)?; 56 | state.end() 57 | } 58 | } 59 | 60 | // TODO(xla): Ensure correct by construction. 61 | /// Entry in a Tree result. 62 | pub struct TreeEntry { 63 | /// Extra info for the entry. 64 | pub info: Info, 65 | /// Absolute path to the object from the root of the repo. 66 | pub path: String, 67 | } 68 | 69 | impl Serialize for TreeEntry { 70 | fn serialize(&self, serializer: S) -> Result 71 | where 72 | S: Serializer, 73 | { 74 | let mut state = serializer.serialize_struct("Tree", 2)?; 75 | state.serialize_field("path", &self.path)?; 76 | state.serialize_field("info", &self.info)?; 77 | state.end() 78 | } 79 | } 80 | 81 | /// Retrieve the [`Tree`] for the given `revision` and directory `prefix`. 82 | /// 83 | /// # Errors 84 | /// 85 | /// Will return [`Error`] if any of the surf interactions fail. 86 | pub fn tree

( 87 | browser: &mut Browser<'_>, 88 | maybe_revision: Option>, 89 | maybe_prefix: Option, 90 | ) -> Result 91 | where 92 | P: ToString, 93 | { 94 | let maybe_revision = maybe_revision.map(Rev::try_from).transpose()?; 95 | let prefix = maybe_prefix.unwrap_or_default(); 96 | 97 | if let Some(revision) = maybe_revision { 98 | browser.rev(revision)?; 99 | } 100 | 101 | let path = if prefix == "/" || prefix.is_empty() { 102 | file_system::Path::root() 103 | } else { 104 | file_system::Path::from_str(&prefix)? 105 | }; 106 | 107 | let root_dir = browser.get_directory()?; 108 | let prefix_dir = if path.is_root() { 109 | root_dir 110 | } else { 111 | root_dir 112 | .find_directory(path.clone()) 113 | .ok_or_else(|| Error::PathNotFound(path.clone()))? 114 | }; 115 | let mut prefix_contents = prefix_dir.list_directory(); 116 | prefix_contents.sort(); 117 | 118 | let entries_results: Result, Error> = prefix_contents 119 | .iter() 120 | .map(|(label, system_type)| { 121 | let entry_path = if path.is_root() { 122 | file_system::Path::new(label.clone()) 123 | } else { 124 | let mut p = path.clone(); 125 | p.push(label.clone()); 126 | p 127 | }; 128 | let mut commit_path = file_system::Path::root(); 129 | commit_path.append(entry_path.clone()); 130 | 131 | let info = Info { 132 | name: label.to_string(), 133 | object_type: match system_type { 134 | file_system::SystemType::Directory => ObjectType::Tree, 135 | file_system::SystemType::File => ObjectType::Blob, 136 | }, 137 | last_commit: None, 138 | }; 139 | 140 | Ok(TreeEntry { 141 | info, 142 | path: entry_path.to_string(), 143 | }) 144 | }) 145 | .collect(); 146 | 147 | let mut entries = entries_results?; 148 | 149 | // We want to ensure that in the response Tree entries come first. `Ord` being 150 | // derived on the enum ensures Variant declaration order. 151 | // 152 | // https://doc.rust-lang.org/std/cmp/trait.Ord.html#derivable 153 | entries.sort_by(|a, b| a.info.object_type.cmp(&b.info.object_type)); 154 | 155 | let last_commit = if path.is_root() { 156 | Some(commit::Header::from(browser.get().first())) 157 | } else { 158 | None 159 | }; 160 | let name = if path.is_root() { 161 | "".into() 162 | } else { 163 | let (_first, last) = path.split_last(); 164 | last.to_string() 165 | }; 166 | let info = Info { 167 | name, 168 | object_type: ObjectType::Tree, 169 | last_commit, 170 | }; 171 | 172 | Ok(Tree { 173 | path: prefix, 174 | entries, 175 | info, 176 | }) 177 | } 178 | -------------------------------------------------------------------------------- /source/src/oid.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::convert::TryFrom; 19 | 20 | use serde::{Deserialize, Serialize}; 21 | 22 | #[derive(Clone, Copy, Debug, Deserialize, Serialize)] 23 | #[serde(try_from = "&str", into = "String")] 24 | pub struct Oid(pub git2::Oid); 25 | 26 | impl TryFrom<&str> for Oid { 27 | type Error = git2::Error; 28 | 29 | fn try_from(value: &str) -> Result { 30 | value.parse().map(Oid) 31 | } 32 | } 33 | 34 | impl From for String { 35 | fn from(oid: Oid) -> Self { 36 | oid.0.to_string() 37 | } 38 | } 39 | 40 | impl From for git2::Oid { 41 | fn from(oid: Oid) -> Self { 42 | oid.0 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /source/src/person.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use serde::Serialize; 19 | 20 | /// Representation of a person (e.g. committer, author, signer) from a 21 | /// repository. Usually extracted from a signature. 22 | #[derive(Clone, Debug, Serialize)] 23 | pub struct Person { 24 | /// Name part of the commit signature. 25 | pub name: String, 26 | /// Email part of the commit signature. 27 | pub email: String, 28 | } 29 | -------------------------------------------------------------------------------- /source/src/revision.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::convert::TryFrom; 19 | 20 | use nonempty::NonEmpty; 21 | use serde::{Deserialize, Serialize}; 22 | 23 | use radicle_surf::vcs::git::{self, Browser, RefScope, Rev}; 24 | 25 | use crate::{ 26 | branch::{branches, Branch}, 27 | error::Error, 28 | oid::Oid, 29 | tag::{tags, Tag}, 30 | }; 31 | 32 | pub enum Category { 33 | Local { peer_id: P, user: U }, 34 | Remote { peer_id: P, user: U }, 35 | } 36 | 37 | /// A revision selector for a `Browser`. 38 | #[derive(Debug, Clone, Serialize, Deserialize)] 39 | #[serde(rename_all = "camelCase", tag = "type")] 40 | pub enum Revision

{ 41 | /// Select a tag under the name provided. 42 | #[serde(rename_all = "camelCase")] 43 | Tag { 44 | /// Name of the tag. 45 | name: String, 46 | }, 47 | /// Select a branch under the name provided. 48 | #[serde(rename_all = "camelCase")] 49 | Branch { 50 | /// Name of the branch. 51 | name: String, 52 | /// The remote peer, if specified. 53 | peer_id: Option

, 54 | }, 55 | /// Select a SHA1 under the name provided. 56 | #[serde(rename_all = "camelCase")] 57 | Sha { 58 | /// The SHA1 value. 59 | sha: Oid, 60 | }, 61 | } 62 | 63 | impl

TryFrom> for Rev 64 | where 65 | P: ToString, 66 | { 67 | type Error = Error; 68 | 69 | fn try_from(other: Revision

) -> Result { 70 | match other { 71 | Revision::Tag { name } => Ok(git::TagName::new(&name).into()), 72 | Revision::Branch { name, peer_id } => Ok(match peer_id { 73 | Some(peer) => { 74 | git::Branch::remote(&format!("heads/{}", name), &peer.to_string()).into() 75 | }, 76 | None => git::Branch::local(&name).into(), 77 | }), 78 | Revision::Sha { sha } => { 79 | let oid: git2::Oid = sha.into(); 80 | Ok(oid.into()) 81 | }, 82 | } 83 | } 84 | } 85 | 86 | /// Bundled response to retrieve both [`Branch`]es and [`Tag`]s for a user's 87 | /// repo. 88 | #[derive(Clone, Debug, PartialEq, Eq)] 89 | pub struct Revisions { 90 | /// The peer peer_id for the user. 91 | pub peer_id: P, 92 | /// The user who owns these revisions. 93 | pub user: U, 94 | /// List of [`git::Branch`]. 95 | pub branches: NonEmpty, 96 | /// List of [`git::Tag`]. 97 | pub tags: Vec, 98 | } 99 | 100 | /// Provide the [`Revisions`] for the given `peer_id`, looking for the 101 | /// branches as [`RefScope::Remote`]. 102 | /// 103 | /// If there are no branches then this returns `None`. 104 | /// 105 | /// # Errors 106 | /// 107 | /// * If we cannot get the branches from the `Browser` 108 | pub fn remote( 109 | browser: &Browser, 110 | peer_id: P, 111 | user: U, 112 | ) -> Result>, Error> 113 | where 114 | P: Clone + ToString, 115 | { 116 | let remote_branches = branches(browser, Some(peer_id.clone()).into())?; 117 | Ok( 118 | NonEmpty::from_vec(remote_branches).map(|branches| Revisions { 119 | peer_id, 120 | user, 121 | branches, 122 | // TODO(rudolfs): implement remote peer tags once we decide how 123 | // https://radicle.community/t/git-tags/214 124 | tags: vec![], 125 | }), 126 | ) 127 | } 128 | 129 | /// Provide the [`Revisions`] for the given `peer_id`, looking for the 130 | /// branches as [`RefScope::Local`]. 131 | /// 132 | /// If there are no branches then this returns `None`. 133 | /// 134 | /// # Errors 135 | /// 136 | /// * If we cannot get the branches from the `Browser` 137 | pub fn local(browser: &Browser, peer_id: P, user: U) -> Result>, Error> 138 | where 139 | P: Clone + ToString, 140 | { 141 | let local_branches = branches(browser, RefScope::Local)?; 142 | let tags = tags(browser)?; 143 | Ok( 144 | NonEmpty::from_vec(local_branches).map(|branches| Revisions { 145 | peer_id, 146 | user, 147 | branches, 148 | tags, 149 | }), 150 | ) 151 | } 152 | 153 | /// Provide the [`Revisions`] of a peer. 154 | /// 155 | /// If the peer is [`Category::Local`], meaning that is the current person doing 156 | /// the browsing and no remote is set for the reference. 157 | /// 158 | /// Othewise, the peer is [`Category::Remote`], meaning that we are looking into 159 | /// a remote part of a reference. 160 | /// 161 | /// # Errors 162 | /// 163 | /// * If we cannot get the branches from the `Browser` 164 | pub fn revisions( 165 | browser: &Browser, 166 | peer: Category, 167 | ) -> Result>, Error> 168 | where 169 | P: Clone + ToString, 170 | { 171 | match peer { 172 | Category::Local { peer_id, user } => local(browser, peer_id, user), 173 | Category::Remote { peer_id, user } => remote(browser, peer_id, user), 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /source/src/syntax.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::path; 19 | 20 | use syntect::{ 21 | easy::HighlightLines, 22 | highlighting::ThemeSet, 23 | parsing::SyntaxSet, 24 | util::LinesWithEndings, 25 | }; 26 | 27 | lazy_static::lazy_static! { 28 | // The syntax set is slow to load (~30ms), so we make sure to only load it once. 29 | // It _will_ affect the latency of the first request that uses syntax highlighting, 30 | // but this is acceptable for now. 31 | pub static ref SYNTAX_SET: SyntaxSet = { 32 | let default_set = SyntaxSet::load_defaults_newlines(); 33 | let mut builder = default_set.into_builder(); 34 | 35 | if cfg!(debug_assertions) { 36 | // In development assets are relative to the proxy source. 37 | // Don't crash if we aren't able to load additional syntaxes for some reason. 38 | builder.add_from_folder("./assets", true).ok(); 39 | } else { 40 | // In production assets are relative to the proxy executable. 41 | let exe_path = std::env::current_exe().expect("Can't get current exe path"); 42 | let root_path = exe_path 43 | .parent() 44 | .expect("Could not get parent path of current executable"); 45 | let mut tmp = root_path.to_path_buf(); 46 | tmp.push("assets"); 47 | let asset_path = tmp.to_str().expect("Couldn't convert pathbuf to str"); 48 | 49 | // Don't crash if we aren't able to load additional syntaxes for some reason. 50 | match builder.add_from_folder(asset_path, true) { 51 | Ok(_) => (), 52 | Err(err) => log::warn!("Syntax builder error : {}", err), 53 | }; 54 | } 55 | builder.build() 56 | }; 57 | } 58 | 59 | /// Return a [`BlobContent`] given a file path, content and theme. Attempts to 60 | /// perform syntax highlighting when the theme is `Some`. 61 | pub fn highlight(path: &str, content: &str, theme_name: &str) -> Option { 62 | let syntax = path::Path::new(path) 63 | .extension() 64 | .and_then(std::ffi::OsStr::to_str) 65 | .and_then(|ext| SYNTAX_SET.find_syntax_by_extension(ext)); 66 | 67 | let ts = ThemeSet::load_defaults(); 68 | let theme = ts.themes.get(theme_name); 69 | 70 | match (syntax, theme) { 71 | (Some(syntax), Some(theme)) => { 72 | let mut highlighter = HighlightLines::new(syntax, theme); 73 | let mut html = String::with_capacity(content.len()); 74 | 75 | for line in LinesWithEndings::from(content) { 76 | let regions = highlighter.highlight(line, &SYNTAX_SET); 77 | syntect::html::append_highlighted_html_for_styled_line( 78 | ®ions[..], 79 | syntect::html::IncludeBackground::No, 80 | &mut html, 81 | ); 82 | } 83 | Some(html) 84 | }, 85 | _ => None, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /source/src/tag.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::fmt; 19 | 20 | use serde::Serialize; 21 | 22 | use radicle_surf::{git::RefScope, vcs::git::Browser}; 23 | 24 | use crate::error::Error; 25 | 26 | /// Tag name representation. 27 | /// 28 | /// We still need full tag support. 29 | #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] 30 | pub struct Tag(pub(crate) String); 31 | 32 | impl From for Tag { 33 | fn from(name: String) -> Self { 34 | Self(name) 35 | } 36 | } 37 | 38 | impl fmt::Display for Tag { 39 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 40 | write!(f, "{}", self.0) 41 | } 42 | } 43 | 44 | /// Retrieves the list of [`Tag`] for the given project `id`. 45 | /// 46 | /// # Errors 47 | /// 48 | /// Will return [`Error`] if the project doesn't exist or the surf interaction 49 | /// fails. 50 | pub fn tags(browser: &Browser<'_>) -> Result, Error> { 51 | let tag_names = browser.list_tags(RefScope::Local)?; 52 | let mut tags: Vec = tag_names 53 | .into_iter() 54 | .map(|tag_name| Tag(tag_name.name().to_string())) 55 | .collect(); 56 | 57 | tags.sort(); 58 | 59 | Ok(tags) 60 | } 61 | -------------------------------------------------------------------------------- /surf/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "radicle-surf" 3 | description = "A code surfing library for VCS file systems" 4 | readme = "README.md" 5 | version = "0.8.0" 6 | authors = ["The Radicle Team "] 7 | edition = "2018" 8 | homepage = "https://github.com/radicle-dev/radicle-surf" 9 | repository = "https://github.com/radicle-dev/radicle-surf" 10 | license = "GPL-3.0-or-later" 11 | 12 | include = [ 13 | "**/*.rs", 14 | "Cargo.toml", 15 | "data/git-platinum.tgz", 16 | ] 17 | 18 | [features] 19 | serialize = ["serde"] 20 | # NOTE: testing `test_submodule_failure` on GH actions 21 | # is painful since it uses this specific repo and expects 22 | # certain branches to be setup. So we use this feature flag 23 | # to ignore the test on CI. 24 | gh-actions = [] 25 | 26 | [dependencies] 27 | either = "1.5" 28 | nom = "6" 29 | nonempty = "0.5" 30 | regex = ">= 1.5.5" 31 | serde = { features = ["serde_derive"], optional = true, version = "1" } 32 | thiserror = "1.0" 33 | 34 | [dependencies.git2] 35 | version = ">= 0.12" 36 | default-features = false 37 | features = [] 38 | 39 | [dev-dependencies] 40 | pretty_assertions = "0.6" 41 | proptest = "0.9" 42 | criterion = "0.3" 43 | serde_json = "1" 44 | 45 | [build-dependencies] 46 | anyhow = "1.0" 47 | flate2 = "1" 48 | tar = "0.4" 49 | 50 | [[bench]] 51 | name = "last_commit" 52 | harness = false 53 | -------------------------------------------------------------------------------- /surf/README.md: -------------------------------------------------------------------------------- 1 | # radicle-surf 2 | 3 | A code surfing library for VCS file systems 🏄‍♀️🏄‍♂️ 4 | 5 | Welcome to `radicle-surf`! 6 | 7 | `radicle-surf` is a system to describe a file-system in a VCS world. 8 | We have the concept of files and directories, but these objects can change over time while people iterate on them. 9 | Thus, it is a file-system within history and we, the user, are viewing the file-system at a particular snapshot. 10 | Alongside this, we will wish to take two snapshots and view their differences. 11 | 12 | ## Contributing 13 | 14 | To get started on contributing you can check out our [developing guide](../DEVELOPMENT.md), and also 15 | our [LICENSE](../LICENSE) file. 16 | 17 | ## The Community 18 | 19 | Join our community disccussions at [radicle.community](https://radicle.community)! 20 | 21 | # Example 22 | 23 | To a taste for the capabilities of `radicle-surf` we provide an example below, but we also 24 | keep our documentation and doc-tests up to date. 25 | 26 | ```rust 27 | use radicle_surf::vcs::git; 28 | use radicle_surf::file_system::{Label, Path, SystemType}; 29 | use radicle_surf::file_system::unsound; 30 | use pretty_assertions::assert_eq; 31 | use std::str::FromStr; 32 | 33 | // We're going to point to this repo. 34 | let repo = git::Repository::new("./data/git-platinum")?; 35 | 36 | // Here we initialise a new Broswer for a the git repo. 37 | let mut browser = git::Browser::new(&repo, "master")?; 38 | 39 | // Set the history to a particular commit 40 | let commit = git::Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?; 41 | browser.commit(commit)?; 42 | 43 | // Get the snapshot of the directory for our current HEAD of history. 44 | let directory = browser.get_directory()?; 45 | 46 | // Let's get a Path to the memory.rs file 47 | let memory = unsound::path::new("src/memory.rs"); 48 | 49 | // And assert that we can find it! 50 | assert!(directory.find_file(memory).is_some()); 51 | 52 | let root_contents = directory.list_directory(); 53 | 54 | assert_eq!(root_contents, vec![ 55 | SystemType::file(unsound::label::new(".i-am-well-hidden")), 56 | SystemType::file(unsound::label::new(".i-too-am-hidden")), 57 | SystemType::file(unsound::label::new("README.md")), 58 | SystemType::directory(unsound::label::new("bin")), 59 | SystemType::directory(unsound::label::new("src")), 60 | SystemType::directory(unsound::label::new("text")), 61 | SystemType::directory(unsound::label::new("this")), 62 | ]); 63 | 64 | let src = directory 65 | .find_directory(Path::new(unsound::label::new("src"))) 66 | .expect("failed to find src"); 67 | let src_contents = src.list_directory(); 68 | 69 | assert_eq!(src_contents, vec![ 70 | SystemType::file(unsound::label::new("Eval.hs")), 71 | SystemType::file(unsound::label::new("Folder.svelte")), 72 | SystemType::file(unsound::label::new("memory.rs")), 73 | ]); 74 | ``` 75 | -------------------------------------------------------------------------------- /surf/benches/last_commit.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; 19 | use radicle_surf::{ 20 | file_system::{unsound, Path}, 21 | vcs::git::{Branch, Browser, Repository}, 22 | }; 23 | 24 | fn last_commit_comparison(c: &mut Criterion) { 25 | let repo = Repository::new("./data/git-platinum") 26 | .expect("Could not retrieve ./data/git-platinum as git repository"); 27 | let browser = 28 | Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser"); 29 | 30 | let mut group = c.benchmark_group("Last Commit"); 31 | for path in [ 32 | Path::root(), 33 | unsound::path::new("~/src/memory.rs"), 34 | unsound::path::new("~/this/is/a/really/deeply/nested/directory/tree"), 35 | ] 36 | .iter() 37 | { 38 | group.bench_with_input(BenchmarkId::new("", path), path, |b, path| { 39 | b.iter(|| browser.last_commit(path.clone())) 40 | }); 41 | } 42 | } 43 | 44 | criterion_group!(benches, last_commit_comparison); 45 | criterion_main!(benches); 46 | -------------------------------------------------------------------------------- /surf/build.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::{ 19 | env, 20 | fs, 21 | fs::File, 22 | io, 23 | path::{Path, PathBuf}, 24 | }; 25 | 26 | use anyhow::Context as _; 27 | use flate2::read::GzDecoder; 28 | use tar::Archive; 29 | 30 | enum Command { 31 | Build(PathBuf), 32 | Publish(PathBuf), 33 | } 34 | 35 | impl Command { 36 | fn new() -> io::Result { 37 | let current = env::current_dir()?; 38 | Ok(if current.ends_with("surf") { 39 | Self::Build(current) 40 | } else { 41 | Self::Publish(PathBuf::from( 42 | env::var("OUT_DIR").map_err(|err| io::Error::new(io::ErrorKind::Other, err))?, 43 | )) 44 | }) 45 | } 46 | 47 | fn target(&self) -> PathBuf { 48 | match self { 49 | Self::Build(path) => path.join("data"), 50 | Self::Publish(path) => path.join("data"), 51 | } 52 | } 53 | } 54 | 55 | fn main() { 56 | let target = Command::new() 57 | .expect("could not determine the cargo command") 58 | .target(); 59 | let git_platinum_tarball = "./data/git-platinum.tgz"; 60 | 61 | unpack(git_platinum_tarball, target).expect("Failed to unpack git-platinum"); 62 | 63 | println!("cargo:rerun-if-changed={}", git_platinum_tarball); 64 | } 65 | 66 | fn unpack(archive_path: impl AsRef, target: impl AsRef) -> anyhow::Result<()> { 67 | let content = target.as_ref().join("git-platinum"); 68 | if content.exists() { 69 | fs::remove_dir_all(content).context("attempting to remove git-platinum")?; 70 | } 71 | let archive_path = archive_path.as_ref(); 72 | let tar_gz = File::open(archive_path).context(format!( 73 | "attempting to open file: {}", 74 | archive_path.display() 75 | ))?; 76 | let tar = GzDecoder::new(tar_gz); 77 | let mut archive = Archive::new(tar); 78 | archive.unpack(target).context("attempting to unpack")?; 79 | 80 | Ok(()) 81 | } 82 | -------------------------------------------------------------------------------- /surf/data/README.md: -------------------------------------------------------------------------------- 1 | # Updating [git-platinum][] 2 | 3 | 1. Push your changes to [`radicle-dev/git-platinum`][git-platinum] and/or update 4 | `surf/data/mock-branches.txt`. 5 | 2. Run `scripts/update-git-platinum.sh` from the repo root. This updates 6 | `surf/data/git-platinum.tgz`. 7 | 3. Run `cargo build` to unpack the updated repo. 8 | 4. Run the tests 9 | 5. Commit your changes. We provide a template below so that we can easily 10 | identify changes to `git-platinum`. Please fill in the details that follow a 11 | comment (`#`): 12 | ``` 13 | data/git-platinum: # short reason for updating 14 | 15 | # provide a longer reason for making changes to git-platinum 16 | # as well as what has changed. 17 | ``` 18 | 19 | 20 | 21 | [git-platinum]: https://github.com/radicle-dev/git-platinum 22 | -------------------------------------------------------------------------------- /surf/data/git-platinum.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radicle-dev/radicle-surf/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/data/git-platinum.tgz -------------------------------------------------------------------------------- /surf/data/mock-branches.txt: -------------------------------------------------------------------------------- 1 | refs/namespaces/golden/refs/heads/master,refs/heads/master 2 | refs/namespaces/golden/refs/heads/banana,refs/heads/dev 3 | refs/namespaces/golden/refs/tags/v0.1.0,refs/tags/v0.1.0 4 | refs/namespaces/golden/refs/tags/v0.2.0,refs/tags/v0.2.0 5 | refs/namespaces/golden/refs/remotes/kickflip/heads/heelflip,refs/heads/dev 6 | refs/namespaces/golden/refs/remotes/kickflip/heads/fakie/bigspin,refs/heads/dev 7 | refs/namespaces/golden/refs/remotes/kickflip/tags/v0.1.0,refs/tags/v0.1.0 8 | refs/namespaces/golden/refs/namespaces/silver/refs/heads/master,refs/heads/dev 9 | refs/remotes/banana/pineapple,refs/remotes/origin/master 10 | refs/remotes/banana/orange/pineapple,refs/remotes/origin/master 11 | refs/namespaces/me/refs/heads/feature/#1194,refs/heads/master 12 | refs/namespaces/me/refs/remotes/fein/heads/feature/#1194,refs/heads/dev 13 | -------------------------------------------------------------------------------- /surf/examples/diff.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | extern crate radicle_surf; 19 | 20 | use std::{env::Args, time::Instant}; 21 | 22 | use git2::Oid; 23 | use nonempty::NonEmpty; 24 | 25 | use radicle_surf::{ 26 | diff::Diff, 27 | file_system::Directory, 28 | vcs::{git, History}, 29 | }; 30 | 31 | fn main() { 32 | let options = get_options_or_exit(); 33 | let repo = init_repository_or_exit(&options.path_to_repo); 34 | let mut browser = 35 | git::Browser::new(&repo, git::Branch::local("master")).expect("failed to create browser:"); 36 | 37 | match options.head_revision { 38 | HeadRevision::Head => { 39 | reset_browser_to_head_or_exit(&mut browser); 40 | }, 41 | HeadRevision::Commit(id) => { 42 | set_browser_history_or_exit(&mut browser, &id); 43 | }, 44 | } 45 | let head_directory = get_directory_or_exit(&browser); 46 | 47 | set_browser_history_or_exit(&mut browser, &options.base_revision); 48 | let base_directory = get_directory_or_exit(&browser); 49 | 50 | let now = Instant::now(); 51 | let elapsed_nanos = now.elapsed().as_nanos(); 52 | let diff = Diff::diff(base_directory, head_directory); 53 | print_diff_summary(&diff, elapsed_nanos); 54 | } 55 | 56 | fn get_options_or_exit() -> Options { 57 | match Options::parse(std::env::args()) { 58 | Ok(options) => options, 59 | Err(message) => { 60 | println!("{}", message); 61 | std::process::exit(1); 62 | }, 63 | } 64 | } 65 | 66 | fn init_repository_or_exit(path_to_repo: &str) -> git::Repository { 67 | match git::Repository::new(path_to_repo) { 68 | Ok(repo) => repo, 69 | Err(e) => { 70 | println!("Failed to create repository: {:?}", e); 71 | std::process::exit(1); 72 | }, 73 | } 74 | } 75 | 76 | fn reset_browser_to_head_or_exit(browser: &mut git::Browser) { 77 | if let Err(e) = browser.head() { 78 | println!("Failed to set browser to HEAD: {:?}", e); 79 | std::process::exit(1); 80 | } 81 | } 82 | 83 | fn set_browser_history_or_exit(browser: &mut git::Browser, commit_id: &str) { 84 | // TODO: Might consider to not require resetting to HEAD when history is not at 85 | // HEAD 86 | reset_browser_to_head_or_exit(browser); 87 | if let Err(e) = set_browser_history(browser, commit_id) { 88 | println!("Failed to set browser history: {:?}", e); 89 | std::process::exit(1); 90 | } 91 | } 92 | 93 | fn set_browser_history(browser: &mut git::Browser, commit_id: &str) -> Result<(), String> { 94 | let oid = match Oid::from_str(commit_id) { 95 | Ok(oid) => oid, 96 | Err(e) => return Err(format!("{}", e)), 97 | }; 98 | let commit = match browser.get().find_in_history(&oid, |artifact| artifact.id) { 99 | Some(commit) => commit, 100 | None => return Err(format!("Git commit not found: {}", commit_id)), 101 | }; 102 | browser.set(History(NonEmpty::new(commit))); 103 | Ok(()) 104 | } 105 | 106 | fn get_directory_or_exit(browser: &git::Browser) -> Directory { 107 | match browser.get_directory() { 108 | Ok(dir) => dir, 109 | Err(e) => { 110 | println!("Failed to get directory: {:?}", e); 111 | std::process::exit(1) 112 | }, 113 | } 114 | } 115 | 116 | fn print_diff_summary(diff: &Diff, elapsed_nanos: u128) { 117 | diff.created.iter().for_each(|created| { 118 | println!("+++ {}", created.path); 119 | }); 120 | diff.deleted.iter().for_each(|deleted| { 121 | println!("--- {}", deleted.path); 122 | }); 123 | diff.modified.iter().for_each(|modified| { 124 | println!("mod {}", modified.path); 125 | }); 126 | 127 | println!( 128 | "created {} / deleted {} / modified {} / total {}", 129 | diff.created.len(), 130 | diff.deleted.len(), 131 | diff.modified.len(), 132 | diff.created.len() + diff.deleted.len() + diff.modified.len() 133 | ); 134 | println!("diff took {} micros ", elapsed_nanos / 1000); 135 | } 136 | 137 | struct Options { 138 | path_to_repo: String, 139 | base_revision: String, 140 | head_revision: HeadRevision, 141 | } 142 | 143 | enum HeadRevision { 144 | Head, 145 | Commit(String), 146 | } 147 | 148 | impl Options { 149 | fn parse(args: Args) -> Result { 150 | let args: Vec = args.collect(); 151 | if args.len() != 4 { 152 | return Err(format!( 153 | "Usage: {} \n\ 154 | \tpath-to-repo: Path to the directory containing .git subdirectory\n\ 155 | \tbase-revision: Git commit ID of the base revision (one that will be considered less recent)\n\ 156 | \thead-revision: Git commit ID of the head revision (one that will be considered more recent) or 'HEAD' to use current git HEAD\n", 157 | args[0])); 158 | } 159 | 160 | let path_to_repo = args[1].clone(); 161 | let base_revision = args[2].clone(); 162 | let head_revision = { 163 | if args[3].eq_ignore_ascii_case("HEAD") { 164 | HeadRevision::Head 165 | } else { 166 | HeadRevision::Commit(args[3].clone()) 167 | } 168 | }; 169 | 170 | Ok(Options { 171 | path_to_repo, 172 | base_revision, 173 | head_revision, 174 | }) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /surf/src/diff/git.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::convert::TryFrom; 19 | 20 | use crate::{ 21 | diff::{self, Diff, EofNewLine, Hunk, Hunks, Line, LineDiff}, 22 | file_system::Path, 23 | }; 24 | 25 | pub mod error { 26 | use thiserror::Error; 27 | 28 | use crate::file_system::{self, Path}; 29 | 30 | #[derive(Debug, Error, PartialEq)] 31 | #[non_exhaustive] 32 | pub enum LineDiff { 33 | /// A Git `DiffLine` is invalid. 34 | #[error( 35 | "invalid `git2::DiffLine` which contains no line numbers for either side of the diff" 36 | )] 37 | Invalid, 38 | } 39 | 40 | #[derive(Debug, Error, PartialEq)] 41 | #[non_exhaustive] 42 | pub enum Hunk { 43 | #[error(transparent)] 44 | Git(#[from] git2::Error), 45 | #[error(transparent)] 46 | Line(#[from] LineDiff), 47 | } 48 | 49 | /// A Git diff error. 50 | #[derive(Debug, PartialEq, Error)] 51 | #[non_exhaustive] 52 | pub enum Diff { 53 | /// A Git delta type isn't currently handled. 54 | #[error("git delta type is not handled")] 55 | DeltaUnhandled(git2::Delta), 56 | #[error(transparent)] 57 | FileSystem(#[from] file_system::Error), 58 | #[error(transparent)] 59 | Git(#[from] git2::Error), 60 | #[error(transparent)] 61 | Hunk(#[from] Hunk), 62 | #[error(transparent)] 63 | Line(#[from] LineDiff), 64 | /// A patch is unavailable. 65 | #[error("couldn't retrieve patch for {0}")] 66 | PatchUnavailable(Path), 67 | /// A The path of a file isn't available. 68 | #[error("couldn't retrieve file path")] 69 | PathUnavailable, 70 | } 71 | } 72 | 73 | impl<'a> TryFrom> for LineDiff { 74 | type Error = error::LineDiff; 75 | 76 | fn try_from(line: git2::DiffLine) -> Result { 77 | match (line.old_lineno(), line.new_lineno()) { 78 | (None, Some(n)) => Ok(Self::addition(line.content().to_owned(), n)), 79 | (Some(n), None) => Ok(Self::deletion(line.content().to_owned(), n)), 80 | (Some(l), Some(r)) => Ok(Self::context(line.content().to_owned(), l, r)), 81 | (None, None) => Err(error::LineDiff::Invalid), 82 | } 83 | } 84 | } 85 | 86 | impl<'a> TryFrom> for Diff { 87 | type Error = error::Diff; 88 | 89 | fn try_from(git_diff: git2::Diff) -> Result { 90 | use git2::{Delta, Patch}; 91 | 92 | let mut diff = Diff::new(); 93 | 94 | for (idx, delta) in git_diff.deltas().enumerate() { 95 | match delta.status() { 96 | Delta::Added => { 97 | let diff_file = delta.new_file(); 98 | let path = diff_file.path().ok_or(error::Diff::PathUnavailable)?; 99 | let path = Path::try_from(path.to_path_buf())?; 100 | 101 | let patch = Patch::from_diff(&git_diff, idx)?; 102 | if let Some(patch) = patch { 103 | diff.add_created_file( 104 | path, 105 | diff::FileDiff::Plain { 106 | hunks: Hunks::try_from(patch)?, 107 | }, 108 | ); 109 | } else { 110 | diff.add_created_file( 111 | path, 112 | diff::FileDiff::Plain { 113 | hunks: Hunks::default(), 114 | }, 115 | ); 116 | } 117 | }, 118 | Delta::Deleted => { 119 | let diff_file = delta.old_file(); 120 | let path = diff_file.path().ok_or(error::Diff::PathUnavailable)?; 121 | let path = Path::try_from(path.to_path_buf())?; 122 | 123 | let patch = Patch::from_diff(&git_diff, idx)?; 124 | if let Some(patch) = patch { 125 | diff.add_deleted_file( 126 | path, 127 | diff::FileDiff::Plain { 128 | hunks: Hunks::try_from(patch)?, 129 | }, 130 | ); 131 | } else { 132 | diff.add_deleted_file( 133 | path, 134 | diff::FileDiff::Plain { 135 | hunks: Hunks::default(), 136 | }, 137 | ); 138 | } 139 | }, 140 | Delta::Modified => { 141 | let diff_file = delta.new_file(); 142 | let path = diff_file.path().ok_or(error::Diff::PathUnavailable)?; 143 | let path = Path::try_from(path.to_path_buf())?; 144 | 145 | let patch = Patch::from_diff(&git_diff, idx)?; 146 | 147 | if let Some(patch) = patch { 148 | let mut hunks: Vec = Vec::new(); 149 | let mut old_missing_eof = false; 150 | let mut new_missing_eof = false; 151 | 152 | for h in 0..patch.num_hunks() { 153 | let (hunk, hunk_lines) = patch.hunk(h)?; 154 | let header = Line(hunk.header().to_owned()); 155 | let mut lines: Vec = Vec::new(); 156 | 157 | for l in 0..hunk_lines { 158 | let line = patch.line_in_hunk(h, l)?; 159 | match line.origin_value() { 160 | git2::DiffLineType::ContextEOFNL => { 161 | new_missing_eof = true; 162 | old_missing_eof = true; 163 | continue; 164 | }, 165 | git2::DiffLineType::AddEOFNL => { 166 | old_missing_eof = true; 167 | continue; 168 | }, 169 | git2::DiffLineType::DeleteEOFNL => { 170 | new_missing_eof = true; 171 | continue; 172 | }, 173 | _ => {}, 174 | } 175 | let line = LineDiff::try_from(line)?; 176 | lines.push(line); 177 | } 178 | hunks.push(Hunk { header, lines }); 179 | } 180 | let eof = match (old_missing_eof, new_missing_eof) { 181 | (true, true) => Some(EofNewLine::BothMissing), 182 | (true, false) => Some(EofNewLine::OldMissing), 183 | (false, true) => Some(EofNewLine::NewMissing), 184 | (false, false) => None, 185 | }; 186 | diff.add_modified_file(path, hunks, eof); 187 | } else if diff_file.is_binary() { 188 | diff.add_modified_binary_file(path); 189 | } else { 190 | return Err(error::Diff::PatchUnavailable(path)); 191 | } 192 | }, 193 | Delta::Renamed => { 194 | let old = delta 195 | .old_file() 196 | .path() 197 | .ok_or(error::Diff::PathUnavailable)?; 198 | let new = delta 199 | .new_file() 200 | .path() 201 | .ok_or(error::Diff::PathUnavailable)?; 202 | 203 | let old_path = Path::try_from(old.to_path_buf())?; 204 | let new_path = Path::try_from(new.to_path_buf())?; 205 | 206 | diff.add_moved_file(old_path, new_path); 207 | }, 208 | Delta::Copied => { 209 | let old = delta 210 | .old_file() 211 | .path() 212 | .ok_or(error::Diff::PathUnavailable)?; 213 | let new = delta 214 | .new_file() 215 | .path() 216 | .ok_or(error::Diff::PathUnavailable)?; 217 | 218 | let old_path = Path::try_from(old.to_path_buf())?; 219 | let new_path = Path::try_from(new.to_path_buf())?; 220 | 221 | diff.add_copied_file(old_path, new_path); 222 | }, 223 | status => { 224 | return Err(error::Diff::DeltaUnhandled(status)); 225 | }, 226 | } 227 | } 228 | 229 | Ok(diff) 230 | } 231 | } 232 | 233 | #[cfg(test)] 234 | mod tests { 235 | use super::*; 236 | 237 | #[test] 238 | fn test_both_missing_eof_newline() { 239 | let buf = r#" 240 | diff --git a/.env b/.env 241 | index f89e4c0..7c56eb7 100644 242 | --- a/.env 243 | +++ b/.env 244 | @@ -1 +1 @@ 245 | -hello=123 246 | \ No newline at end of file 247 | +hello=1234 248 | \ No newline at end of file 249 | "#; 250 | let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap(); 251 | let diff = Diff::try_from(diff).unwrap(); 252 | assert_eq!(diff.modified[0].eof, Some(EofNewLine::BothMissing)); 253 | } 254 | 255 | #[test] 256 | fn test_none_missing_eof_newline() { 257 | let buf = r#" 258 | diff --git a/.env b/.env 259 | index f89e4c0..7c56eb7 100644 260 | --- a/.env 261 | +++ b/.env 262 | @@ -1 +1 @@ 263 | -hello=123 264 | +hello=1234 265 | "#; 266 | let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap(); 267 | let diff = Diff::try_from(diff).unwrap(); 268 | assert_eq!(diff.modified[0].eof, None); 269 | } 270 | 271 | // TODO(xphoniex): uncomment once libgit2 has fixed the bug 272 | //#[test] 273 | fn test_old_missing_eof_newline() { 274 | let buf = r#" 275 | diff --git a/.env b/.env 276 | index f89e4c0..7c56eb7 100644 277 | --- a/.env 278 | +++ b/.env 279 | @@ -1 +1 @@ 280 | -hello=123 281 | \ No newline at end of file 282 | +hello=1234 283 | "#; 284 | let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap(); 285 | let diff = Diff::try_from(diff).unwrap(); 286 | assert_eq!(diff.modified[0].eof, Some(EofNewLine::OldMissing)); 287 | } 288 | 289 | // TODO(xphoniex): uncomment once libgit2 has fixed the bug 290 | //#[test] 291 | fn test_new_missing_eof_newline() { 292 | let buf = r#" 293 | diff --git a/.env b/.env 294 | index f89e4c0..7c56eb7 100644 295 | --- a/.env 296 | +++ b/.env 297 | @@ -1 +1 @@ 298 | -hello=123 299 | +hello=1234 300 | \ No newline at end of file 301 | "#; 302 | let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap(); 303 | let diff = Diff::try_from(diff).unwrap(); 304 | assert_eq!(diff.modified[0].eof, Some(EofNewLine::NewMissing)); 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /surf/src/file_system.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | //! A model of a non-empty directory data structure that can be searched, 19 | //! queried, and rendered. The concept is to represent VCS directory, but is not 20 | //! necessarily tied to one. 21 | //! 22 | //! # Examples 23 | //! 24 | //! ``` 25 | //! use nonempty::NonEmpty; 26 | //! use radicle_surf::file_system as fs; 27 | //! 28 | //! // This used for unsafe set up of the directory, but should not be used in production code. 29 | //! use radicle_surf::file_system::unsound; 30 | //! 31 | //! let mut directory = fs::Directory::root(); 32 | //! 33 | //! // Set up root files 34 | //! let readme = fs::File::new(b"Radicle Surfing"); 35 | //! let cargo = fs::File::new(b"[package]\nname = \"radicle-surf\""); 36 | //! let root_files = NonEmpty::from(( 37 | //! (unsound::label::new("README.md"), readme), 38 | //! vec![(unsound::label::new("Cargo.toml"), cargo)], 39 | //! )); 40 | //! 41 | //! // Set up src files 42 | //! let lib = fs::File::new(b"pub mod diff;\npub mod file_system;\n pub mod vcs;"); 43 | //! let file_system_mod = fs::File::new(b"pub mod directory;\npub mod error;\nmod path;"); 44 | //! 45 | //! directory.insert_files(&[], root_files); 46 | //! directory.insert_file(unsound::path::new("src/lib.rs"), lib.clone()); 47 | //! directory.insert_file(unsound::path::new("src/file_system/mod.rs"), file_system_mod); 48 | //! 49 | //! // With a directory in place we can begin to operate on it 50 | //! // The first we will do is list what contents are at the root. 51 | //! let root_contents = directory.list_directory(); 52 | //! 53 | //! // Checking that we have the correct contents 54 | //! assert_eq!( 55 | //! root_contents, 56 | //! vec![ 57 | //! fs::SystemType::file(unsound::label::new("Cargo.toml")), 58 | //! fs::SystemType::file(unsound::label::new("README.md")), 59 | //! fs::SystemType::directory(unsound::label::new("src")), 60 | //! ] 61 | //! ); 62 | //! 63 | //! // We can then go down one level to explore sub-directories 64 | //! // Note here that we can use `Path::new`, since there's guranteed to be a `Label`, 65 | //! // although we cheated and created the label unsafely. 66 | //! let src = directory.find_directory(fs::Path::new(unsound::label::new("src"))); 67 | //! 68 | //! // Ensure that we found the src directory 69 | //! assert!(src.is_some()); 70 | //! let src = src.unwrap(); 71 | //! 72 | //! let src_contents = src.list_directory(); 73 | //! 74 | //! // Checking we have the correct contents of 'src' 75 | //! assert_eq!( 76 | //! src_contents, 77 | //! vec![ 78 | //! fs::SystemType::directory(unsound::label::new("file_system")), 79 | //! fs::SystemType::file(unsound::label::new("lib.rs")), 80 | //! ] 81 | //! ); 82 | //! 83 | //! // We can dive down to 'file_system' either from the root or src, they should be the same. 84 | //! assert_eq!( 85 | //! src.find_directory(unsound::path::new("file_system")), 86 | //! directory.find_directory(unsound::path::new("src/file_system")), 87 | //! ); 88 | //! 89 | //! // We can also find files 90 | //! assert_eq!( 91 | //! src.find_file(unsound::path::new("lib.rs")), 92 | //! Some(lib) 93 | //! ); 94 | //! 95 | //! // From anywhere 96 | //! assert_eq!( 97 | //! directory.find_file(unsound::path::new("src/file_system/mod.rs")), 98 | //! src.find_file(unsound::path::new("file_system/mod.rs")), 99 | //! ); 100 | //! 101 | //! // And we can also check the size of directories and files 102 | //! assert_eq!( 103 | //! directory.find_file(unsound::path::new("src/file_system/mod.rs")).map(|f| f.size()), 104 | //! Some(43), 105 | //! ); 106 | //! 107 | //! assert_eq!( 108 | //! directory.size(), 109 | //! 137, 110 | //! ); 111 | //! ``` 112 | 113 | pub mod directory; 114 | mod error; 115 | pub use error::Error; 116 | mod path; 117 | 118 | pub use self::{directory::*, path::*}; 119 | -------------------------------------------------------------------------------- /surf/src/file_system/error.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | //! Errors that can occur within the file system logic. 19 | //! 20 | //! These errors occur due to [`Label`](super::path::Label) and 21 | //! [`Path`](super::path::Path) parsing when using their respective `TryFrom` 22 | //! instances. 23 | 24 | use std::ffi::OsStr; 25 | use thiserror::Error; 26 | 27 | pub(crate) const EMPTY_PATH: Error = Error::Path(PathError::Empty); 28 | pub(crate) const EMPTY_LABEL: Error = Error::Label(LabelError::Empty); 29 | 30 | /// Build an [`Error::Label(LabelError::InvalidUTF8)`] from an 31 | /// [`OsStr`](std::ffi::OsStr) 32 | pub(crate) fn label_invalid_utf8(item: &OsStr) -> Error { 33 | Error::Label(LabelError::InvalidUTF8 { 34 | label: item.to_string_lossy().into(), 35 | }) 36 | } 37 | 38 | /// Build an [`Error::Label(LabelError::ContainsSlash)`] from a [`str`] 39 | pub(crate) fn label_has_slash(item: &str) -> Error { 40 | Error::Label(LabelError::ContainsSlash { label: item.into() }) 41 | } 42 | 43 | /// Error type for all file system errors that can occur. 44 | #[derive(Debug, Clone, PartialEq, Eq, Error)] 45 | #[non_exhaustive] 46 | pub enum Error { 47 | /// A `LabelError` specific error for parsing a 48 | /// [`Path`](super::path::Label). 49 | #[error(transparent)] 50 | Label(#[from] LabelError), 51 | /// A `PathError` specific error for parsing a [`Path`](super::path::Path). 52 | #[error(transparent)] 53 | Path(#[from] PathError), 54 | } 55 | 56 | /// Parse errors for when parsing a string to a [`Path`](super::path::Path). 57 | #[derive(Debug, Clone, PartialEq, Eq, Error)] 58 | #[non_exhaustive] 59 | pub enum PathError { 60 | /// An error signifying that a [`Path`](super::path::Path) is empty. 61 | #[error("path is empty")] 62 | Empty, 63 | } 64 | 65 | /// Parse errors for when parsing a string to a [`Label`](super::path::Label). 66 | #[derive(Debug, Clone, PartialEq, Eq, Error)] 67 | #[non_exhaustive] 68 | pub enum LabelError { 69 | /// An error signifying that a [`Label`](super::path::Label) is contains 70 | /// invalid UTF-8. 71 | #[error("label '{label}' contains invalid UTF-8")] 72 | InvalidUTF8 { label: String }, 73 | /// An error signifying that a [`Label`](super::path::Label) contains a `/`. 74 | #[error("label '{label}' contains a slash")] 75 | ContainsSlash { label: String }, 76 | /// An error signifying that a [`Label`](super::path::Label) is empty. 77 | #[error("label is empty")] 78 | Empty, 79 | } 80 | -------------------------------------------------------------------------------- /surf/src/file_system/path.rs: -------------------------------------------------------------------------------- 1 | // This file is part of radicle-surf 2 | // 3 | // 4 | // Copyright (C) 2019-2020 The Radicle Team 5 | // 6 | // This program is free software: you can redistribute it and/or modify 7 | // it under the terms of the GNU General Public License version 3 or 8 | // later as published by the Free Software Foundation. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | 18 | use std::fmt::Write as _; 19 | 20 | use crate::{file_system::error, nonempty::split_last}; 21 | use nonempty::NonEmpty; 22 | use std::{convert::TryFrom, ffi::CString, fmt, ops::Deref, path, str::FromStr}; 23 | 24 | #[cfg(feature = "serialize")] 25 | use serde::{Serialize, Serializer}; 26 | 27 | pub mod unsound; 28 | 29 | /// `Label` is a special case of a `String` identifier for 30 | /// [`Directory`](`crate::file_system::directory::Directory`) and 31 | /// [`File`](`crate::file_system::directory::File`) names, and is used in 32 | /// [`Path`] as the component parts of a path. 33 | /// 34 | /// A `Label` should not be empty or contain `/`s. It is encouraged to use the 35 | /// `TryFrom` instance to create a `Label`. 36 | #[cfg_attr(feature = "serialize", derive(Serialize))] 37 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 38 | pub struct Label { 39 | pub(crate) label: String, 40 | pub(crate) hidden: bool, 41 | } 42 | 43 | impl Deref for Label { 44 | type Target = String; 45 | 46 | fn deref(&self) -> &Self::Target { 47 | &self.label 48 | } 49 | } 50 | 51 | impl Label { 52 | /// The root label for the root directory, i.e. `"~"`. 53 | /// 54 | /// Prefer creating a root [`Path`], by using 55 | /// [`Path::root`](struct.Path.html#method.root). 56 | /// 57 | /// # Examples 58 | /// 59 | /// ``` 60 | /// use radicle_surf::file_system::{Label, Path}; 61 | /// 62 | /// let root = Path::root(); 63 | /// assert_eq!(*root.split_first().0, Label::root()); 64 | /// ``` 65 | pub fn root() -> Self { 66 | Label { 67 | label: "~".into(), 68 | hidden: false, 69 | } 70 | } 71 | 72 | /// Check that the label is equivalent to [`Label::root`]. 73 | /// 74 | /// # Examples 75 | /// 76 | /// ``` 77 | /// use radicle_surf::file_system::Label; 78 | /// use radicle_surf::file_system::unsound; 79 | /// 80 | /// let root = unsound::label::new("~"); 81 | /// assert!(root.is_root()); 82 | /// ``` 83 | pub fn is_root(&self) -> bool { 84 | *self == Self::root() 85 | } 86 | } 87 | 88 | impl fmt::Display for Label { 89 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 90 | write!(f, "{}", self.label) 91 | } 92 | } 93 | 94 | impl TryFrom<&str> for Label { 95 | type Error = error::Error; 96 | 97 | fn try_from(item: &str) -> Result { 98 | if item.is_empty() { 99 | Err(error::EMPTY_LABEL) 100 | } else if item.contains('/') { 101 | Err(error::label_has_slash(item)) 102 | } else { 103 | Ok(Label { 104 | label: item.into(), 105 | hidden: false, 106 | }) 107 | } 108 | } 109 | } 110 | 111 | impl FromStr for Label { 112 | type Err = error::Error; 113 | 114 | fn from_str(item: &str) -> Result { 115 | Label::try_from(item) 116 | } 117 | } 118 | 119 | /// A non-empty set of [`Label`]s to define a path to a directory or file. 120 | /// 121 | /// `Path` tends to be used for insertion or find operations. 122 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 123 | pub struct Path(pub NonEmpty