diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6505149..3e39c95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,16 @@ jobs: - name: Lint run: npm -ws run lint --if-present continue-on-error: true + - name: Web unit tests + run: npm --workspace apps/web run test:unit + - name: Desktop frontend tests + run: npm --workspace apps/desktop run test - name: Build web run: npm --workspace apps/web run build + - name: Smoke test the Pages build under /gitcontext/ + run: | + npm --workspace apps/web exec -- playwright install --with-deps chromium + npm --workspace apps/web run test:pages - name: Upload web artifact uses: actions/upload-artifact@v4 with: @@ -36,7 +44,20 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-latest] + include: + # Unsigned here; release.yml signs and notarizes the macOS build. + - os: macos-latest + args: --target universal-apple-darwin + rust-targets: aarch64-apple-darwin,x86_64-apple-darwin + bundle: apps/desktop/src-tauri/target/universal-apple-darwin/release/bundle/ + - os: windows-latest + args: "" + rust-targets: "" + bundle: apps/desktop/src-tauri/target/release/bundle/ + - os: ubuntu-22.04 + args: "" + rust-targets: "" + bundle: apps/desktop/src-tauri/target/release/bundle/ steps: - name: Checkout uses: actions/checkout@v4 @@ -47,6 +68,8 @@ jobs: cache: 'npm' - name: Install Rust uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust-targets }} - name: Rust cache uses: swatinem/rust-cache@v2 with: @@ -54,19 +77,17 @@ jobs: - name: Install dependencies run: npm ci - name: Install Linux dependencies - if: matrix.os == 'ubuntu-latest' + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - name: Build Tauri desktop app - uses: tauri-apps/tauri-action@v0 - with: - projectPath: apps/desktop - tauriScript: npm run tauri --workspace=gitcontext-desktop + shell: bash + run: npm run tauri --workspace=gitcontext-desktop -- build ${{ matrix.args }} - name: Upload desktop artifacts if: always() uses: actions/upload-artifact@v4 with: name: desktop-${{ matrix.os }} - path: apps/desktop/src-tauri/target/release/bundle/ + path: ${{ matrix.bundle }} retention-days: 7 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..331c1c3 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,61 @@ +# The web app on GitHub Pages: https://kccarlos.github.io/gitcontext/ +# +# Independent of the desktop release on purpose: a signing or packaging +# failure never takes the site down. The build is smoke-tested under the +# /gitcontext/ base path before it is deployed. +name: Deploy Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Unit tests + run: npm --workspace apps/web run test:unit + + - name: Build + run: npm --workspace apps/web run build + + - name: Smoke test the build under /gitcontext/ + run: | + npm --workspace apps/web exec -- playwright install --with-deps chromium + npm --workspace apps/web run test:pages + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: apps/web/dist + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c44917..d0e5877 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,148 +1,393 @@ -name: Release and Deploy +# Release: every push to main that semantic-release turns into a version. +# +# release semantic-release: the next version from Conventional Commits, +# the tag, CHANGELOG.md, and a DRAFT GitHub Release. +# desktop builds the desktop app at that tag with the version stamped +# in, on macOS (universal, Developer ID signed, app and DMG +# notarized and stapled), Windows and Linux; uploads the +# installers to the draft. +# publish publishes the draft once every platform has uploaded. +# homebrew renders packaging/homebrew/gitcontext.rb for the new version +# and pushes it to kccarlos/homebrew-tap. Uses the `homebrew` +# environment's HOMEBREW_TAP_TOKEN (Contents read/write on that +# one repository); without it the job leaves a notice. +# summary one table of results; fails the run when anything failed. +# +# workflow_dispatch builds and signs the desktop apps (version 0.0.0) as +# workflow artifacts without releasing anything: a way to check signing. +# +# The web app deploys separately (pages.yml), so a failed release never +# takes the site down. +# +# Signing secrets live in the `release` environment: +# DEVELOPER_ID_P12_BASE64, DEVELOPER_ID_P12_PASSWORD Developer ID Application identity +# ASC_API_KEY_P8_BASE64, ASC_API_KEY_ID, ASC_API_ISSUER_ID App Store Connect API key (notarization) +# and the repository variable APPLE_TEAM_ID. +name: Release on: push: - branches: - - main + branches: [main] + workflow_dispatch: permissions: - contents: write - issues: write - pull-requests: write - pages: write - id-token: write + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - build-desktop: - name: Build Desktop on ${{ matrix.os }} + release: + name: Version and draft release + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + outputs: + published: ${{ steps.tag.outputs.published }} + version: ${{ steps.tag.outputs.version }} + tag: ${{ steps.tag.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: semantic-release + run: npx semantic-release + env: + GITHUB_TOKEN: ${{ github.token }} + HUSKY: 0 + + # semantic-release tags its CHANGELOG commit, which is now HEAD; no + # tag there means this push did not warrant a release. + - name: The new tag + id: tag + run: | + tag="$(git tag --points-at HEAD --list 'v*' | head -n1)" + if [ -z "$tag" ]; then + echo "No release for this push." + echo "published=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + { + echo "published=true" + echo "tag=$tag" + echo "version=${tag#v}" + } >> "$GITHUB_OUTPUT" + echo "Released $tag (draft until the installers are uploaded)" + + desktop: + name: Desktop (${{ matrix.name }}) + needs: release + if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.release.outputs.published == 'true') }} runs-on: ${{ matrix.os }} + environment: release + permissions: + contents: write strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-latest] - + include: + - name: macOS + os: macos-latest + args: --target universal-apple-darwin + rust-targets: aarch64-apple-darwin,x86_64-apple-darwin + bundle: apps/desktop/src-tauri/target/universal-apple-darwin/release/bundle + - name: Windows + os: windows-latest + args: "" + rust-targets: "" + bundle: apps/desktop/src-tauri/target/release/bundle + - name: Linux + os: ubuntu-22.04 + args: "" + rust-targets: "" + bundle: apps/desktop/src-tauri/target/release/bundle + env: + VERSION: ${{ needs.release.outputs.version || '0.0.0' }} + TAG: ${{ needs.release.outputs.tag }} + PUBLISHED: ${{ needs.release.outputs.published == 'true' }} + BUNDLE: ${{ matrix.bundle }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + defaults: + run: + shell: bash steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + ref: ${{ needs.release.outputs.tag || github.sha }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' + cache: npm - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust-targets }} - - name: Rust cache - uses: swatinem/rust-cache@v2 + - uses: swatinem/rust-cache@v2 with: workspaces: './apps/desktop/src-tauri -> target' - - name: Install dependencies - run: npm ci + - run: npm ci - - name: Install Linux dependencies - if: matrix.os == 'ubuntu-latest' + - name: Linux dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: Build Tauri desktop app - uses: tauri-apps/tauri-action@v0 - with: - projectPath: apps/desktop - tauriScript: npm run tauri --workspace=gitcontext-desktop + - name: Stamp the version + run: | + node -e ' + const fs = require("fs"); + const p = "apps/desktop/src-tauri/tauri.conf.json"; + const c = JSON.parse(fs.readFileSync(p, "utf8")); + c.version = process.env.VERSION; + fs.writeFileSync(p, JSON.stringify(c, null, 2) + "\n"); + ' + echo "Building GitContext $VERSION" - - name: Upload desktop installer artifacts - uses: actions/upload-artifact@v4 - with: - name: dist-${{ matrix.os }} - path: apps/desktop/src-tauri/target/release/bundle/ - retention-days: 1 + # Tauri signs with APPLE_SIGNING_IDENTITY from the keychain search list + # and notarizes the app with the App Store Connect API key. + - name: Developer ID identity and notary key + if: runner.os == 'macOS' + env: + DEVELOPER_ID_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12_BASE64 }} + DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + run: | + printf '%s' "$APPLE_TEAM_ID" | grep -Eq '^[A-Z0-9]{10}$' \ + || { echo "::error::Set the repository variable APPLE_TEAM_ID to the 10-character Apple team ID"; exit 1; } + if [ -z "$DEVELOPER_ID_P12_BASE64" ] || [ -z "$DEVELOPER_ID_P12_PASSWORD" ]; then + echo "::error::Set DEVELOPER_ID_P12_BASE64 and DEVELOPER_ID_P12_PASSWORD in the release environment"; exit 1 + fi + if [ -z "$ASC_API_KEY_P8_BASE64" ] || [ -z "$ASC_API_KEY_ID" ] || [ -z "$ASC_API_ISSUER_ID" ]; then + echo "::error::Set ASC_API_KEY_P8_BASE64, ASC_API_KEY_ID and ASC_API_ISSUER_ID in the release environment"; exit 1 + fi - release: - name: Create GitHub Release - needs: build-desktop - runs-on: ubuntu-latest - outputs: - version: ${{ steps.get_version.outputs.version }} + keychain="$RUNNER_TEMP/gitcontext-signing.keychain-db" + keychain_password="$(openssl rand -hex 24)" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + (umask 077 && printf '%s' "$DEVELOPER_ID_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/developer-id.p12") + security import "$RUNNER_TEMP/developer-id.p12" -k "$keychain" -P "$DEVELOPER_ID_P12_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security >/dev/null + rm -f "$RUNNER_TEMP/developer-id.p12" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" >/dev/null + # Prepend the signing keychain, keep the existing ones (word splitting intended). + # shellcheck disable=SC2046 + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GH_TOKEN }} + identity="$(security find-identity -v -p codesigning "$keychain" \ + | sed -n 's/.*"\(Developer ID Application: .*\)"$/\1/p' | head -n1)" + case "$identity" in + *"($APPLE_TEAM_ID)") echo "Signing as: $identity" ;; + "") echo "::error::The .p12 holds no Developer ID Application identity"; exit 1 ;; + *) echo "::error::The Developer ID identity is not team $APPLE_TEAM_ID's"; exit 1 ;; + esac - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'npm' + key="$RUNNER_TEMP/AuthKey_$ASC_API_KEY_ID.p8" + (umask 077 && printf '%s' "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$key") + { + echo "SIGNING_KEYCHAIN=$keychain" + echo "APPLE_SIGNING_IDENTITY=$identity" + echo "APPLE_API_KEY=$ASC_API_KEY_ID" + echo "APPLE_API_ISSUER=$ASC_API_ISSUER_ID" + echo "APPLE_API_KEY_PATH=$key" + } >> "$GITHUB_ENV" - - name: Install dependencies - run: npm ci + - name: Build + run: npm run tauri --workspace=gitcontext-desktop -- build ${{ matrix.args }} - - name: Download all desktop build artifacts - uses: actions/download-artifact@v4 - with: - path: dist-all + # Tauri notarizes and staples the app; the DMG around it is signed, + # notarized and stapled here so Gatekeeper accepts the download too. + - name: Check the app, notarize the DMG + if: runner.os == 'macOS' + run: | + app="$BUNDLE/macos/GitContext.app" + dmg_name="GitContext_${VERSION}_universal.dmg" + dmg="$BUNDLE/dmg/$dmg_name" + test -f "$dmg" || { echo "::error::$dmg was not built"; ls -R "$BUNDLE"; exit 1; } - - name: Display downloaded artifacts structure - run: ls -R dist-all + codesign --verify --deep --strict --verbose=2 "$app" + codesign -dv "$app" 2>&1 | grep -q "^TeamIdentifier=$APPLE_TEAM_ID\$" \ + || { echo "::error::The app is not signed by team $APPLE_TEAM_ID"; exit 1; } + xcrun stapler validate "$app" + spctl --assess --type execute --verbose=2 "$app" + archs="$(lipo -archs "$app/Contents/MacOS/gitcontext-desktop")" + case "$archs" in *x86_64*arm64*|*arm64*x86_64*) echo "Architectures: $archs" ;; *) echo "::error::Not universal: $archs"; exit 1 ;; esac + # Only system libraries: a Homebrew dylib would not exist on users' Macs. + if otool -L "$app/Contents/MacOS/gitcontext-desktop" | tail -n +2 | grep -v -E '^\s*(/System/|/usr/lib/)'; then + echo "::error::The binary links a library outside the OS"; exit 1 + fi - - name: Run semantic-release - run: npx semantic-release + codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg" + xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" \ + --issuer "$APPLE_API_ISSUER" --wait --timeout 30m + xcrun stapler staple "$dmg" + spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg" + (cd "$BUNDLE/dmg" && shasum -a 256 "$dmg_name" > "$dmg_name.sha256" && cat "$dmg_name.sha256") + + - name: Collect the installers + run: | + mkdir -p dist + find "$BUNDLE" -maxdepth 2 -type f \( -name '*.dmg' -o -name '*.dmg.sha256' -o -name '*.msi' \ + -o -name '*-setup.exe' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \) -exec cp {} dist/ \; + ls -l dist + test -n "$(ls dist)" || { echo "::error::No installers found under $BUNDLE"; exit 1; } + + - uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.name }} + path: dist/* + if-no-files-found: error + retention-days: 14 + + - name: Upload to the draft release + if: env.PUBLISHED == 'true' env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - HUSKY: 0 + GH_TOKEN: ${{ github.token }} + run: gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY" - - name: Get the new version number - id: get_version + - name: Remove the identity and the key + if: always() && runner.os == 'macOS' run: | - NEW_VERSION=$(node -p "require('./package.json').version") - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + if [ -n "${SIGNING_KEYCHAIN:-}" ]; then security delete-keychain "$SIGNING_KEYCHAIN" || true; fi + rm -f "$RUNNER_TEMP"/AuthKey_*.p8 "$RUNNER_TEMP/developer-id.p12" - deploy-pages: - name: Deploy to GitHub Pages - needs: release + publish: + name: Publish the release + needs: [release, desktop] + if: needs.release.outputs.published == 'true' && needs.desktop.result == 'success' runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + permissions: + contents: write + steps: + - name: Publish the draft + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.release.outputs.tag }} + run: gh release edit "$TAG" --draft=false --latest --repo "$GITHUB_REPOSITORY" + homebrew: + name: Homebrew tap + needs: [release, publish] + if: needs.publish.result == 'success' + runs-on: ubuntu-latest + environment: homebrew + permissions: + contents: read + outputs: + state: ${{ steps.push.outputs.state }} + env: + VERSION: ${{ needs.release.outputs.version }} + TAG: ${{ needs.release.outputs.tag }} steps: - - name: Checkout main branch - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 + # A secret cannot appear in a job's `if:`; this step stands in. + - name: The tap token + id: token + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + if [ -z "$HOMEBREW_TAP_TOKEN" ]; then + echo "::notice title=Homebrew tap not updated::HOMEBREW_TAP_TOKEN is not set in the homebrew environment. Render the cask by hand with packaging/homebrew/render-cask.sh." + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + if: steps.token.outputs.configured == 'true' with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci + ref: ${{ needs.release.outputs.tag }} + persist-credentials: false - - name: Build web application - run: npm --workspace apps/web run build - - - name: Configure Pages - uses: actions/configure-pages@v5 + - name: The published checksum + if: steps.token.outputs.configured == 'true' + id: sha + env: + GH_TOKEN: ${{ github.token }} + run: | + name="GitContext_${VERSION}_universal.dmg" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name.sha256" --dir "$RUNNER_TEMP" + read -r sha file < "$RUNNER_TEMP/$name.sha256" + [ "${file#\*}" = "$name" ] || { echo "::error::The checksum file is for $file, not $name"; exit 1; } + echo "sha256=$sha" >> "$GITHUB_OUTPUT" - - name: Upload web artifact for deployment - uses: actions/upload-pages-artifact@v3 + - uses: actions/checkout@v4 + if: steps.token.outputs.configured == 'true' with: - path: apps/web/dist + repository: kccarlos/homebrew-tap + ref: main + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap + + - name: Update and push the cask + id: push + env: + CONFIGURED: ${{ steps.token.outputs.configured }} + SHA256: ${{ steps.sha.outputs.sha256 }} + run: | + if [ "$CONFIGURED" != "true" ]; then + echo "state=no token" >> "$GITHUB_OUTPUT"; exit 0 + fi + packaging/homebrew/render-cask.sh "$VERSION" "$SHA256" homebrew-tap/Casks/gitcontext.rb + cd homebrew-tap + git add Casks/gitcontext.rb + if git diff --cached --quiet; then + echo "state=already current" >> "$GITHUB_OUTPUT"; exit 0 + fi + git diff --cached + git -c user.name=kccarlos \ + -c user.email=110118511+kccarlos@users.noreply.github.com \ + commit --quiet -m "gitcontext $VERSION" + git push origin HEAD:main + echo "state=updated" >> "$GITHUB_OUTPUT" - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + summary: + name: Release summary + needs: [release, desktop, publish, homebrew] + if: always() && needs.release.outputs.published == 'true' + runs-on: ubuntu-latest + steps: + - env: + TAG: ${{ needs.release.outputs.tag }} + DESKTOP: ${{ needs.desktop.result }} + PUBLISH: ${{ needs.publish.result }} + HOMEBREW: ${{ needs.homebrew.result }} + HOMEBREW_STATE: ${{ needs.homebrew.outputs.state }} + run: | + { + echo "## GitContext $TAG" + echo + echo "| Job | Result |" + echo "| --- | --- |" + echo "| Desktop builds (macOS signed + notarized, Windows, Linux) | $DESKTOP |" + echo "| Publish release | $PUBLISH |" + echo "| Homebrew tap | $HOMEBREW${HOMEBREW_STATE:+ ($HOMEBREW_STATE)} |" + } >> "$GITHUB_STEP_SUMMARY" + status=0 + if [ "$DESKTOP" != "success" ] || [ "$PUBLISH" != "success" ]; then + echo "::error title=$TAG is still a draft::A desktop build failed, so the release was not published. Fix it and re-run the failed jobs; the draft and its tag are kept." + status=1 + fi + if [ "$HOMEBREW" = "failure" ] || [ "$HOMEBREW" = "cancelled" ]; then + echo "::error title=Homebrew tap $HOMEBREW::The release is published but the cask was not updated. Re-run the homebrew job." + status=1 + fi + exit "$status" diff --git a/README.md b/README.md index 195bf06..55cdaa5 100644 --- a/README.md +++ b/README.md @@ -9,65 +9,95 @@

-Build Status +Release +Pages Latest Release License

+

+ Try it in your browser · + Install · + How it works +

+ +

+ GitContext comparing two branches: file tree with added, modified and removed files, prompt template, model picker and token usage +

+ --- -**GitContext** lets you securely analyze and package local Git repository diffs directly on your machine for quick copy-and-paste into AI Chatbots like ChatGPT, Claude, and others. Available as a high-performance native desktop app (Tauri + Rust) for macOS, Windows, and Linux, or as a web app running entirely in your browser. +**GitContext** turns the part of a repository you care about into one clean prompt for ChatGPT, Claude or any other LLM. Pick two branches, tick the files, and copy a single block with the file tree, the diffs and your instructions, with a live token count against the model's context window. + +Everything runs on your machine. There is no server and no upload: the desktop app reads Git through native Rust (`libgit2`), and the web app runs Git in your browser with `isomorphic-git`. -Similar to Repomix or GitIngest, but with key differences: +It is similar to Repomix or GitIngest, with a few differences: -- **Zero servers, zero uploads** – everything runs entirely locally on your machine or in your browser -- **Native performance** – desktop app uses Rust for blazing-fast Git operations -- Supports **Git diffs** between branches, not just files -- Generate a **file tree** with your selected code/diffs for easy LLM ingestion -- **One-click prompt templates** tailored for common coding scenarios -- **Real-time token counting** to stay within LLM context limits +- **Branch-to-branch diffs**, not just whole files, so a review or bug fix prompt carries only what changed +- **Visual selection**: a file tree with add/modify/remove markers instead of CLI glob filters +- **Token budget up front**: per-file and total counts (tiktoken) against the selected model's context window +- **Prompt templates** for common jobs: summarize a branch diff, review changes, suggest tests, draft release notes +- **Private by construction**: local files only, no network calls for your code -## Downloads & Installation +## Install -### Desktop App (Recommended) +### macOS (Homebrew) -Download the latest native desktop app for your operating system from the [GitHub Releases](https://github.com/kccarlos/gitcontext/releases/latest) page: +```sh +brew install --cask kccarlos/tap/gitcontext +``` -- **macOS**: `.dmg` file -- **Windows**: `.msi` or `.exe` installer -- **Linux**: `.AppImage`, `.deb`, or `.rpm` package +The cask installs the same signed and notarized universal DMG (Apple silicon and Intel) that is attached to each [GitHub release](https://github.com/kccarlos/gitcontext/releases/latest). Update with `brew upgrade --cask gitcontext`. -The desktop app offers superior performance with native Rust-powered Git operations. +### Direct download -### Web App +| Platform | Package | +| --- | --- | +| macOS 10.15+ | `GitContext__universal.dmg` (Developer ID signed, notarized) | +| Windows | `.msi` or `-setup.exe` | +| Linux | `.AppImage`, `.deb` or `.rpm` | -Alternatively, use the web version directly at [gitcontext.xyz](https://gitcontext.xyz) — no installation required! +All from the [latest release](https://github.com/kccarlos/gitcontext/releases/latest). -The web app runs entirely in your browser using modern Web APIs and never uploads your code anywhere. +### Web app + +Open **[kccarlos.github.io/gitcontext](https://kccarlos.github.io/gitcontext/)** in Chrome or Edge (it needs the File System Access API). Nothing to install, and your code still never leaves the browser. --- -## What is GitContext? +## Screenshots + +These show the web app. The desktop app has the same core workflow with a native folder picker, saved workspaces and a tabbed side panel. -GitContext is a **privacy-first, local-first** application for analyzing and staging Git repository diffs — all without any cloud services. + + + + + + + + +
Side-by-side diff preview of a modified file
Diff preview: unified or side-by-side, before you include a file.
Main view in dark mode
Dark mode, following the system or set by hand.
Selected files with per-file token counts and the Copy All Selected button
Selected files with per-file token counts, sorted by size, then one click to copy the whole prompt.
-**Desktop App** uses: -- Native file system access with Tauri -- Blazing-fast Git operations via Rust's `git2` crate -- Multi-threaded performance for large repositories +--- -**Web App** uses: -- File System Access API for local-only file system access -- `isomorphic-git` for client-side Git operations -- Web workers + IndexedDB caching for performance +## How it works + +```mermaid +flowchart LR + UI["React UI
(shared @gitcontext/ui)"] --> Engine{{GitEngine interface}} + Engine -->|desktop| Rust["Tauri commands
Rust + libgit2"] + Engine -->|web| Worker["Web Worker
isomorphic-git + LightningFS"] + Rust --> Repo[(Local repository)] + Worker -->|File System Access API| Repo + UI --> Tok["tiktoken (WASM)
token counts"] + UI --> Out["Prompt: file tree + diffs + instructions"] +``` -**Key features:** -- Local-only file system access with persistent permissions -- Real-time Git operations with visual diff analysis -- Interactive file tree navigation and selective staging -- Instant token count estimation for LLM context limits (GPT-4, Claude, etc.) -- Flexible output formats with smart handling of binary files -- Dark mode support +- **One UI, two engines.** The React app talks to a `GitEngine` interface. In the desktop build it is backed by Tauri commands over `libgit2`; in the browser, by a Web Worker running `isomorphic-git` on an in-memory file system seeded from the `.git` folder you pick. +- **Responsive with large repositories.** Diffs and file reads run off the main thread, token counting is debounced and cancellable with bounded concurrency, and stale results are dropped by request IDs. +- **Exact token counts.** Counts are taken on the same text that is copied, so the budget shown matches what the model receives. +- **Shipping.** Conventional Commits drive semantic-release. Each release builds Windows, Linux and a universal macOS app in CI. The macOS app is signed with a Developer ID, notarized, and published to a Homebrew tap. The web app deploys to GitHub Pages after a smoke test of the production build. --- @@ -152,7 +182,7 @@ Build the desktop app for production: npm run desktop:build ``` -Installers will be created in `apps/desktop/src-tauri/target/release/bundle/` +Installers will be created in `apps/desktop/src-tauri/target/release/bundle/`. Release builds are signed and notarized in CI (`.github/workflows/release.yml`). ### Testing @@ -166,6 +196,15 @@ Run unit tests: ```bash npm --workspace apps/web run test:unit +npm --workspace apps/desktop run test +cd apps/desktop/src-tauri && cargo test +``` + +Smoke test the production web build as GitHub Pages serves it (under `/gitcontext/`): + +```bash +npm --workspace apps/web run build +npm --workspace apps/web run test:pages ``` --- diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 07429f3..d988082 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,7 +19,8 @@ tauri-plugin-fs = "2.0" tauri-plugin-clipboard-manager = "2.0" serde = { version = "1", features = ["derive"] } serde_json = "1" -git2 = "0.19" +# Local repositories only: no https/ssh, so no OpenSSL or libssh2 to link. +git2 = { version = "0.19", default-features = false } ignore = "0.4" notify = "6.1" notify-debouncer-full = "0.3" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 209ad62..a4d4948 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ "shortDescription": "Package Git diffs and code for LLM context", "longDescription": "GitContext helps you package local file diffs and code into a single, clean prompt for AI chatbots.", "macOS": { - "minimumSystemVersion": "10.13" + "minimumSystemVersion": "10.15" } }, "app": { diff --git a/apps/web/package.json b/apps/web/package.json index 7fbc5c7..5f09305 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "type": "git", "url": "https://github.com/kccarlos/gitcontext.git" }, - "homepage": "https://gitcontext.xyz/", + "homepage": "https://kccarlos.github.io/gitcontext/", "bugs": { "url": "https://github.com/kccarlos/gitcontext/issues" }, @@ -31,7 +31,8 @@ "preview": "vite preview", "test:e2e": "playwright test", "test:unit": "vitest run --reporter=dot", - "test": "npm run test:unit && npm run test:e2e" + "test": "npm run test:unit && npm run test:e2e", + "test:pages": "playwright test -c playwright.pages.config.ts" }, "dependencies": { "@dqbd/tiktoken": "^1.0.22", @@ -54,6 +55,7 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@playwright/test": "^1.48.2", + "@testing-library/react": "16.3.3", "@types/node": "^22.7.4", "@types/prismjs": "^1.26.5", "@types/react": ">=18 <19", @@ -63,6 +65,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "25.0.1", "typescript": "~5.8.3", "typescript-eslint": "^8.39.0", "vite": "^7.1.0", diff --git a/apps/web/playwright.pages.config.ts b/apps/web/playwright.pages.config.ts new file mode 100644 index 0000000..eefc424 --- /dev/null +++ b/apps/web/playwright.pages.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test' + +// Smoke test of the production build as GitHub Pages serves it, under +// /gitcontext/. Run after `vite build`: npm run test:pages +export default defineConfig({ + testDir: './tests/pages', + forbidOnly: !!process.env.CI, + reporter: [['list']], + use: { + baseURL: 'http://localhost:4173/gitcontext/', + headless: true, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'npx vite preview --port 4173 --strictPort', + url: 'http://localhost:4173/gitcontext/', + reuseExistingServer: false, + timeout: 60_000, + }, +}) diff --git a/apps/web/public/CNAME b/apps/web/public/CNAME deleted file mode 100644 index 46454d9..0000000 --- a/apps/web/public/CNAME +++ /dev/null @@ -1 +0,0 @@ -gitcontext.xyz diff --git a/apps/web/src/context/TokenCountsContext.test.tsx b/apps/web/src/context/TokenCountsContext.test.tsx new file mode 100644 index 0000000..ee5d74f --- /dev/null +++ b/apps/web/src/context/TokenCountsContext.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import type { GitEngine } from '../platform/types' +import type { FileDiffStatus } from '../hooks/useFileTree' + +vi.mock('../platform/tokenizerFactory', () => ({ + createTokenizer: () => ({ + async count(text: string) { return text.length }, + async warmup() {}, + }), +})) + +import { TokenCountsProvider, useTokenCountsContext } from './TokenCountsContext' + +function Total({ onRender }: { onRender: (total: number, busy: boolean) => void }) { + const { total, busy } = useTokenCountsContext() + onRender(total, busy) + return null +} + +describe('TokenCountsProvider', () => { + it('counts each selected file once instead of restarting on its own progress updates', async () => { + const readFile = vi.fn(async (_ref: string, path: string) => ({ text: `content of ${path}\n` })) + const gitClient = { readFile } as unknown as GitEngine + const selectedPaths = new Set(['a.ts', 'b.ts']) + const statusByPath = new Map([['a.ts', 'add'], ['b.ts', 'add']]) + let last = { total: 0, busy: true } + + render( + + { last = { total, busy } }} /> + , + ) + + await waitFor(() => expect(last.busy).toBe(false)) + await new Promise((r) => setTimeout(r, 50)) + + expect(last.total).toBeGreaterThan(0) + // Two added files: one read each, from the compare ref. + expect(readFile).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/web/src/context/TokenCountsContext.tsx b/apps/web/src/context/TokenCountsContext.tsx index 2adf632..8893b1f 100644 --- a/apps/web/src/context/TokenCountsContext.tsx +++ b/apps/web/src/context/TokenCountsContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useMemo, useState } from 'react' +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react' import type { GitEngine } from '../platform/types' import type { FileDiffStatus } from '../hooks/useFileTree' import { useTokenCounts } from '../hooks/useTokenCounts' @@ -36,6 +36,16 @@ export function TokenCountsProvider({ }: ProviderProps) { const [progress, setProgress] = useState({ completed: 0, total: 0, percent: 0 }) + // Stable identity: onBatch is a dependency of the counting effect, so a new + // function per render would restart counting on every progress update. + const onBatch = useCallback((done: number, totalFiles: number) => { + const pct = + totalFiles <= 0 + ? 100 + : Math.max(0, Math.min(100, Math.round((done / totalFiles) * 100))) + setProgress({ completed: done, total: totalFiles, percent: pct }) + }, []) + const { counts, total, busy } = useTokenCounts({ gitClient, baseRef, @@ -44,13 +54,7 @@ export function TokenCountsProvider({ statusByPath, diffContextLines, includeBinaryPaths, - onBatch: (done, totalFiles) => { - const pct = - totalFiles <= 0 - ? 100 - : Math.max(0, Math.min(100, Math.round((done / totalFiles) * 100))) - setProgress({ completed: done, total: totalFiles, percent: pct }) - }, + onBatch, }) const value = useMemo(() => ({ counts, total, busy, progress }), [counts, total, busy, progress]) diff --git a/apps/web/tests/pages/pages.spec.ts b/apps/web/tests/pages/pages.spec.ts new file mode 100644 index 0000000..632a06b --- /dev/null +++ b/apps/web/tests/pages/pages.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test' + +test('the built site loads under /gitcontext/ with every asset found', async ({ page }) => { + const failed: string[] = [] + const errors: string[] = [] + page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url()}`) }) + page.on('pageerror', (e) => errors.push(e.message)) + + await page.goto('./') + await expect(page.getByRole('button', { name: /Select Project Folder/i })).toBeVisible() + + const logo = page.locator('header img').first() + await expect(logo).toHaveAttribute('src', /^\/gitcontext\//) + expect(await logo.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0)).toBe(true) + + // Public files the app fetches at run time resolve under the base path. + const example = await page.request.get('example-output.txt') + expect(example.ok()).toBe(true) + + expect(failed).toEqual([]) + expect(errors).toEqual([]) +}) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4c5754b..c433f31 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,8 +8,10 @@ import { dirname, resolve } from 'node:path' const __dirname = dirname(fileURLToPath(import.meta.url)) // https://vite.dev/config/ -export default defineConfig({ - base: '/', +export default defineConfig(({ command, isPreview }) => ({ + // Production builds are served from GitHub Pages at kccarlos.github.io/gitcontext/; + // `vite preview` serves that same path. The dev server (and the e2e tests) stay at the root. + base: command === 'build' || isPreview ? '/gitcontext/' : '/', plugins: [ react(), wasm(), @@ -63,4 +65,4 @@ export default defineConfig({ platform: 'browser', }, }, -}) +})) diff --git a/docs/screenshots/01-diff-overview.png b/docs/screenshots/01-diff-overview.png new file mode 100644 index 0000000..b96b974 Binary files /dev/null and b/docs/screenshots/01-diff-overview.png differ diff --git a/docs/screenshots/02-file-preview.png b/docs/screenshots/02-file-preview.png new file mode 100644 index 0000000..4c0ee29 Binary files /dev/null and b/docs/screenshots/02-file-preview.png differ diff --git a/docs/screenshots/03-output.png b/docs/screenshots/03-output.png new file mode 100644 index 0000000..f0db7ea Binary files /dev/null and b/docs/screenshots/03-output.png differ diff --git a/docs/screenshots/04-dark-mode.png b/docs/screenshots/04-dark-mode.png new file mode 100644 index 0000000..325e26d Binary files /dev/null and b/docs/screenshots/04-dark-mode.png differ diff --git a/package-lock.json b/package-lock.json index e536ded..30c519a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@playwright/test": "^1.48.2", + "@testing-library/react": "16.3.3", "@types/node": "^22.7.4", "@types/prismjs": "^1.26.5", "@types/react": ">=18 <19", @@ -104,6 +105,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "25.0.1", "typescript": "~5.8.3", "typescript-eslint": "^8.39.0", "vite": "^7.1.0", @@ -3548,9 +3550,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 91cfa04..0283a36 100644 --- a/package.json +++ b/package.json @@ -47,14 +47,7 @@ [ "@semantic-release/github", { - "assets": [ - "dist-all/**/*.dmg", - "dist-all/**/*.exe", - "dist-all/**/*.AppImage", - "dist-all/**/*.msi", - "dist-all/**/*.deb", - "dist-all/**/*.rpm" - ] + "draftRelease": true } ], [ diff --git a/packaging/homebrew/gitcontext.rb b/packaging/homebrew/gitcontext.rb new file mode 100644 index 0000000..d3b62af --- /dev/null +++ b/packaging/homebrew/gitcontext.rb @@ -0,0 +1,28 @@ +cask "gitcontext" do + version "@VERSION@" + sha256 "@SHA256@" + + url "https://github.com/kccarlos/gitcontext/releases/download/v#{version}/GitContext_#{version}_universal.dmg" + name "GitContext" + desc "Package Git diffs and code into LLM-ready context, fully offline" + homepage "https://github.com/kccarlos/gitcontext" + + livecheck do + url :url + strategy :github_latest + end + + depends_on :macos + + app "GitContext.app" + + uninstall quit: "xyz.gitcontext.desktop" + + zap trash: [ + "~/Library/Application Support/xyz.gitcontext.desktop", + "~/Library/Caches/xyz.gitcontext.desktop", + "~/Library/Preferences/xyz.gitcontext.desktop.plist", + "~/Library/Saved Application State/xyz.gitcontext.desktop.savedState", + "~/Library/WebKit/xyz.gitcontext.desktop", + ] +end diff --git a/packaging/homebrew/render-cask.sh b/packaging/homebrew/render-cask.sh new file mode 100755 index 0000000..54df6b0 --- /dev/null +++ b/packaging/homebrew/render-cask.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Render the Homebrew cask for a released version. +# +# render-cask.sh +# +# The template (gitcontext.rb next to this script) is the whole cask; the +# release workflow renders it into kccarlos/homebrew-tap/Casks/gitcontext.rb. +set -euo pipefail + +version="${1:?version}" +sha="${2:?sha256}" +out="${3:?output path}" + +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "render-cask: '$version' is not MAJOR.MINOR.PATCH" >&2; exit 2; } +[[ "$sha" =~ ^[0-9a-f]{64}$ ]] || { echo "render-cask: the checksum is not 64 lowercase hex digits" >&2; exit 2; } + +template="$(dirname "$0")/gitcontext.rb" +mkdir -p "$(dirname "$out")" +sed -e "s/@VERSION@/$version/" -e "s/@SHA256@/$sha/" "$template" > "$out" +echo "render-cask: $out at $version"