From 8a8960e865e3a574aaa8ea6a2d82691f9650feb3 Mon Sep 17 00:00:00 2001 From: nonlooped Date: Fri, 31 Jul 2026 11:40:01 +0300 Subject: [PATCH 1/2] feat: add commit and pull request authoring --- apps/desktop/PRODUCT.md | 2 +- apps/desktop/src/main/git.test.ts | 24 +++- apps/desktop/src/main/git.ts | 72 +++++++++- apps/desktop/src/main/ipc.ts | 18 ++- .../src/renderer/components/CommitDialog.tsx | 131 ++++++++++++++++++ .../src/renderer/components/ContextPane.tsx | 38 ++++- apps/desktop/src/shared/pi-types.ts | 5 + apps/desktop/src/shared/rpc-schema.ts | 8 ++ 8 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/renderer/components/CommitDialog.tsx diff --git a/apps/desktop/PRODUCT.md b/apps/desktop/PRODUCT.md index 96610b2..1f60b61 100644 --- a/apps/desktop/PRODUCT.md +++ b/apps/desktop/PRODUCT.md @@ -62,7 +62,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains - Pi session files are the durable conversation source of truth. NativePi persists only pinned projects and chats, the last project and chat, text drafts, favorite models, pane state, and its own interface preferences. - Agent configuration is Pi's. NativePi reads and writes it through Pi's own settings manager at user scope, so a change made here is a change the Pi command line sees; NativePi never writes Pi's configuration format itself, and exposes only the settings that have meaning in a desktop window. Project-scope overrides remain the Pi command line's business. - Authentication is Pi-backed. Credentials are never stored in NativePi renderer persistence or its state file. -- Git mutation is deliberately narrow: branch checkout and creation require a clean worktree, and worktrees may be added. NativePi does not stage, commit, merge, rebase, discard changes, create checkpoints, roll back work, or rewrite history. +- Git mutation is deliberately narrow: branch checkout and creation require a clean worktree, and worktrees may be added. NativePi can stage individual hunks, create commits, push the current branch, and open a GitHub pull request through `gh`; Pi drafts commit wording when asked. NativePi does not merge, rebase, discard changes, create checkpoints, roll back work, or rewrite history. - Normal Pi extensions run unchanged. Optional graphical extensions contribute only through controlled NativePi UI slots and are trusted code, not sandboxed code. - Terminal extension components are drawn by Pi and displayed, not reimplemented: NativePi runs the component in the Pi process and shows what it draws, so it looks as its author wrote it rather than as NativePi would have styled it. Two parts of Pi's terminal UI have no equivalent here and keep Pi's documented no-op: raw terminal input, and replacing the input editor, which in this window is the composer. - NativePi has no cloud sync, collaboration, remote projects, SSH launching, configurable keybindings, product accounts, paid features, or telemetry. diff --git a/apps/desktop/src/main/git.test.ts b/apps/desktop/src/main/git.test.ts index ee642fb..431f62c 100644 --- a/apps/desktop/src/main/git.test.ts +++ b/apps/desktop/src/main/git.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { gitAddWorktree, gitBranches, gitCheckout } from "./git.ts"; +import { gitAddWorktree, gitBranches, gitCheckout, gitCommit, gitHunks, gitStageHunk, gitStatus } from "./git.ts"; /** * These run against a real repository rather than a mocked `git`. @@ -103,3 +103,25 @@ test("a checkout Git refuses returns its reason instead of claiming success", as expect(res.ok).toBe(false); expect(res.error).toBeTruthy(); }); + +test("a selected hunk stages without staging its neighbour", async () => { + const dir = await repo(); + await writeFile(path.join(dir, "a.txt"), "one\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\ntwenty\n", "utf8"); + execFileSync("git", ["add", "a.txt"], { cwd: dir }); + execFileSync("git", ["commit", "-m", "lines"], { cwd: dir }); + await writeFile(path.join(dir, "a.txt"), "first\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nb\n", "utf8"); + + const hunks = await gitHunks(dir, "a.txt", false); + expect(hunks.length).toBe(2); + expect(await gitStageHunk(dir, "a.txt", false, 0)).toEqual({ ok: true }); + + const status = await gitStatus(dir); + expect(status.files).toEqual([{ path: "a.txt", state: "modified", staged: true, unstaged: true }]); + expect(execFileSync("git", ["diff", "--cached"], { cwd: dir, encoding: "utf8" })).toContain("first"); + expect(execFileSync("git", ["diff"], { cwd: dir, encoding: "utf8" })).toContain("b"); +}); + +test("committing without staged changes is refused", async () => { + const dir = await repo(); + expect(await gitCommit(dir, "feat: nothing")).toEqual({ ok: false, error: "Stage at least one change before committing." }); +}); diff --git a/apps/desktop/src/main/git.ts b/apps/desktop/src/main/git.ts index 873fe50..653915e 100644 --- a/apps/desktop/src/main/git.ts +++ b/apps/desktop/src/main/git.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; -import type { GitBranch, GitDiff, GitStatus } from "../shared/pi-types.ts"; +import type { GitBranch, GitDiff, GitHunk, GitStatus } from "../shared/pi-types.ts"; function run(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> { @@ -18,6 +18,15 @@ function run(args: string[], cwd: string): Promise<{ stdout: string; stderr: str }); } +function runGh(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + execFile("gh", args, { cwd, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => { + const code = err && typeof (err as { code?: unknown }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ stdout, stderr, code }); + }); + }); +} + function labelFor(x: string, y: string): GitStatus["files"][number]["state"] { if (x === "?" && y === "?") return "untracked"; if (x === "A" || y === "A") return "added"; @@ -49,7 +58,7 @@ export async function gitStatus(projectDir: string): Promise { // A rename entry is followed by its original path in the next NUL field. if (x === "R" || y === "R") i++; if (!path) continue; - files.push({ path, state: labelFor(x, y), staged: x !== " " && x !== "?" }); + files.push({ path, state: labelFor(x, y), staged: x !== " " && x !== "?", unstaged: y !== " " || x === "?" }); } return { @@ -70,6 +79,65 @@ export async function gitDiff(projectDir: string, file: string, untracked: boole return { path: file, patch: res.stdout }; } +export async function gitHunks(projectDir: string, file: string, untracked: boolean): Promise { + const res = await run( + untracked + ? ["diff", "--no-color", "--unified=0", "--no-index", "--", "/dev/null", file] + : ["diff", "--no-color", "--unified=0", "--", file], + projectDir, + ); + const starts = [...res.stdout.matchAll(/^@@/gm)].map((match) => match.index ?? 0); + if (starts.length === 0) return []; + const prefix = res.stdout.slice(0, starts[0]); + return starts.map((start, index) => ({ + header: res.stdout.slice(start, res.stdout.indexOf("\n", start)).trim(), + patch: prefix + res.stdout.slice(start, starts[index + 1]), + })); +} + +export async function gitStageHunk( + projectDir: string, + file: string, + untracked: boolean, + hunk: number, +): Promise<{ ok: boolean; error?: string }> { + const hunks = await gitHunks(projectDir, file, untracked); + const selected = hunks[hunk]; + if (!selected) return { ok: false, error: "That change is no longer available. Refresh and try again." }; + const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve) => { + const child = execFile("git", ["apply", "--cached", "--unidiff-zero", "-"], { + cwd: projectDir, + maxBuffer: 32 * 1024 * 1024, + windowsHide: true, + }, (err, stdout, stderr) => { + const code = err && typeof (err as { code?: unknown }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ stdout, stderr, code }); + }); + child.stdin?.end(selected.patch); + }); + return result.code === 0 ? { ok: true } : { ok: false, error: failure(result) }; +} + +export async function gitCommit(projectDir: string, message: string): Promise<{ ok: boolean; error?: string }> { + const staged = await run(["diff", "--cached", "--quiet"], projectDir); + if (staged.code === 0) return { ok: false, error: "Stage at least one change before committing." }; + const result = await run(["commit", "-m", message], projectDir); + return result.code === 0 ? { ok: true } : { ok: false, error: failure(result) }; +} + +export async function gitPushAndCreatePr( + projectDir: string, + title: string, + body: string, +): Promise<{ ok: boolean; url?: string; error?: string }> { + const push = await run(["push", "-u", "origin", "HEAD"], projectDir); + if (push.code !== 0) return { ok: false, error: failure(push) }; + const base = await runGh(["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], projectDir); + if (base.code !== 0 || !base.stdout.trim()) return { ok: false, error: failure(base) }; + const pr = await runGh(["pr", "create", "--base", base.stdout.trim(), "--title", title, "--body", body], projectDir); + return pr.code === 0 ? { ok: true, url: pr.stdout.trim() } : { ok: false, error: failure(pr) }; +} + /** Git's own message is the useful one; ours would only be vaguer. */ function failure(res: { stdout: string; stderr: string }): string { return (res.stderr.trim() || res.stdout.trim() || "git failed").split("\n").slice(0, 4).join("\n"); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 2898140..8c8a29e 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -8,7 +8,7 @@ import type { PiMessage } from "./pi/protocol.ts"; import { deleteSession, listSessions, readSession, searchSessions, sessionMtime, watchProjectSessions, watchSessionFile } from "./sessions.ts"; import { loadState, saveState } from "./state.ts"; import * as auth from "./auth.ts"; -import { gitAddWorktree, gitBranches, gitCheckout, gitDiff, gitStatus } from "./git.ts"; +import { gitAddWorktree, gitBranches, gitCheckout, gitCommit, gitDiff, gitHunks, gitPushAndCreatePr, gitStageHunk, gitStatus } from "./git.ts"; import { installPackage, listPackages, removePackage, updatePackage } from "./packages.ts"; import { listSkills } from "./skills.ts"; import { listProjectFiles } from "./files.ts"; @@ -294,6 +294,9 @@ const gitMutationParamsSchema = z.object({ branch: z.string().min(1), create: z.boolean(), }); +const gitHunkParamsSchema = z.object({ projectDir: z.string().min(1), file: z.string().min(1), untracked: z.boolean(), hunk: z.number().int().nonnegative() }); +const gitCommitParamsSchema = z.object({ projectDir: z.string().min(1), message: z.string().trim().min(1).max(10_000) }); +const gitPrParamsSchema = z.object({ projectDir: z.string().min(1), title: z.string().trim().min(1).max(256), body: z.string().max(50_000) }); const projectDirParamsSchema = z.object({ projectDir: z.string().min(1) }); /** * `get_commands` as the composer needs it, checked at the Pi boundary. @@ -953,6 +956,19 @@ const handlers: HandlerMap = { gitStatus: async ({ projectDir }) => ({ status: await gitStatus(projectDir) }), gitDiff: async ({ projectDir, file, untracked }) => ({ diff: await gitDiff(projectDir, file, untracked) }), + gitHunks: async ({ projectDir, file, untracked }) => ({ hunks: await gitHunks(projectDir, file, untracked) }), + gitStageHunk: async (params) => { + try { const { projectDir, file, untracked, hunk } = gitHunkParamsSchema.parse(params); return await gitStageHunk(projectDir, file, untracked, hunk); } + catch (err) { return { ok: false, error: errorMessage(err) }; } + }, + gitCommit: async (params) => { + try { const { projectDir, message } = gitCommitParamsSchema.parse(params); return await gitCommit(projectDir, message); } + catch (err) { return { ok: false, error: errorMessage(err) }; } + }, + gitPushAndCreatePr: async (params) => { + try { const { projectDir, title, body } = gitPrParamsSchema.parse(params); return await gitPushAndCreatePr(projectDir, title, body); } + catch (err) { return { ok: false, error: errorMessage(err) }; } + }, gitBranches: async ({ projectDir }) => ({ branches: await gitBranches(projectDir) }), gitCheckout: async (params) => { try { diff --git a/apps/desktop/src/renderer/components/CommitDialog.tsx b/apps/desktop/src/renderer/components/CommitDialog.tsx new file mode 100644 index 0000000..c3a7061 --- /dev/null +++ b/apps/desktop/src/renderer/components/CommitDialog.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { SparkleIcon } from "@phosphor-icons/react/Sparkle"; +import type { AssistantMessage, SessionEntry } from "../../shared/pi-types.ts"; +import { rpc } from "../lib/rpc.ts"; +import { activeConversation, useAppStore } from "../lib/store.ts"; +import { Button } from "@/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Textarea } from "@/components/ui/textarea.tsx"; + +function assistantText(entries: SessionEntry[], since: number): string | null { + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]; + const message = (entry as { message?: unknown }).message; + if (entry.type !== "message" || !message || (message as { role?: string }).role !== "assistant") continue; + const assistant = message as AssistantMessage; + if (assistant.timestamp < since) return null; + const text = assistant.content.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim(); + if (text) return text; + } + return null; +} + +export default function CommitDialog({ projectDir, onClose }: { projectDir: string | null; onClose: () => void }) { + const conversation = useAppStore(activeConversation); + const refreshGit = useAppStore((s) => s.refreshGit); + const [message, setMessage] = useState(""); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [draftingSince, setDraftingSince] = useState(null); + const [busy, setBusy] = useState<"commit" | "pr" | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!draftingSince) return; + const draft = assistantText(conversation.entries, draftingSince); + if (!draft) return; + setMessage(draft); + setTitle(draft.split("\n")[0].slice(0, 256)); + setDraftingSince(null); + }, [conversation.entries, draftingSince]); + + useEffect(() => { + if (projectDir) return; + setMessage(""); + setTitle(""); + setBody(""); + setError(null); + setDraftingSince(null); + }, [projectDir]); + + async function askPi() { + if (!projectDir || !conversation.sessionFile || conversation.running) return; + setError(null); + const since = Date.now(); + setDraftingSince(since); + const result = await rpc.request.submit({ + projectDir, + sessionFile: conversation.sessionFile, + message: "Inspect the currently staged Git changes. Draft only a concise Conventional Commit message, with an optional body only when it adds necessary context. Do not commit or change files.", + }); + if (!result.ok) { + setDraftingSince(null); + setError(result.error ?? "Pi could not draft a commit message."); + } + } + + async function commit() { + if (!projectDir || !message.trim()) return; + setBusy("commit"); + setError(null); + const result = await rpc.request.gitCommit({ projectDir, message }); + setBusy(null); + if (!result.ok) return setError(result.error ?? "Git could not create the commit."); + await refreshGit(); + } + + async function createPr() { + if (!projectDir || !title.trim()) return; + setBusy("pr"); + setError(null); + const result = await rpc.request.gitPushAndCreatePr({ projectDir, title, body }); + setBusy(null); + if (!result.ok) return setError(result.error ?? "GitHub CLI could not open the pull request."); + if (result.url) window.open(result.url, "_blank", "noopener,noreferrer"); + onClose(); + } + + const canAskPi = Boolean(conversation.sessionFile) && !conversation.running && !draftingSince; + return ( + !open && onClose()}> + + + Commit changes + + Stage the changes you want first. Pi can draft the message from the staged diff; you can edit it before committing. + + +
+