diff --git a/AGENTS.md b/AGENTS.md index 55cdb00..be7651f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,28 @@ menu for stage, stage-all, unstage, and explicitly confirmed discard actions. GitHub pull-request creation, review, and management stay inside Git Workspace/Git Sidebar through the typed CrewCode client. Preserve the single-PR Branches → Details → Review creation flow, measured base-relative evidence, the compact current-branch PR sidebar launcher, and the canonical repository PR Browser with real on-demand detail/diff/check evidence, PierreDiff per-file review, overall review submission, explicit draft-to-ready action, visible action-gate reasons, explicit merge confirmation, and merge/squash/rebase choices. The Browser must capture the exact selected PR/branches, lock selection/navigation during every mutation, and refresh authoritative evidence after the observed result; do not restore a second review shell or mutation state. PR conflict preparation must require explicit confirmation, the exact clean PR-head worktree, validated refs, observed fetch/merge outcomes, and must surface conflicts through the existing merge card without implicit checkout; retain `MERGE_HEAD` visibility after the last file is staged, then require explicit continue and push. `--auto` is not conflict resolution. Read methods remain registered-root `workspace:read`; mutations require `workspace:write`. Never expose GitHub credentials to the renderer/browser, fabricate inline comments, imply self-approval is available, or infer readiness/merge from silence. Crew integration remains a separate provenance-journal and behavioral-verification workflow. See `docs/github-pull-requests.md`. +Pull-request creation may select a bounded subset of the observed base-to-head +commit history. Revalidate full commit ids after fetching the latest base, then +create and push a new named head branch by cherry-picking in history order from +an isolated temporary worktree; never rewrite, checkout, or dirty the user's +current branch. Abort and remove the isolated operation without pushing when a +selected commit conflicts. Keep PR conflict file editing, ours/theirs, +exact-file staging, continue, abort, and push in the PR Browser's contextual +Conflicts tab. Recheck the selected PR head branch before every local mutation +and refresh GitHub detail/check evidence after the resolved head is pushed. + +PR creation Details keeps optional Description, Problem, What changed, Why it +changed, and Solution fields. Submit only non-empty author content under exact +level-two Markdown headings and show explicit not-provided states in Review; +never fabricate a missing section. The PR Browser Conflicts tab must render one +bounded, canonical PierreDiff patch from Git's exact stage-2 ours and stage-3 +theirs blobs for the selected unresolved path, label the PR-head/base sides, +and keep Use ours/Use theirs actions beside that evidence. Manual resolution +remains editable, must reject standard conflict markers before exact-file +staging, and must not treat the marker-filled working file as side evidence. +Conflict-side reads require registered-root `workspace:read`; resolution writes +and staging retain `workspace:write`. + The Git Workspace repository PR browser loads a bounded all-state catalogue through the typed client, selects the current branch's head PR when present, and filters the observed catalogue locally by all/open/closed/assigned-to-viewer. Treat merged PRs as closed and assigned as observed assignee or requested-reviewer identity only. Load selected PR details on demand and identify the author with the observed GitHub username plus exact creation timestamp. Keep Overview, Timeline, Code changes, and Checks in the middle pane: Overview always exposes Description/Problem/What changed/Why it changed/Solution while marking absent author content as not provided rather than inventing it; Timeline chronologically combines the observed open event, commits, comments, and submitted reviews; Code changes loads GitHub's combined base-to-head diff on demand, never the `--patch` format-patch mail series, strips defensive mail boundaries, folds repeated paths under one canonical `diff --git` header, and sends one selected file patch to PierreDiff; Checks shows observed status and links only as an external fallback. Keep non-heading copy across every PR surface at the readable PR body scale rather than terminal-label sizes. Show real reviewers, assignees, labels, checks, branches, and change metrics. Keep mutation actions in the Browser inspector with exact target confirmation and mutation locking. Preserve the stacked mobile catalogue/detail and file-list/diff layouts and the explicit SSH-unavailable result. See `docs/github-pull-requests.md`. PR authorship avatars must be retrieved only through the trusted main/Brain boundary. Restrict every redirect and bounded raster response to GitHub-controlled hosts, return only a data URL to the renderer, cache successful images by username, and keep the GitHub-mark fallback when retrieval fails. Keep observed GitHub comments and submitted review summaries at the bottom of PR Browser Overview after the description sections, with author, state, exact timestamp, Markdown body, and an explicit empty state. @@ -25,7 +47,7 @@ PR Browser inline reviews remain bound to the exact selected PR, head commit, fi PR Browser management stays in the canonical selected-PR inspector. Preserve exact-number mutation locking and authoritative catalogue/detail refresh for title/body edits, reviewer/assignee/label changes, draft/ready transitions, and close/reopen. Load bounded assignable-user, suggested-reviewer, and repository-label choices only when management opens through registered-root `workspace:read`; all management mutations require `workspace:write`. Keep author, label, base, head, review-request, and review-decision filtering local to the bounded catalogue. Preserve repository-scoped selected PR, filters/search, middle-pane tab, and selected file when returning where the evidence still exists. Copy PR identity and branch evidence without opening GitHub. Never expose GitHub credentials to the renderer or imply a mutation succeeded before refreshed GitHub evidence is observed. See `docs/github-pull-requests.md`. -PR Browser checks and merging remain head-pinned to the exact selected PR. Load bounded check suites, jobs, steps, annotations, and logs on demand through the trusted main/Brain boundary; state explicit third-party provider limits instead of inventing unavailable evidence. Check context and logs require registered-root `workspace:read`; reruns and merge automation require `workspace:write`. Revalidate the GitHub head and job/run ownership before reruns, and require explicit confirmation for job/workflow reruns, direct merge, auto-merge, queue submission, and disabling automation. Keep merge/squash/rebase, auto-merge, and queue operations locked to the observed head commit, then refresh authoritative detail and check evidence and report only the resulting observed state. Treat branch-update permission reasons as update guidance, not fabricated merge blockers. See `docs/github-pull-requests.md`. +PR Browser checks and merging remain head-pinned to the exact selected PR. Load bounded check suites, jobs, steps, annotations, and logs on demand through the trusted main/Brain boundary; state explicit third-party provider limits instead of inventing unavailable evidence. Pass the exact selected PR number when resolving GitHub's required-check fields, and surface check-context failures in the merge inspector instead of silently swallowing the action. Check context and logs require registered-root `workspace:read`; reruns and merge automation require `workspace:write`. Revalidate the GitHub head and job/run ownership before reruns, and require explicit confirmation for job/workflow reruns, direct merge, auto-merge, queue submission, and disabling automation. Keep merge/squash/rebase, auto-merge, and queue operations locked to the observed head commit, then refresh authoritative detail and check evidence and report only the resulting observed state. Treat branch-update permission reasons as update guidance, not fabricated merge blockers. See `docs/github-pull-requests.md`. Desktop system-tray behavior is opt-in. When enabled, closing the window hides it while app-owned work continues; the tray must expose explicit Open and Quit diff --git a/README.md b/README.md index cdddae6..b8acf94 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ [![X](https://img.shields.io/badge/X-@OnPointTools-000000.svg)](https://x.com/OnPointTools) [![YouTube](https://img.shields.io/badge/YouTube-@CjWisdom-FF0000.svg)](https://www.youtube.com/@CjWisdom) +**[Want to continue where you left off — from any browser? Jump to the setup →](#continue-your-work-from-anywhere)** +
@@ -182,6 +184,38 @@ Plugin agent providers support `mock`, `exec`, and `http` runtimes, so you can c - Configurable agent launch paths and shell preferences - Remote hosting: Remote file editing,terminal, and agents connection to host +## Continue your work from anywhere + +Leave your PC running at home and pick up exactly where you left off from a browser — +or run CrewCode headless on a VPS, NAS, or always-on server so your agents are always +reachable. + +- **Desktop & web continuity (Background Brain):** enable **Settings → Desktop & Web** on an enrolled machine and CrewCode keeps a detached Brain running even after the Electron window closes. Desktop and browser share the same machine-authoritative workspaces, chat sessions, transcripts, terminals, and agents — provider resume state carries over, so you continue the same conversation instead of starting from scratch. +- **Self-hosted Hub:** one always-on `crewcode hub` gives you a single passkey-secured dashboard of all your enrolled machines. Brains connect outbound-only, so your dev machines need no open inbound ports. The relay is end-to-end encrypted — the Hub never sees your code, prompts, transcripts, or terminal output, and files never leave the machine they live on. +- **Always available:** run `crewcode brain` on a VPS or home server with explicit workspace roots and your workspaces are reachable from any device, anytime. +- **Phone access:** the Hub's mobile dashboard lists your online machines and recent threads, with a QR-assisted setup over Tailscale HTTPS. +- **Secure by default:** remote authority is off until you grant explicit workspace roots and scopes; pairing codes and connection tickets are short-lived and single-use; sign-in is passkey-based; provider API keys never leave the Brain's machine. + +```bash +# On the always-on Hub host (VPS, NAS, or home server): +node bin/crewcode-server.mjs hub --local-brain \ + --host 0.0.0.0 --public-origin https://your-hub.example + +# On each development machine, enroll once and enable Background Brain +# in Settings → Desktop & Web (or run a headless Brain yourself): +node bin/crewcode-server.mjs enroll --hub https://your-hub.example +npm run brain +``` + +For a single machine with no Hub, `npm run serve` exposes the same web client over +loopback, LAN, or a Tailscale tailnet behind a one-time pairing URL. + +This is a preview feature with documented first-release limits. See +[web remote access](./docs/web-remote-access.md) for deployment profiles, the Hub +security model, and direct-server mode, and +[desktop & web continuity](./docs/desktop-web-continuity.md) for how desktop state and +the Brain stay in sync. + ## Best fit today CrewCode is strongest today for: diff --git a/docs/github-pull-requests.md b/docs/github-pull-requests.md index 994a3cf..0356df6 100644 --- a/docs/github-pull-requests.md +++ b/docs/github-pull-requests.md @@ -7,11 +7,31 @@ CrewCode's Git Sidebar and Git Workspace provide an in-app pull-request workflow Open **Pull Requests**, then select **Create pull request**. CrewCode presents one pull request through three focused steps: 1. **Branches** selects the source and base, then measures ahead/behind counts, changed files, and merge conflicts directly against that base. -2. **Details** collects the title, optional Markdown description, and draft or ready-for-review state. +2. **Details** collects the title, five optional Markdown sections—Description, + Problem, What changed, Why it changed, and Solution—and the draft or + ready-for-review state. 3. **Review** confirms the exact source, target, evidence, and content before creation. New pull requests default to draft. Branch comparison uses read-only Git commands and never checks out or moves a ref. CrewCode passes each creation value as a separate `gh` argument, refreshes GitHub state after the command completes, and reports the observed result in the Git banner. It does not infer success from a URL or open the new pull request in a browser. +Each Details section is optional. CrewCode includes only non-empty sections in +the submitted PR body, using the visible field label as a level-two Markdown +heading. The Review step shows every section and marks omitted content as not +provided, so the author sees the structure before creation. CrewCode does not +generate or infer problem statements, rationale, or solutions. + +The Branches step can use **All new commits** or **Pick commits**. The default +uses the current branch as the PR head, so a fresh `dev` to `main` PR contains +only commits that GitHub has not already merged into `main`. Pick-commit mode +assembles a smaller release from the current base-to-head history. Select up to +100 full commit identities and name a new source branch. On confirmation, the +trusted service fetches the latest `origin/`, revalidates every selected +commit against that authoritative range, creates an isolated temporary +worktree from the latest base, and cherry-picks the commits in history order. +Only the new branch is pushed and used as the PR head; the current branch and +worktree are never checked out, rewritten, or dirtied. A cherry-pick conflict +aborts the temporary operation and no branch is pushed. + A draft cannot be merged. The review inspector explains that gate and provides **Mark ready for review**, backed by `gh pr ready`. After GitHub confirms the transition, CrewCode reloads the PR evidence; it never treats clicking the @@ -46,7 +66,8 @@ service, accepts only bounded raster content from GitHub-controlled hosts, and returns a `data:` image to the renderer. Images are cached by username for the app session; a failed or rejected image falls back to the GitHub mark. -The middle pane has four views: +The middle pane has four standard views, plus a contextual **Conflicts** view +while local PR conflict resolution is active: - **Overview** always shows **Description**, **Problem**, **What changed**, **Why it changed**, and **Solution**. Authored headings and common aliases map @@ -79,6 +100,17 @@ The middle pane has four views: whole workflow. Third-party status providers that do not expose a GitHub Actions run retain their observed status and external details link, while CrewCode states that in-app logs and reruns are unavailable. +- **Conflicts** appears for a conflicting PR or active conflict-resolution + operation. It lists the exact unmerged paths and reads Git's stage-2 ours blob + and stage-3 theirs blob through the trusted registered-root boundary. One + canonical per-file patch renders those sides in PierreDiff, labeled with the + PR head and base branch. You can accept the complete ours/theirs side from the + comparison or edit the resolution result below it. CrewCode refuses to mark a + manual result resolved while standard conflict markers remain, then saves and + stages only that exact file. It retains merge-in-progress evidence after the + last path is staged and keeps explicit continue, abort, and push actions in + the PR workspace. Binary or oversized evidence is reported explicitly while + ours/theirs resolution remains available when Git exposes that side. Non-heading copy across the creation flow, sidebar PR card, repository browser, timeline, file catalogue, inspector, and review workspace uses the shared @@ -158,7 +190,9 @@ misrepresented as independent merge rules. Merging always requires an explicit in-card confirmation. CrewCode refreshes the selected PR's check and merge context before presenting that confirmation, names the exact head commit, and asks GitHub to reject the operation if the head -changes. Choose one of the methods supported by GitHub: +changes. If GitHub's merge-requirements query fails, the inspector reports the +failure and directs the user to **Checks** for the exact error instead of +silently ignoring the merge action. Choose one of the methods supported by GitHub: - **Create merge commit** preserves the branch commits and adds a merge commit. - **Squash and merge** combines the pull request into one commit. @@ -195,15 +229,22 @@ git merge --no-edit origin/ ``` Every argument is passed separately and both refs are validated. If the merge -is clean, CrewCode reports that the local head must be pushed. If Git observes -unmerged paths, the review returns to Git Workspace and the existing -**Conflicts** card lists the exact files. Each file can be opened in CrewCode's -editable Code Editor, resolved with ours/theirs, or assigned to an agent. Save -manual edits and stage the resolved file. **Continue merge** remains available after -the last conflict is staged because merge-in-progress state comes from observed -`MERGE_HEAD`, not from the continued presence of an unmerged file. **Abort** -restores the pre-merge state. After continuing, explicitly push the PR head and -refresh the PR before attempting the confirmed GitHub merge again. +is clean, CrewCode opens the contextual **Conflicts** tab at its push step. If +Git observes unmerged paths, that tab lists the exact files instead of sending +you back to Git Workspace. Select a file and edit its conflict markers, or +explicitly choose **Use ours** or **Use theirs** for the complete file. **Save +and mark resolved** writes through the registered-root filesystem boundary and +stages that exact path after checking that standard conflict markers were +removed. The comparison is an observed, bounded stage-2-to-stage-3 Git diff; +it is not reconstructed from the marker-filled working file. **Continue +merge** remains available after the last +conflict is staged because merge-in-progress state comes from observed +`MERGE_HEAD`, not from the continued presence of an unmerged file. **Abort +merge** restores the pre-merge state and exits the contextual workspace. After +continuing, **Push ``** updates the exact PR branch, reloads GitHub detail +and check evidence, and returns to Checks before another confirmed merge +attempt. Every local mutation rechecks that the worktree is still on the +selected PR head; target drift is refused. GitHub's suggested `--auto` flag is intentionally not treated as conflict resolution. Auto-merge only queues the final merge after conflicts, checks, @@ -211,7 +252,7 @@ reviews, and repository rules are satisfied. ## Desktop and web behavior -Electron invokes the local authenticated `gh` CLI. A browser connected to a Brain uses the same typed CrewCode client contract. PR catalogue, comparison, detail, diff, public avatar, review-context, management choices, detailed check context, and bounded check-log methods require `workspace:read`; creation, detail and metadata editing, draft/ready transitions, review submission, branch update, check reruns, auto-merge or queue changes, close/reopen, and merge require `workspace:write`. Every operation remains confined to a registered workspace root. GitHub credentials never cross into the renderer or browser. +Electron invokes the local authenticated `gh` CLI. A browser connected to a Brain uses the same typed CrewCode client contract. PR catalogue, comparison, detail, diff, conflict-side evidence, public avatar, review-context, management choices, detailed check context, and bounded check-log methods require `workspace:read`; creation, conflict resolution and staging, detail and metadata editing, draft/ready transitions, review submission, branch update, check reruns, auto-merge or queue changes, close/reopen, and merge require `workspace:write`. Every operation remains confined to a registered workspace root. GitHub credentials never cross into the renderer or browser. This GitHub workflow is separate from crew integration. Crew lane merges continue to use the provenance journal, behavioral verification, and explicit apply gate documented in [Behavioral merge review](behavioral-merge-review.md). diff --git a/src/main/gh.ts b/src/main/gh.ts index c838509..d77bef9 100644 --- a/src/main/gh.ts +++ b/src/main/gh.ts @@ -1,7 +1,7 @@ import { ipcMain, BrowserWindow, shell } from 'electron' import { spawn, spawnSync, ChildProcess } from 'child_process' import { publishRepository, type PublishRepoOpts } from './github-publish' -import { getGitHubAvatar, getPullRequestCatalogue, getPullRequestCheckLog, getPullRequestChecksContext, getPullRequestCreateContext, getPullRequestDetail, getPullRequestDiff, getPullRequestManagementContext, getPullRequestReviewContext, ghAvailable, getGhStatus, preparePullRequestConflictResolution, pullRequestActionArgs, pullRequestCommentArgs, pullRequestCreateArgs, pullRequestEditArgs, pullRequestMergeArgs, pullRequestMetadataArgs, pullRequestReviewArgs, rerunPullRequestCheck, runGh, submitPullRequestReview, updatePullRequestMergeAutomation, updatePullRequestReviewThread, updatePullRequestViewedFile } from './github-service' +import { createPullRequest, getGitHubAvatar, getPullRequestCatalogue, getPullRequestCheckLog, getPullRequestChecksContext, getPullRequestCreateContext, getPullRequestDetail, getPullRequestDiff, getPullRequestManagementContext, getPullRequestReviewContext, ghAvailable, getGhStatus, preparePullRequestConflictResolution, pullRequestActionArgs, pullRequestCommentArgs, pullRequestEditArgs, pullRequestMergeArgs, pullRequestMetadataArgs, pullRequestReviewArgs, rerunPullRequestCheck, runGh, submitPullRequestReview, updatePullRequestMergeAutomation, updatePullRequestReviewThread, updatePullRequestViewedFile } from './github-service' import type { GitHubMergeMethod, GitHubPullRequestCheckRerunOptions, GitHubPullRequestCreateOptions, GitHubPullRequestEditOptions, GitHubPullRequestMergeAutomationOptions, GitHubPullRequestMetadataOptions, GitHubPullRequestReviewOptions, GitHubPullRequestViewedFileOptions } from '../shared/github-types' export interface GhStatus { @@ -139,7 +139,7 @@ export function registerGhIpc(): void { // Pull-request operations for the Git Sidebar. ipcMain.handle('gh:prCreate', (_e, cwd: string, options: GitHubPullRequestCreateOptions) => { - try { return runGh(cwd, pullRequestCreateArgs(options)) } + try { return createPullRequest(cwd, options) } catch (error) { return { ok: false, output: '', error: error instanceof Error ? error.message : String(error) } } }) ipcMain.handle('gh:prMerge', (_e, cwd: string, num: number, method: GitHubMergeMethod, headCommitId?: string) => { diff --git a/src/main/git-conflict-diff.ts b/src/main/git-conflict-diff.ts new file mode 100644 index 0000000..0306f86 --- /dev/null +++ b/src/main/git-conflict-diff.ts @@ -0,0 +1,46 @@ +import { createPatch } from 'diff' +import type { GitConflictDiffResult } from '../shared/git-conflict-types' + +interface GitOutput { stdout: string; stderr: string } +export type GitConflictRunner = (args: string[]) => Promise + +const MAX_CONFLICT_SIDE_BYTES = 1024 * 1024 + +function errorMessage(error: unknown): string { + const value = error as Error & { stderr?: string } + return value.stderr?.trim() || value.message +} + +function boundedText(value: string, side: string): string { + if (Buffer.byteLength(value, 'utf8') > MAX_CONFLICT_SIDE_BYTES) throw new Error(`${side} conflict content exceeds the 1 MiB review limit`) + if (value.includes('\0')) throw new Error('Binary conflicts cannot be rendered as a text diff') + return value +} + +/** Reads Git's stage-2 (ours) and stage-3 (theirs) blobs for one observed conflict. */ +export async function getGitConflictDiff(fileInput: string, run: GitConflictRunner): Promise { + const file = fileInput.trim() + if (!file || file.length > 4096 || file.includes('\0') || /[\r\n]/.test(file)) return { ok: false, patch: '', oursAvailable: false, theirsAvailable: false, error: 'Invalid conflict path' } + try { + await run(['rev-parse', '-q', '--verify', 'MERGE_HEAD']) + const unresolved = await run(['diff', '--name-only', '--diff-filter=U', '--', file]) + if (!unresolved.stdout.split(/\r?\n/).includes(file)) return { ok: false, patch: '', oursAvailable: false, theirsAvailable: false, error: `${file} is no longer an unresolved merge conflict` } + + const readSide = async (stage: 2 | 3): Promise => { + let value: string + try { value = (await run(['show', `:${stage}:${file}`])).stdout } + catch { return null } + return boundedText(value, stage === 2 ? 'Ours' : 'Theirs') + } + const [ours, theirs] = await Promise.all([readSide(2), readSide(3)]) + if (ours == null && theirs == null) return { ok: false, patch: '', oursAvailable: false, theirsAvailable: false, error: `Git did not return either side of ${file}` } + + const raw = createPatch(file, ours ?? '', theirs ?? '', 'ours (PR head)', 'theirs (base)', { context: 3 }) + const headers = raw.indexOf('--- ') + const body = headers >= 0 ? raw.slice(headers) : raw + const patch = `diff --git a/${file} b/${file}\n${body.replace(`--- ${file}`, `--- a/${file}`).replace(`+++ ${file}`, `+++ b/${file}`)}` + return { ok: true, patch, oursAvailable: ours != null, theirsAvailable: theirs != null } + } catch (error) { + return { ok: false, patch: '', oursAvailable: false, theirsAvailable: false, error: errorMessage(error) } + } +} diff --git a/src/main/git-service.test.ts b/src/main/git-service.test.ts index f16478f..82e586e 100644 --- a/src/main/git-service.test.ts +++ b/src/main/git-service.test.ts @@ -72,7 +72,14 @@ describe('GitService', () => { try { execFileSync('git', ['merge', 'base'], { cwd: root, stdio: 'pipe' }) } catch { /* expected conflict */ } expect(await service.status(root)).toMatchObject({ ok: true, mergeInProgress: true }) + const evidence = await service.conflictDiff(root, 'README.md') + expect(evidence).toMatchObject({ ok: true, oursAvailable: true, theirsAvailable: true }) + expect(evidence.patch).toContain('diff --git a/README.md b/README.md') + expect(evidence.patch).toContain('ours (PR head)') + expect(evidence.patch).toContain('theirs (base)') + expect((evidence.patch.match(/^diff --git /gm) ?? [])).toHaveLength(1) expect(await service.resolveConflict(root, 'README.md', 'ours')).toEqual({ ok: true }) + expect(await service.conflictDiff(root, 'README.md')).toMatchObject({ ok: false, error: expect.stringContaining('no longer an unresolved') }) expect(await service.status(root)).toMatchObject({ ok: true, mergeInProgress: true, unstaged: [] }) expect(await service.mergeContinue(root)).toMatchObject({ ok: true }) expect(await service.status(root)).toMatchObject({ ok: true, mergeInProgress: false }) diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 065e2af..dbe08a7 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -2,6 +2,7 @@ import { execFile } from 'child_process' import { parseLog, parseStatus } from './git-porcelain-parse' import { unstagePaths } from './git-unstage' import { discardPath } from './git-discard' +import { getGitConflictDiff } from './git-conflict-diff' interface GitResult { stdout: string; stderr: string } @@ -53,6 +54,10 @@ export class GitService { } catch (error) { return { error: this.message(error) } } } + async conflictDiff(cwd: string, path: string) { + return getGitConflictDiff(path, args => this.run(cwd, args)) + } + async changesVsRef(cwd: string, ref: string) { if (!validRef(ref)) return { error: 'invalid comparison branch' } try { diff --git a/src/main/git.ts b/src/main/git.ts index 37f7ccb..fcadf9a 100644 --- a/src/main/git.ts +++ b/src/main/git.ts @@ -7,6 +7,7 @@ import os from 'os' import { formatRemoteRoot, isRemoteRoot, parseRemoteTarget } from './remote/ssh-target' import { execRemote } from './remote/ssh-pool' import { parseStatus, parseLog, isSigningFailure, isMergeConflictOutput, type GitStatusFile } from './git-porcelain-parse' +import { getGitConflictDiff } from './git-conflict-diff' import { mergeDelegatedBranch } from './delegated-merge' import { unstagePaths } from './git-unstage' import { @@ -379,6 +380,9 @@ export function registerGitIpc(): void { } }) + ipcMain.handle('git:conflictDiff', (_e, cwd: string, path: string) => + getGitConflictDiff(path, args => runGit(cwd, args))) + // Files changed on this checkout relative to a base ref — committed work // included. `git diff --name-status ` compares the ref to the working // tree (so committed + unstaged tracked changes both show), and untracked diff --git a/src/main/github-service.test.ts b/src/main/github-service.test.ts index 0109b21..2c381a7 100644 --- a/src/main/github-service.test.ts +++ b/src/main/github-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { getGhStatus, getGitHubAvatar, getGitHubStatus, getPullRequestCatalogue, getPullRequestCheckLog, getPullRequestChecksContext, getPullRequestCreateContext, getPullRequestDetail, getPullRequestDiff, getPullRequestManagementContext, getPullRequestReviewContext, preparePullRequestConflictResolution, pullRequestActionArgs, pullRequestCommentArgs, pullRequestCreateArgs, pullRequestEditArgs, pullRequestMergeArgs, pullRequestMetadataArgs, pullRequestReviewArgs, pullRequestReviewThreadArgs, pullRequestViewedFileArgs, rerunPullRequestCheck, submitPullRequestReview, updatePullRequestMergeAutomation, updatePullRequestReviewThread, updatePullRequestViewedFile, type GitHubCommandRunner, type GitHubInputCommandRunner } from './github-service' +import { createPullRequest, getGhStatus, getGitHubAvatar, getGitHubStatus, getPullRequestCatalogue, getPullRequestCheckLog, getPullRequestChecksContext, getPullRequestCreateContext, getPullRequestDetail, getPullRequestDiff, getPullRequestManagementContext, getPullRequestReviewContext, preparePullRequestConflictResolution, pullRequestActionArgs, pullRequestCommentArgs, pullRequestCreateArgs, pullRequestEditArgs, pullRequestMergeArgs, pullRequestMetadataArgs, pullRequestReviewArgs, pullRequestReviewThreadArgs, pullRequestViewedFileArgs, rerunPullRequestCheck, submitPullRequestReview, updatePullRequestMergeAutomation, updatePullRequestReviewThread, updatePullRequestViewedFile, type GitHubCommandRunner, type GitHubInputCommandRunner } from './github-service' function result(stdout = '', status = 0, stderr = '') { return { status, stdout, stderr } @@ -70,6 +70,7 @@ describe('non-blocking GitHub status', () => { expect(pullRequestCreateArgs({ title: ' Fix CI ', body: ' Verified ', base: 'dev', draft: true })).toEqual([ 'pr', 'create', '--title', 'Fix CI', '--base', 'dev', '--body', 'Verified', '--draft', ]) + expect(pullRequestCreateArgs({ title: 'Selected fix', base: 'main', draft: false, selectedBranch: 'pr/selected-fix' })).toContain('pr/selected-fix') expect(pullRequestMergeArgs(7, 'merge')).toEqual(['pr', 'merge', '7', '--merge']) expect(pullRequestMergeArgs(7, 'squash')).toEqual(['pr', 'merge', '7', '--squash']) expect(pullRequestMergeArgs(7, 'rebase')).toEqual(['pr', 'merge', '7', '--rebase']) @@ -160,6 +161,9 @@ describe('non-blocking GitHub status', () => { checks: [{ id: 'CR_1', name: 'test', suiteName: 'CI', isRequired: true, runId: 11, runAttempt: 2, jobId: 22, steps: [{ name: 'npm test', conclusion: 'FAILURE' }], annotations: [{ path: 'src/a.ts', startLine: 17, endLine: 18, message: 'Expected true' }] }], }) + expect(run).toHaveBeenCalledWith('gh', expect.arrayContaining([ + expect.stringContaining('isRequired(pullRequestNumber:$number)'), + ]), '/repo') const logRun: GitHubCommandRunner = vi.fn(async (_command, args) => { if (args[0] === 'repo') return result('o/r\n') @@ -248,15 +252,39 @@ describe('non-blocking GitHub status', () => { if (args[0] === 'diff') return result('src/a.ts\nsrc/b.ts\n') if (args[0] === 'merge-base') return result('abc123\n') if (args[0] === 'merge-tree') return result('merged cleanly\n') + if (args[0] === 'log') return result('0123456789012345678901234567890123456789\x1fFix one\x1fCJ\x1f2026-09-03T12:00:00Z\n') return result('', 1) }) await expect(getPullRequestCreateContext('/repo', 'main', run)).resolves.toEqual({ head: 'feature', base: 'main', ahead: 4, behind: 2, changedFiles: 2, mergeStatus: 'clean', + commits: [{ oid: '0123456789012345678901234567890123456789', title: 'Fix one', author: 'CJ', committedAt: '2026-09-03T12:00:00Z' }], }) expect(run).toHaveBeenCalledWith('git', ['merge-tree', 'abc123', 'main', 'HEAD'], '/repo') }) + it('creates a selected-commit PR from an isolated latest-base worktree', async () => { + const oid = '0123456789012345678901234567890123456789' + const run: GitHubCommandRunner = vi.fn(async (command, args) => { + if (command === 'gh') return result('https://github.com/crewcode/app/pull/8\n') + if (args[0] === 'fetch') return result('fetched\n') + if (args[0] === 'rev-list') return result(`${oid}\n`) + if (args[0] === 'show-ref') return result('', 1) + if (args[0] === 'ls-remote') return result('', 2) + return result('ok\n') + }) + + await expect(createPullRequest('/repo', { + title: 'Selected fix', base: 'main', draft: false, + selectedCommits: [oid], selectedBranch: 'pr/selected-fix', + }, run)).resolves.toEqual({ ok: true, output: 'https://github.com/crewcode/app/pull/8' }) + expect(run).toHaveBeenCalledWith('git', ['fetch', 'origin', 'main'], '/repo') + expect(run).toHaveBeenCalledWith('git', ['rev-list', '--reverse', 'origin/main..HEAD'], '/repo') + expect(run).toHaveBeenCalledWith('git', ['cherry-pick', oid], expect.stringContaining('crewcode-pr-')) + expect(run).toHaveBeenCalledWith('git', ['push', '--set-upstream', 'origin', 'pr/selected-fix'], expect.stringContaining('crewcode-pr-')) + expect(run).toHaveBeenCalledWith('gh', expect.arrayContaining(['pr', 'create', '--head', 'pr/selected-fix']), '/repo') + }) + it('starts PR conflict resolution only from the clean expected head worktree', async () => { const run: GitHubCommandRunner = vi.fn(async (_command, args) => { const key = args.join(' ') diff --git a/src/main/github-service.ts b/src/main/github-service.ts index fb502e9..3bfcc9b 100644 --- a/src/main/github-service.ts +++ b/src/main/github-service.ts @@ -1,4 +1,7 @@ import { execFile, spawn, spawnSync } from 'child_process' +import { promises as fsp } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import type { GitHubMergeMethod, GitHubPullRequestCommit, @@ -263,10 +266,62 @@ export function pullRequestCreateArgs(options: GitHubPullRequestCreateOptions): if (!title) throw new Error('Pull request title is required') if (!base) throw new Error('Base branch is required') const args = ['pr', 'create', '--title', title, '--base', base, '--body', options.body?.trim() ?? ''] + if (options.selectedBranch?.trim()) args.push('--head', options.selectedBranch.trim()) if (options.draft) args.push('--draft') return args } +export async function createPullRequest( + cwd: string, + options: GitHubPullRequestCreateOptions, + runCommand: GitHubCommandRunner = runGitHubCommand, +): Promise<{ ok: boolean; output: string; error?: string }> { + if (!options.selectedCommits?.length) return runGh(cwd, pullRequestCreateArgs(options), runCommand) + if (/^ssh:\/\//i.test(cwd)) return { ok: false, output: '', error: 'Selected-commit pull requests are not available for SSH workspaces yet' } + const selectedBranch = options.selectedBranch?.trim() ?? '' + if (!validGitRef(selectedBranch) || selectedBranch === options.base.trim()) return { ok: false, output: '', error: 'A valid new source branch is required for selected commits' } + const selected = [...new Set(options.selectedCommits.map(oid => oid.trim()))] + if (selected.length > 100 || selected.some(oid => !/^[0-9a-f]{40}$/i.test(oid))) return { ok: false, output: '', error: 'Selected commits must contain at most 100 full commit IDs' } + + const fetch = await runCommand('git', ['fetch', 'origin', options.base.trim()], cwd) + if (fetch.status !== 0) return { ok: false, output: '', error: (fetch.stderr || fetch.stdout).trim() || `Could not fetch origin/${options.base.trim()}` } + const available = await runCommand('git', ['rev-list', '--reverse', `origin/${options.base.trim()}..HEAD`], cwd) + if (available.status !== 0) return { ok: false, output: '', error: (available.stderr || available.stdout).trim() || 'Could not validate selected commits' } + const orderedAvailable = available.stdout.split(/\r?\n/).filter(Boolean) + if (selected.some(oid => !orderedAvailable.includes(oid))) return { ok: false, output: '', error: 'A selected commit is no longer in the current base-to-head range' } + const ordered = orderedAvailable.filter(oid => selected.includes(oid)) + + const localBranch = await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/heads/${selectedBranch}`], cwd) + if (localBranch.status === 0) return { ok: false, output: '', error: `Branch ${selectedBranch} already exists locally` } + const remoteBranch = await runCommand('git', ['ls-remote', '--exit-code', '--heads', 'origin', selectedBranch], cwd) + if (remoteBranch.status === 0) return { ok: false, output: '', error: `Branch ${selectedBranch} already exists on origin` } + + const temporaryRoot = await fsp.mkdtemp(join(tmpdir(), 'crewcode-pr-')) + const temporaryWorktree = join(temporaryRoot, 'worktree') + let branchCreated = false + let pushed = false + try { + const add = await runCommand('git', ['worktree', 'add', '--detach', temporaryWorktree, `origin/${options.base.trim()}`], cwd) + if (add.status !== 0) return { ok: false, output: add.stdout.trim(), error: (add.stderr || add.stdout).trim() || 'Could not create the isolated PR worktree' } + const createBranchResult = await runCommand('git', ['switch', '-c', selectedBranch], temporaryWorktree) + if (createBranchResult.status !== 0) return { ok: false, output: createBranchResult.stdout.trim(), error: (createBranchResult.stderr || createBranchResult.stdout).trim() || `Could not create ${selectedBranch}` } + branchCreated = true + const cherryPick = await runCommand('git', ['cherry-pick', ...ordered], temporaryWorktree) + if (cherryPick.status !== 0) { + await runCommand('git', ['cherry-pick', '--abort'], temporaryWorktree) + return { ok: false, output: (cherryPick.stdout + cherryPick.stderr).trim(), error: 'The selected commits conflict with the latest base. No branch was pushed.' } + } + const push = await runCommand('git', ['push', '--set-upstream', 'origin', selectedBranch], temporaryWorktree) + if (push.status !== 0) return { ok: false, output: (push.stdout + push.stderr).trim(), error: (push.stderr || push.stdout).trim() || `Could not push ${selectedBranch}` } + pushed = true + return runGh(cwd, pullRequestCreateArgs({ ...options, selectedBranch }), runCommand) + } finally { + await runCommand('git', ['worktree', 'remove', '--force', temporaryWorktree], cwd) + await fsp.rm(temporaryRoot, { recursive: true, force: true }) + if (branchCreated && !pushed) await runCommand('git', ['branch', '-D', selectedBranch], cwd) + } +} + export function pullRequestMergeArgs(number: number, method: GitHubMergeMethod, headCommitId?: string): string[] { assertPullRequestNumber(number) if (!['merge', 'squash', 'rebase'].includes(method)) throw new Error('Unsupported pull request merge method') @@ -372,7 +427,7 @@ const CHECKS_CONTEXT_QUERY = `query CrewCodePullRequestChecks($owner:String!,$re statusCheckRollup { contexts(first:100) { nodes { __typename ... on CheckRun { - id databaseId name status conclusion isRequired detailsUrl permalink startedAt completedAt title summary text + id databaseId name status conclusion isRequired(pullRequestNumber:$number) detailsUrl permalink startedAt completedAt title summary text steps(first:100) { nodes { name number status conclusion startedAt completedAt } pageInfo { hasNextPage } } annotations(first:50) { nodes { annotationLevel path message title rawDetails blobUrl @@ -380,7 +435,7 @@ const CHECKS_CONTEXT_QUERY = `query CrewCodePullRequestChecks($owner:String!,$re } pageInfo { hasNextPage } } checkSuite { app { name } workflowRun { databaseId runAttempt url workflow { name } } } } - ... on StatusContext { id context state description isRequired targetUrl createdAt updatedAt } + ... on StatusContext { id context state description isRequired(pullRequestNumber:$number) targetUrl createdAt updatedAt } } } } } } @@ -798,11 +853,12 @@ export async function getPullRequestCreateContext( if (/^ssh:\/\//i.test(cwd)) return { error: 'Pull-request comparison is not available for SSH workspaces yet' } const base = baseInput.trim() if (!validGitRef(base)) return { error: 'Invalid base branch' } - const [headResult, countsResult, filesResult, mergeBaseResult] = await Promise.all([ + const [headResult, countsResult, filesResult, mergeBaseResult, commitsResult] = await Promise.all([ runCommand('git', ['branch', '--show-current'], cwd), runCommand('git', ['rev-list', '--left-right', '--count', `${base}...HEAD`], cwd), runCommand('git', ['diff', '--name-only', `${base}...HEAD`], cwd), runCommand('git', ['merge-base', base, 'HEAD'], cwd), + runCommand('git', ['log', '--reverse', '--max-count=100', '--format=%H%x1f%s%x1f%an%x1f%cI', `${base}..HEAD`], cwd), ]) if (countsResult.status !== 0) return { error: (countsResult.stderr || countsResult.stdout).trim() || `Could not compare HEAD with ${base}` } const [behindRaw = '0', aheadRaw = '0'] = countsResult.stdout.trim().split(/\s+/) @@ -820,6 +876,10 @@ export async function getPullRequestCreateContext( behind: Number.parseInt(behindRaw, 10) || 0, changedFiles: filesResult.status === 0 ? filesResult.stdout.split(/\r?\n/).filter(Boolean).length : 0, mergeStatus, + commits: commitsResult.status === 0 ? commitsResult.stdout.split(/\r?\n/).filter(Boolean).map(line => { + const [oid = '', title = '', author = '', committedAt = ''] = line.split('\x1f') + return { oid, title, author, committedAt } + }).filter(commit => /^[0-9a-f]{40}$/i.test(commit.oid)) : [], } } diff --git a/src/main/headless-agent-resolver.test.ts b/src/main/headless-agent-resolver.test.ts index 7b6aef4..4dd0426 100644 --- a/src/main/headless-agent-resolver.test.ts +++ b/src/main/headless-agent-resolver.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' afterEach(() => { vi.doUnmock('fs') @@ -8,8 +9,9 @@ afterEach(() => { describe('headless agent registry', () => { it('discovers Codex from Bun cache installs without a synchronous shell probe', async () => { + const expectedPath = join('/home/test', '.cache', '.bun', 'bin', 'codex') const access = vi.fn(async (candidate: string) => { - if (candidate === '/home/test/.cache/.bun/bin/codex') return + if (candidate === expectedPath) return throw new Error('missing') }) vi.doMock('os', async importOriginal => ({ @@ -28,7 +30,7 @@ describe('headless agent registry', () => { const registry = await headlessAgentRegistry() expect(registry.find(agent => agent.id === 'codex')).toMatchObject({ available: true, - path: '/home/test/.cache/.bun/bin/codex', + path: expectedPath, }) }) diff --git a/src/main/hub-brain-relay.ts b/src/main/hub-brain-relay.ts index ab9c119..fa15195 100644 --- a/src/main/hub-brain-relay.ts +++ b/src/main/hub-brain-relay.ts @@ -21,7 +21,7 @@ import { startRemoteAccessServer } from './remote-access-server' const READ_METHODS = new Set([ 'workspaces.list', 'workspaces.inspectPath', 'fs.readDir', 'fs.readFile', 'fs.readDataUrl', 'fs.listFiles', - 'git.status', 'git.diff', 'git.log', 'git.branches', 'git.remotes', 'worktrees.list', + 'git.status', 'git.diff', 'git.conflictDiff', 'git.log', 'git.branches', 'git.remotes', 'worktrees.list', 'github.status', 'github.prCreateContext', 'github.prCatalogue', 'github.prDetail', 'github.prDiff', 'github.prReviewContext', 'github.prManagementContext', 'github.prChecksContext', 'github.prCheckLog', 'github.avatar', 'gh.status', 'continuity.get', ]) diff --git a/src/main/hub-relay.test.ts b/src/main/hub-relay.test.ts index 403c782..73b42bc 100644 --- a/src/main/hub-relay.test.ts +++ b/src/main/hub-relay.test.ts @@ -226,6 +226,7 @@ async function encryptedRpc(input: { describe('Brain-local RPC authorization', () => { it('classifies workspace, terminal, and agent methods without a permissive fallback', () => { expect(brainScopeForMethod('workspaces.list')).toBe('workspace:read') + expect(brainScopeForMethod('git.conflictDiff')).toBe('workspace:read') expect(brainScopeForMethod('fs.writeFile')).toBe('workspace:write') expect(brainScopeForMethod('pty.create')).toBe('terminal') expect(brainScopeForMethod('attachments.chunk')).toBe('workspace:write') diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index 41c2f16..8299cf1 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -25,7 +25,7 @@ import { PtyService } from './pty-service' import { AgentBridgeService, webConversationKey, type AgentPathResolver } from './agents/bridge-service' import { headlessAgentRegistry, listHeadlessAgentModels } from './headless-agent-resolver' import { readMcpConfig } from './mcp-config-service' -import { getGhStatus, getGitHubAvatar, getGitHubStatus, getPullRequestCatalogue, getPullRequestCheckLog, getPullRequestChecksContext, getPullRequestCreateContext, getPullRequestDetail, getPullRequestDiff, getPullRequestManagementContext, getPullRequestReviewContext, preparePullRequestConflictResolution, pullRequestActionArgs, pullRequestCommentArgs, pullRequestCreateArgs, pullRequestEditArgs, pullRequestMergeArgs, pullRequestMetadataArgs, pullRequestReviewArgs, rerunPullRequestCheck, runGh, submitPullRequestReview, updatePullRequestMergeAutomation, updatePullRequestReviewThread, updatePullRequestViewedFile } from './github-service' +import { createPullRequest, getGhStatus, getGitHubAvatar, getGitHubStatus, getPullRequestCatalogue, getPullRequestCheckLog, getPullRequestChecksContext, getPullRequestCreateContext, getPullRequestDetail, getPullRequestDiff, getPullRequestManagementContext, getPullRequestReviewContext, preparePullRequestConflictResolution, pullRequestActionArgs, pullRequestCommentArgs, pullRequestEditArgs, pullRequestMergeArgs, pullRequestMetadataArgs, pullRequestReviewArgs, rerunPullRequestCheck, runGh, submitPullRequestReview, updatePullRequestMergeAutomation, updatePullRequestReviewThread, updatePullRequestViewedFile } from './github-service' import type { GitHubMergeMethod, GitHubPullRequestCheckRerunOptions, GitHubPullRequestCreateOptions, GitHubPullRequestEditOptions, GitHubPullRequestMergeAutomationOptions, GitHubPullRequestMetadataOptions, GitHubPullRequestReviewOptions, GitHubPullRequestViewedFileOptions } from '../shared/github-types' import { RemoteGhService } from './remote-gh-service' import { RemoteEditorLanguageServer } from './remote-editor-language-server' @@ -443,6 +443,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['git.unstage', params => gitService.unstage(registeredRoot({ root: params.cwd }), Array.isArray(params.paths) ? params.paths.map(String) : [])], ['git.discard', params => gitService.discard(registeredRoot({ root: params.cwd }), String(params.path ?? ''))], ['git.diff', params => gitService.diff(registeredRoot({ root: params.cwd }), String(params.path ?? ''), params.staged === true)], + ['git.conflictDiff', params => gitService.conflictDiff(registeredRoot({ root: params.cwd }), String(params.path ?? ''))], ['git.changesVsRef', params => gitService.changesVsRef(registeredRoot({ root: params.cwd }), String(params.ref ?? ''))], ['git.diffVsRef', params => gitService.diffVsRef(registeredRoot({ root: params.cwd }), String(params.ref ?? ''), String(params.path ?? ''))], ['git.log', params => gitService.log(registeredRoot({ root: params.cwd }), Number(params.limit ?? 20))], @@ -465,7 +466,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions ['github.avatar', params => getGitHubAvatar(registeredRoot({ root: params.cwd }), String(params.login ?? ''))], ['gh.status', () => getGhStatus()], ['gh.prCreate', params => { - try { return runGh(registeredRoot({ root: params.cwd }), pullRequestCreateArgs(params.options as GitHubPullRequestCreateOptions)) } + try { return createPullRequest(registeredRoot({ root: params.cwd }), params.options as GitHubPullRequestCreateOptions) } catch (error) { return { ok: false, output: '', error: error instanceof Error ? error.message : String(error) } } }], ['gh.prMerge', params => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 4859aab..1b56827 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -447,6 +447,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('git:discard', cwd, path), gitDiff: (cwd: string, path: string, staged: boolean) => ipcRenderer.invoke('git:diff', cwd, path, staged), + gitConflictDiff: (cwd: string, path: string) => + ipcRenderer.invoke('git:conflictDiff', cwd, path), gitChangesVsRef: (cwd: string, ref: string) => ipcRenderer.invoke('git:changesVsRef', cwd, ref), gitDiffVsRef: (cwd: string, ref: string, path: string) => diff --git a/src/renderer/src/components/git/PullRequestBrowser.tsx b/src/renderer/src/components/git/PullRequestBrowser.tsx index 274a5a4..8324255 100644 --- a/src/renderer/src/components/git/PullRequestBrowser.tsx +++ b/src/renderer/src/components/git/PullRequestBrowser.tsx @@ -25,9 +25,11 @@ import { Icon } from '../ui/Icon' import { parsePullRequestBodySections } from './pull-request-body' import { splitPullRequestPatch } from './pull-request-diff' import type { GitActionOutcome } from './git-state' +import type { GitStatus } from '../../types' +import type { GitConflictDiffResult } from '../../../../shared/git-conflict-types' type PullRequestFilter = 'all' | 'open' | 'closed' | 'assigned' -type PullRequestBrowserTab = 'overview' | 'timeline' | 'changes' | 'checks' +type PullRequestBrowserTab = 'overview' | 'timeline' | 'changes' | 'checks' | 'conflicts' type PullRequestReviewFilter = 'any' | 'requested' | 'requested-to-you' | 'approved' | 'changes-requested' | 'review-required' interface PullRequestBrowserMemory { @@ -146,6 +148,13 @@ export function PullRequestBrowser({ const [mergeMethod, setMergeMethod] = useState('squash') const [confirmAction, setConfirmAction] = useState<'merge' | 'auto' | 'disable-auto' | 'queue' | 'close' | 'reopen' | 'draft' | 'resolve' | null>(null) const [conflictPreparation, setConflictPreparation] = useState(null) + const [localConflictStatus, setLocalConflictStatus] = useState(null) + const [localConflictError, setLocalConflictError] = useState('') + const [selectedConflictPath, setSelectedConflictPath] = useState('') + const [conflictFileText, setConflictFileText] = useState('') + const [conflictFileLoading, setConflictFileLoading] = useState(false) + const [conflictDiff, setConflictDiff] = useState(null) + const [conflictOperation, setConflictOperation] = useState('') const [editingDetails, setEditingDetails] = useState(false) const [editTitle, setEditTitle] = useState('') const [editBody, setEditBody] = useState('') @@ -190,6 +199,11 @@ export function PullRequestBrowser({ setNotice(null) setConfirmAction(null) setConflictPreparation(null) + setLocalConflictStatus(null) + setLocalConflictError('') + setSelectedConflictPath('') + setConflictFileText('') + setConflictDiff(null) void loadCatalogue() }, [open, repoPath, loadCatalogue]) @@ -236,6 +250,11 @@ export function PullRequestBrowser({ setReviewBody('') setConfirmAction(null) setConflictPreparation(null) + setLocalConflictStatus(null) + setLocalConflictError('') + setSelectedConflictPath('') + setConflictFileText('') + setConflictDiff(null) setEditingDetails(false) setManagementOpen(false) setManagementError('') @@ -382,9 +401,51 @@ export function PullRequestBrowser({ if (!visibleItems.some(item => item.number === selectedNumber)) setSelectedNumber(visibleItems[0].number) }, [mutation, selectedNumber, visibleItems]) - if (!open) return null const selected = catalogue?.items.find(item => item.number === selectedNumber) ?? null const detail = selectedNumber == null ? null : detailByNumber[selectedNumber] ?? null + + const loadLocalConflictStatus = useCallback(async () => { + if (!detail) return null + setLocalConflictError('') + try { + const status = await getCrewCodeClient().gitStatus(repoPath) + if (status.error) throw new Error(status.error) + if (status.branch !== detail.head) throw new Error(`Conflict resolution for #${detail.number} is bound to ${detail.head}, but this worktree is on ${status.branch}.`) + setLocalConflictStatus(status) + const paths = [...new Set([...status.staged, ...status.unstaged].filter(file => file.status === 'U').map(file => file.path))] + setSelectedConflictPath(current => current && paths.includes(current) ? current : paths[0] ?? '') + return status + } catch (statusError) { + setLocalConflictError(statusError instanceof Error ? statusError.message : String(statusError)) + return null + } + }, [detail, repoPath]) + + useEffect(() => { + if (!open || tab !== 'conflicts' || !detail) return + void loadLocalConflictStatus() + }, [detail, loadLocalConflictStatus, open, tab]) + + useEffect(() => { + if (!selectedConflictPath || tab !== 'conflicts') { setConflictFileText(''); setConflictDiff(null); return } + let cancelled = false + setConflictFileLoading(true) + void Promise.all([ + getCrewCodeClient().fsReadFile(repoPath, selectedConflictPath), + getCrewCodeClient().gitConflictDiff(repoPath, selectedConflictPath), + ]).then(([result, diff]) => { + if (cancelled) return + if (result.error || typeof result.text !== 'string') throw new Error(result.error || `Could not read ${selectedConflictPath}`) + setConflictFileText(result.text) + setConflictDiff(diff) + if (!diff.ok) setLocalConflictError(diff.error || `Could not load conflict evidence for ${selectedConflictPath}`) + }).catch(readError => { + if (!cancelled) setLocalConflictError(readError instanceof Error ? readError.message : String(readError)) + }).finally(() => { if (!cancelled) setConflictFileLoading(false) }) + return () => { cancelled = true } + }, [repoPath, selectedConflictPath, tab]) + + if (!open) return null const author = detail?.author || selected?.author || 'unknown' const authorAvatar = avatarByLogin[author] const createdAt = detail?.createdAt || selected?.createdAt || '' @@ -419,6 +480,10 @@ export function PullRequestBrowser({ const showConflictFlow = detail?.mergeStateStatus === 'DIRTY' || /not mergeable|conflict|cleanly created/i.test(notice?.text ?? '') || conflictPreparation !== null + const localConflictPaths = [...new Set([ + ...(localConflictStatus?.staged ?? []), + ...(localConflictStatus?.unstaged ?? []), + ].filter(file => file.status === 'U').map(file => file.path))] const filterChoices = { authors: [...new Set((catalogue?.items ?? []).map(item => item.author).filter(Boolean))].sort(), labels: [...new Set((catalogue?.items ?? []).flatMap(item => item.labels))].sort(), @@ -705,7 +770,10 @@ export function PullRequestBrowser({ setMutation({ kind: 'Load merge requirements', number: targetNumber }) try { const evidence = await loadChecksContext(targetNumber, true) - if (!evidence) return + if (!evidence) { + setNotice({ kind: 'error', text: `Could not load current merge requirements for #${targetNumber}. Open Checks for the GitHub error, then retry.` }) + return + } if (evidence.headCommitId !== targetHead) { await refreshSelectedEvidence(targetNumber) setNotice({ kind: 'error', text: `The head commit for #${targetNumber} changed while merge requirements loaded. Review the refreshed evidence before continuing.` }) @@ -717,6 +785,63 @@ export function PullRequestBrowser({ } } + const runConflictOperation = async (label: string, action: () => Promise<{ ok?: boolean; error?: string }>) => { + if (!detail || conflictOperation || actionLocked) return false + setConflictOperation(label) + setLocalConflictError('') + try { + const before = await getCrewCodeClient().gitStatus(repoPath) + if (before.error) throw new Error(before.error) + if (before.branch !== detail.head) throw new Error(`This operation is locked to ${detail.head}; the worktree is on ${before.branch}.`) + const result = await action() + if (!result.ok) throw new Error(result.error || `${label} did not complete`) + await loadLocalConflictStatus() + setNotice({ kind: 'ok', text: `${label} completed for #${detail.number}.` }) + return true + } catch (operationError) { + setLocalConflictError(operationError instanceof Error ? operationError.message : String(operationError)) + return false + } finally { + setConflictOperation('') + } + } + + const saveResolvedConflict = async () => { + if (!selectedConflictPath) return + if (/^(?:<<<<<<<|=======|>>>>>>>)(?: |$)/m.test(conflictFileText)) { + setLocalConflictError('Remove every Git conflict marker before marking this file resolved.') + return + } + await runConflictOperation('Save and mark resolved', async () => { + const write = await getCrewCodeClient().fsWriteFile(repoPath, selectedConflictPath, conflictFileText) + if (write.error) return { ok: false, error: write.error } + return getCrewCodeClient().gitStage(repoPath, [selectedConflictPath]) + }) + } + + const pushResolvedHead = async () => { + if (!detail) return + const pushed = await runConflictOperation('Push resolved head', () => getCrewCodeClient().gitPush(repoPath)) + if (pushed) { + try { + await refreshSelectedEvidence(detail.number) + await loadChecksContext(detail.number, true) + setConflictPreparation(null) + setTab('checks') + } catch (refreshError) { + setNotice({ kind: 'error', text: `Push completed, but GitHub evidence could not be refreshed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}` }) + } + } + } + + const abortLocalMerge = async () => { + const aborted = await runConflictOperation('Abort merge', () => getCrewCodeClient().gitMergeAbort(repoPath)) + if (aborted) { + setConflictPreparation(null) + setTab('overview') + } + } + const prepareConflicts = async () => { if (!selected || !detail || !onPrepareConflicts || actionLocked) return const targetNumber = selected.number @@ -728,8 +853,10 @@ export function PullRequestBrowser({ const result = await onPrepareConflicts(targetHead, targetBase) setConflictPreparation(result) if (!result.ok) throw new Error(result.error || 'Could not start local conflict resolution') - if (result.status === 'conflicts') setNotice({ kind: 'ok', text: `Local merge for #${targetNumber} started with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? '' : 's'}. Return to Git Workspace to resolve them.` }) - else if (result.status === 'ready-to-continue') setNotice({ kind: 'ok', text: `All conflicts for #${targetNumber} are resolved. Return to Git Workspace and continue the merge.` }) + await loadLocalConflictStatus() + setTab('conflicts') + if (result.status === 'conflicts') setNotice({ kind: 'ok', text: `Local merge for #${targetNumber} started with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? '' : 's'}. Resolve them here, then continue and push.` }) + else if (result.status === 'ready-to-continue') setNotice({ kind: 'ok', text: `All conflicts for #${targetNumber} are resolved. Continue the merge here, then push.` }) else setNotice({ kind: 'ok', text: `Merged ${targetBase} into ${targetHead} locally. Push ${targetHead} to update #${targetNumber}.` }) } catch (mutationError) { setNotice({ kind: 'error', text: mutationError instanceof Error ? mutationError.message : String(mutationError) }) @@ -790,9 +917,9 @@ export function PullRequestBrowser({

Merge {detail?.head ?? selected.head} into {detail?.base ?? selected.base}

@@ -903,6 +1030,32 @@ export function PullRequestBrowser({
} } + + {tab === 'conflicts' && detail &&
+ +
+
Conflict workspace

{selectedConflictPath || (localConflictStatus?.mergeInProgress ? 'Ready to continue' : 'Ready to push')}

{detail.head} ← origin/{detail.base}
+ {localConflictError &&
{localConflictError}
} + {!localConflictStatus ?
Loading local merge state…
: selectedConflictPath ? <> +
+
+
Ours{detail.head}
+
Theirs{detail.base}
+
+ {conflictFileLoading ?
Loading ours and theirs…
: conflictDiff?.ok ? :
The conflict comparison is unavailable. You can still edit the resolution result below.
} +
+
Resolution resultRemove every conflict marker, then save this file.
{conflictFileLoading ?
Loading editable result…
: