diff --git a/.cspell/words.txt b/.cspell/words.txt index 22978489..7f34b0f1 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -9,6 +9,7 @@ APFS arboard atuin cachain +cachix catppuccin claudemd Clawd @@ -24,6 +25,7 @@ dedupe deque deserialize desync +direnv disambiguable dtolnay EACCES diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 00000000..2fc2586b --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,27 @@ +name: Setup Nix +description: Install Nix and configure the Cachix substituter + +inputs: + cachix_name: + description: Cachix cache name (skips Cachix if not provided) + cachix_auth_token: + description: Cachix authentication token + cachix_skip_push: + description: Skip pushing to Cachix + default: 'false' + +runs: + using: composite + steps: + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + install_options: ${{ runner.os == 'Linux' && '--no-daemon' || '' }} + + - name: Setup Cachix + if: ${{ inputs.cachix_name }} + uses: cachix/cachix-action@v17 + with: + name: ${{ inputs.cachix_name }} + authToken: ${{ inputs.cachix_auth_token }} + skipPush: ${{ inputs.cachix_skip_push }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c871a17..802c6fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,50 +7,77 @@ on: branches: [main] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: + CACHIX_NAME: hakula CARGO_TERM_COLOR: always jobs: + flake-check: + name: Nix Flake Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Nix + uses: ./.github/actions/setup-nix + with: + cachix_name: ${{ env.CACHIX_NAME }} + cachix_auth_token: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachix_skip_push: ${{ !(github.ref == 'refs/heads/main' || github.actor == 'hakula139') }} + + - name: Run flake check + run: nix flake check + rust-check: + name: Rust Check runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + - name: Setup Nix + uses: ./.github/actions/setup-nix with: - components: rustfmt, clippy + cachix_name: ${{ env.CACHIX_NAME }} + cachix_auth_token: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachix_skip_push: ${{ !(github.ref == 'refs/heads/main' || github.actor == 'hakula139') }} - - name: Cache dependencies + - name: Cache cargo target uses: Swatinem/rust-cache@v2 - name: Format - run: cargo fmt --all --check + run: nix develop -c cargo fmt --all --check - name: Clippy - run: cargo clippy --all-targets -- -D warnings + run: nix develop -c cargo clippy --all-targets -- -D warnings - name: Test - run: cargo test + run: nix develop -c cargo test coverage: + name: Coverage runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + - name: Setup Nix + uses: ./.github/actions/setup-nix + with: + cachix_name: ${{ env.CACHIX_NAME }} + cachix_auth_token: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachix_skip_push: ${{ !(github.ref == 'refs/heads/main' || github.actor == 'hakula139') }} - - name: Cache dependencies + - name: Cache cargo target uses: Swatinem/rust-cache@v2 - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - name: Generate coverage - run: cargo llvm-cov --ignore-filename-regex 'main\.rs' --lcov --output-path lcov.info + run: nix develop -c cargo llvm-cov --ignore-filename-regex 'main\.rs' --lcov --output-path lcov.info - name: Upload to Codecov uses: codecov/codecov-action@v6 @@ -59,25 +86,45 @@ jobs: files: lcov.info node-check: + name: Node Check runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - - name: Set up pnpm - uses: pnpm/action-setup@v5 - - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Setup Nix + uses: ./.github/actions/setup-nix with: - node-version: lts/* - cache: pnpm + cachix_name: ${{ env.CACHIX_NAME }} + cachix_auth_token: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachix_skip_push: ${{ !(github.ref == 'refs/heads/main' || github.actor == 'hakula139') }} - name: Install dependencies - run: pnpm install --frozen-lockfile + run: nix develop -c pnpm install --frozen-lockfile - name: Lint Markdown - run: pnpm lint + run: nix develop -c pnpm lint - name: Spell check - run: pnpm spellcheck + run: nix develop -c pnpm spellcheck + + nix-build: + name: Nix Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Nix + uses: ./.github/actions/setup-nix + with: + cachix_name: ${{ env.CACHIX_NAME }} + cachix_auth_token: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachix_skip_push: ${{ !(github.ref == 'refs/heads/main' || github.actor == 'hakula139') }} + + - name: Build oxide-code + run: nix build .#oxide-code --print-build-logs diff --git a/.gitignore b/.gitignore index 29623de1..8aba0f81 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ /target/ /node_modules/ +# Nix +.direnv/ +.pre-commit-config.yaml +result +result-* + # cargo insta pending-review files *.snap.new diff --git a/CLAUDE.md b/CLAUDE.md index 04478dff..c8be53fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -293,6 +293,8 @@ pnpm spellcheck # Spell check The `pnpm` checks gate the `node-check` CI job. `cspell` covers Rust sources too, so a new word in a doc comment fails the same way as one in `README.md`. +`nix develop` provisions the hook toolchain and installs a [pre-commit](https://pre-commit.com) hook (generated by [`git-hooks.nix`](https://github.com/cachix/git-hooks.nix)) that runs the compile-free subset of these checks at commit time: `rustfmt`, `nixfmt`, `markdownlint`, and `cspell`. `nix flake check` runs the same hooks. `clippy`, tests, and coverage stay out of the hook because their build cost would gate every commit. + ### Mutation testing Coverage reports whether a line ran. Mutation testing reports whether a mutation of that line would be caught. Run out-of-band before large-scope changes ship because a full run is slow: diff --git a/crates/oxide-code/src/file_tracker.rs b/crates/oxide-code/src/file_tracker.rs index 1c5fe985..d95f8c67 100644 --- a/crates/oxide-code/src/file_tracker.rs +++ b/crates/oxide-code/src/file_tracker.rs @@ -1080,10 +1080,12 @@ mod tests { ]; let tracker = FileTracker::default(); - let dropped = tracker.restore_verified(snaps); + let mut dropped = tracker.restore_verified(snaps); + dropped.sort(); + let mut expected = vec![drifted_path, missing_path]; + expected.sort(); assert_eq!( - dropped, - vec![missing_path, drifted_path], + dropped, expected, "both the size-drifted and the missing snapshots must be reported", ); assert!(tracker.lock().contains_key(&kept_path)); diff --git a/flake.lock b/flake.lock index fb4114b0..86111e13 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,21 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,18 +34,61 @@ "type": "github" } }, + "git-hooks-nix": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781733627, + "narHash": "sha256-U3yTuGBnmXvXoQI3qkpfEDsn9RovQPAjN7ndRco+3u0=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "3bbec39bc90eadfa031e6f3b77272f3f60803e39", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1778003029, - "narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=", + "lastModified": 1782233679, + "narHash": "sha256-QyuGP5+QOtmXpy4i2X4DhBVBaySBdDKQEhqKcphcp34=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5", + "rev": "667d5cf1c59585031d743c78b394b0a647537c35", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.11", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } @@ -37,6 +96,7 @@ "root": { "inputs": { "flake-utils": "flake-utils", + "git-hooks-nix": "git-hooks-nix", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay" } diff --git a/flake.nix b/flake.nix index a579cac7..ec466d80 100644 --- a/flake.nix +++ b/flake.nix @@ -2,17 +2,23 @@ # # nix run github:hakula139/oxide-code # one-shot # nix profile install github:hakula139/oxide-code +# nix develop # dev shell + pre-commit hooks +# nix flake check # run pre-commit hooks { description = "oxide-code — terminal-based AI coding assistant"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; flake-utils.url = "github:numtide/flake-utils"; rust-overlay = { url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; + git-hooks-nix = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = @@ -20,6 +26,7 @@ nixpkgs, flake-utils, rust-overlay, + git-hooks-nix, ... }: flake-utils.lib.eachDefaultSystem ( @@ -31,7 +38,13 @@ }; # Track the workspace's MSRV via rust-overlay; nixpkgs' stable rustc may lag. - rustToolchain = pkgs.rust-bin.stable.latest.default; + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "llvm-tools-preview" + "rust-analyzer" + "rust-src" + ]; + }; cargoToml = fromTOML (builtins.readFile ./Cargo.toml); @@ -79,13 +92,101 @@ mainProgram = "ox"; }; }; + + # ---------------------------------------------------------------------- + # Node Hook Wrapper + # ---------------------------------------------------------------------- + # `pnpm exec` needs node + pnpm on PATH and the project's `node_modules` + # materialised. The Nix sandbox lacks the latter, so `nix flake check` + # skips these hooks; the equivalent checks run in CI via direct `pnpm` + # scripts (the `node-check` job). + nodeHook = + name: cmd: + let + wrapper = pkgs.writeShellApplication { + inherit name; + runtimeInputs = [ + pkgs.nodejs_24 + pkgs.pnpm + ]; + text = '' + if [ ! -d node_modules ]; then + exit 0 + fi + pnpm exec ${cmd} "$@" + ''; + }; + in + "${wrapper}/bin/${name}"; + + # ---------------------------------------------------------------------- + # Pre-commit Hooks + # ---------------------------------------------------------------------- + preCommitCheck = git-hooks-nix.lib.${system}.run { + src = ./.; + hooks = { + nixfmt.enable = true; + + # Clippy stays in CI; the bare hook would recompile on every commit. + rustfmt = { + enable = true; + packageOverrides = { + cargo = rustToolchain; + rustfmt = rustToolchain; + }; + }; + + markdownlint = { + enable = true; + name = "markdownlint-cli2"; + entry = nodeHook "markdownlint" "markdownlint-cli2 --fix"; + files = "\\.md$"; + pass_filenames = true; + }; + + cspell = { + enable = true; + entry = nodeHook "cspell" "cspell --no-must-find-files --no-progress"; + types = [ "text" ]; + pass_filenames = true; + }; + }; + }; in { + # ---------------------------------------------------------------------- + # Dev Shell + # ---------------------------------------------------------------------- + devShells.default = pkgs.mkShell { + name = "oxide-code-dev"; + + packages = + preCommitCheck.enabledPackages + ++ [ rustToolchain ] + ++ (with pkgs; [ + cargo-llvm-cov + git-cliff + nodejs_24 + pnpm + ]); + + shellHook = preCommitCheck.shellHook; + + env.RUST_BACKTRACE = "1"; + }; + packages = { default = oxide-code; inherit oxide-code; }; + # ---------------------------------------------------------------------- + # Checks (`nix flake check`) + # ---------------------------------------------------------------------- + checks = { + pre-commit = preCommitCheck; + }; + formatter = pkgs.nixfmt; } );