From c53f559358c96fd180d2d43497076afa85c64f66 Mon Sep 17 00:00:00 2001 From: ruru Date: Sun, 20 Sep 2026 20:18:18 +0530 Subject: [PATCH] feat(desktop): finish commit composer controls --- apps/desktop/src-tauri/src/commands/commit.rs | 15 +- apps/desktop/src-tauri/src/commands/rebase.rs | 6 +- apps/desktop/src-tauri/src/lib.rs | 1 + .../git/components/write-commit-box.tsx | 630 +++++++++--- .../git/rebase/rebase-actions-bar.tsx | 47 +- apps/desktop/src/hooks/use-rebase.ts | 1 + apps/desktop/src/hooks/use-repository.ts | 22 + .../src/state/domains/repository-state.ts | 15 + .../src/store/use-commit-draft-store.ts | 176 +++- .../tests/rebase-draft-transitions.test.tsx | 177 ++++ apps/desktop/tests/write-commit-box.test.tsx | 965 ++++++++++++++++++ crates/git/parsers/commit.rs | 7 +- crates/git/runner.rs | 104 +- crates/git/service/commit.rs | 480 ++++++++- crates/git/service/graph.rs | 33 +- crates/git/service/operation.rs | 7 +- crates/git/service/rebase.rs | 164 ++- crates/git/service/rebase_tests.rs | 93 +- crates/git/tests/commit_service.rs | 432 +++++++- packages/commands/src/.typecache | 4 +- packages/commands/src/commands.ts | 402 ++++---- packages/commands/src/index.ts | 2 +- packages/commands/src/types.ts | 848 +++++++-------- 23 files changed, 3728 insertions(+), 903 deletions(-) create mode 100644 apps/desktop/tests/rebase-draft-transitions.test.tsx create mode 100644 apps/desktop/tests/write-commit-box.test.tsx diff --git a/apps/desktop/src-tauri/src/commands/commit.rs b/apps/desktop/src-tauri/src/commands/commit.rs index 29e4209f..3aa18750 100644 --- a/apps/desktop/src-tauri/src/commands/commit.rs +++ b/apps/desktop/src-tauri/src/commands/commit.rs @@ -1,6 +1,6 @@ use git::{ core::get_services, - models::commit::{CommitInfo, CommitMessage, FullCommitInfo}, + models::commit::{Author, CommitInfo, CommitMessage, FullCommitInfo}, AppState, }; @@ -28,11 +28,22 @@ pub async fn create_commit( context_id: String, commit_meta: CommitMessage, allow_empty: bool, + amend: bool, + expected_head: Option, state: tauri::State<'_, AppState>, ) -> Result { let services = get_services(state, &context_id).await?; services .commit() - .create_commit(&commit_meta, allow_empty) + .create_commit(&commit_meta, allow_empty, amend, expected_head.as_deref()) .await } + +#[tauri::command] +pub async fn commit_authors( + context_id: String, + state: tauri::State<'_, AppState>, +) -> Result, String> { + let services = get_services(state, &context_id).await?; + services.commit().commit_authors().await +} diff --git a/apps/desktop/src-tauri/src/commands/rebase.rs b/apps/desktop/src-tauri/src/commands/rebase.rs index 87001983..952d3091 100644 --- a/apps/desktop/src-tauri/src/commands/rebase.rs +++ b/apps/desktop/src-tauri/src/commands/rebase.rs @@ -88,7 +88,7 @@ pub async fn rebase_update_todo( state: tauri::State<'_, AppState>, ) -> Result { let services = get_services(state, &context_id).await?; - services.rebase().update_todo(request.entries) + services.rebase().update_todo(request.entries).await } #[tauri::command] @@ -98,7 +98,7 @@ pub async fn rebase_set_commit_message( state: tauri::State<'_, AppState>, ) -> Result<(), String> { let services = get_services(state, &context_id).await?; - services.rebase().set_commit_message(&message) + services.rebase().set_commit_message(&message).await } #[tauri::command] @@ -108,5 +108,5 @@ pub async fn rebase_resolve_conflict( state: tauri::State<'_, AppState>, ) -> Result<(), String> { let services = get_services(state, &context_id).await?; - services.rebase().resolve_conflict(request) + services.rebase().resolve_conflict(request).await } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9f37cdc4..2887ec6f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -83,6 +83,7 @@ pub fn run() { commands::commit::last_commit, commands::commit::commit_by_id, commands::commit::create_commit, + commands::commit::commit_authors, commands::branch::push, commands::branch::publish_branch, commands::branch::pull, diff --git a/apps/desktop/src/features/git/components/write-commit-box.tsx b/apps/desktop/src/features/git/components/write-commit-box.tsx index 5e0d4667..2d9a1311 100644 --- a/apps/desktop/src/features/git/components/write-commit-box.tsx +++ b/apps/desktop/src/features/git/components/write-commit-box.tsx @@ -1,5 +1,23 @@ -import type { CommitMessage } from "@gitru/commands"; +import type { Author, RepoOperation } from "@gitru/commands"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "@gitru/ui/components/alert-dialog"; +import { Badge } from "@gitru/ui/components/badge"; import { Button } from "@gitru/ui/components/button"; +import { + Combobox, + ComboboxChips, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@gitru/ui/components/combobox"; import { Group, GroupSeparator } from "@gitru/ui/components/group"; import { InputGroup, @@ -13,12 +31,27 @@ import { MenuPopup, MenuTrigger, } from "@gitru/ui/components/menu"; -import { ChevronDownIcon, Loader2, Sparkles, UserPlus } from "lucide-react"; -import { memo, useCallback, useEffect, useState } from "react"; +import { + Popover, + PopoverDescription, + PopoverPopup, + PopoverTitle, + PopoverTrigger, +} from "@gitru/ui/components/popover"; +import { + ChevronDownIcon, + Loader2, + SearchIcon, + UserPlus, + XIcon, +} from "lucide-react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useCreateCommit, + useGetCommitAuthors, useGetCurrentBranch, + useGetLastCommit, useGetRepoOperation, useGetStatus, useGitAdd, @@ -26,28 +59,95 @@ import { import { useActiveRepositoryState } from "@/state/use-active-repository-state"; import { splitCommitMessage, + stripTrailingCoAuthorTrailers, useCommitDraftStore, } from "@/store/use-commit-draft-store"; +type CommitAction = "create" | "empty" | "amend"; + +const REBASE_OPERATION_KINDS = new Set([ + "rebase", + "rebaseInteractive", + "rebaseMerge", +]); +const NORMAL_COMMIT_OPERATION_KINDS = new Set([ + "clean", + "merge", + "revert", + "cherryPick", + "bisect", +]); + +export function getCommitActionAvailability( + operation: RepoOperation | null | undefined, +) { + if (!operation) { + return { create: false, empty: false, amend: false, isRebaseLike: false }; + } + + const isRebaseLike = + operation.isRebasing || REBASE_OPERATION_KINDS.has(operation.kind); + const hasConflicts = operation.conflictPaths.length > 0; + const clean = operation.kind === "clean" && !isRebaseLike && !hasConflicts; + const editableRebasePause = + isRebaseLike && operation.pauseReason === "edit" && !hasConflicts; + + return { + create: + !isRebaseLike && + !hasConflicts && + NORMAL_COMMIT_OPERATION_KINDS.has(operation.kind), + empty: clean, + amend: clean || editableRebasePause, + isRebaseLike, + }; +} + +function authorKey(author: Author | readonly [string, string]) { + const email = "name" in author ? author.email : author[1]; + return email.trim().toLowerCase(); +} + +function authorTuple(author: Author): [string, string] { + return [author.name, author.email]; +} + export const WriteCommitBox = memo(function WriteCommitBox({ visibleAddablePaths, }: { visibleAddablePaths: string[]; }) { - const [co_authors, setCoAuthors] = useState([]); + const summaryRef = useRef(null); + const [emptyCommitOpen, setEmptyCommitOpen] = useState(false); + const [coAuthorPickerOpen, setCoAuthorPickerOpen] = useState(false); + const [enteringAmend, setEnteringAmend] = useState(false); const repo = useActiveRepositoryState(); - const title = useCommitDraftStore((s) => s.title); - const description = useCommitDraftStore((s) => s.description); - const setTitle = useCommitDraftStore((s) => s.setTitle); - const setDescription = useCommitDraftStore((s) => s.setDescription); - const applyAutofill = useCommitDraftStore((s) => s.applyAutofill); - const switchRepo = useCommitDraftStore((s) => s.switchRepo); - const clearDraft = useCommitDraftStore((s) => s.clear); + const title = useCommitDraftStore((state) => state.title); + const description = useCommitDraftStore((state) => state.description); + const coAuthors = useCommitDraftStore((state) => state.coAuthors); + const mode = useCommitDraftStore((state) => state.mode); + const amendCommitId = useCommitDraftStore((state) => state.amendCommitId); + const setTitle = useCommitDraftStore((state) => state.setTitle); + const setDescription = useCommitDraftStore((state) => state.setDescription); + const setCoAuthors = useCommitDraftStore((state) => state.setCoAuthors); + const applyAutofill = useCommitDraftStore((state) => state.applyAutofill); + const beginAmend = useCommitDraftStore((state) => state.beginAmend); + const cancelAmend = useCommitDraftStore((state) => state.cancelAmend); + const switchRepo = useCommitDraftStore((state) => state.switchRepo); + const clearDraft = useCommitDraftStore((state) => state.clear); const { data: currentBranch } = useGetCurrentBranch(); - const { data: status } = useGetStatus(); - const { data: operation } = useGetRepoOperation(); + const { data: status, isLoading: statusLoading } = useGetStatus(); + const { data: operation, isLoading: operationLoading } = + useGetRepoOperation(); + const { + data: lastCommit, + isLoading: lastCommitLoading, + refetch: refetchLastCommit, + } = useGetLastCommit(); + const { data: suggestedAuthors = [], isLoading: authorsLoading } = + useGetCommitAuthors(); const { mutateAsync: gitAdd, isPending: isAdding } = useGitAdd(); const { mutateAsync: createCommit, isPending: isCreatingCommit } = useCreateCommit(); @@ -58,7 +158,6 @@ export const WriteCommitBox = memo(function WriteCommitBox({ // Prefill Summary/Description from the paused rebase commit — Continue reads // the same draft store. Keyed so refetch doesn't clobber user edits. - // Autofill for reword and edit (edit is optional to change, but helpful). const shouldAutofillRebaseMessage = !!operation?.isRebasing && !!operation.commitMessage?.trim() && @@ -76,137 +175,412 @@ export const WriteCommitBox = memo(function WriteCommitBox({ useEffect(() => { if (rebaseAutofillKey && rebaseMessage) { const parts = splitCommitMessage(rebaseMessage); - applyAutofill(rebaseAutofillKey, parts.title, parts.description); + applyAutofill( + rebaseAutofillKey, + parts.title, + parts.description, + parts.coAuthors, + ); return; } - if (!isRebasing) { - const key = useCommitDraftStore.getState().autofillKey; - if (key?.startsWith("rebase:")) clearDraft(); + if (!operationLoading && !isRebasing) { + const draft = useCommitDraftStore.getState(); + const rebaseScopedAmend = + draft.autofillKey?.startsWith("amend:") && + draft.draftBeforeAmend?.autofillKey?.startsWith("rebase:"); + if (draft.autofillKey?.startsWith("rebase:") || rebaseScopedAmend) { + clearDraft(); + } } - }, [applyAutofill, clearDraft, isRebasing, rebaseAutofillKey, rebaseMessage]); + }, [ + applyAutofill, + clearDraft, + isRebasing, + operationLoading, + rebaseAutofillKey, + rebaseMessage, + ]); - const nothingToCommit = - status?.files.filter((file) => - file.status.some((s) => s.startsWith("Index")), - ).length === 0; + const selectedAuthors = useMemo(() => { + const authors = coAuthors.map(([name, email]) => ({ name, email })); + return authors.filter( + (author, index) => + authors.findIndex( + (candidate) => authorKey(candidate) === authorKey(author), + ) === index, + ); + }, [coAuthors]); + const coAuthorOptions = useMemo(() => { + const authors = [...selectedAuthors, ...(suggestedAuthors ?? [])]; + return authors.filter( + (author, index) => + author.name.trim().length > 0 && + author.email.trim().length > 0 && + authors.findIndex( + (candidate) => authorKey(candidate) === authorKey(author), + ) === index, + ); + }, [selectedAuthors, suggestedAuthors]); - const handelCommit = useCallback(async () => { - if (nothingToCommit) { - if (visibleAddablePaths.length === 0) { - toast.error("No visible changes to add"); - return; + const hasStagedChanges = + status?.files.some((file) => + file.status.some((fileStatus) => fileStatus.startsWith("Index")), + ) ?? false; + const statusReady = !statusLoading && status != null; + const availability = operationLoading + ? { create: false, empty: false, amend: false, isRebaseLike: false } + : getCommitActionAvailability(operation); + const summaryValid = title.trim().length > 0; + const busy = isAdding || isCreatingCommit || enteringAmend; + const canSubmitCreate = + availability.create && + statusReady && + (hasStagedChanges || visibleAddablePaths.length > 0); + const canSubmitEmpty = availability.empty && statusReady && !hasStagedChanges; + const canSubmitAmend = + availability.amend && statusReady && amendCommitId !== null; + const submitDisabled = + busy || + !summaryValid || + (mode === "amend" ? !canSubmitAmend : !canSubmitCreate); + + const submitCommit = async (action: CommitAction): Promise => { + if (!summaryValid) { + summaryRef.current?.focus(); + return false; + } + if (action === "create" && !canSubmitCreate) return false; + if (action === "empty" && !canSubmitEmpty) return false; + if (action === "amend" && (!canSubmitAmend || !amendCommitId)) return false; + + const submissionRepoKey = repo?.contextId ?? null; + try { + if (action === "create" && !hasStagedChanges) { + await gitAdd(visibleAddablePaths); } - await gitAdd(visibleAddablePaths); + await createCommit({ + commitMeta: { + title, + description, + co_authors: selectedAuthors.map(authorTuple), + }, + allowEmpty: action === "empty", + amend: action === "amend", + expectedHead: + action === "amend" ? (amendCommitId ?? undefined) : undefined, + }); + + if (useCommitDraftStore.getState().repoKey === submissionRepoKey) { + clearDraft(); + } + setEmptyCommitOpen(false); + setCoAuthorPickerOpen(false); + toast.success( + action === "amend" + ? "Last commit amended" + : action === "empty" + ? "Empty commit created" + : "Commit created successfully", + ); + return true; + } catch { + // Mutation hooks report the error. Keep every draft field and the active + // mode intact so the user can fix the problem and retry. + return false; } + }; - const data = await createCommit({ - commitMeta: { - title, - description, - co_authors, - }, - allowEmpty: false, - }); - if (data) { - clearDraft(); - setCoAuthors([]); - toast.success("Commit created successfully"); + const enterAmendMode = async () => { + if (!availability.amend || !lastCommit || mode === "amend") return; + const amendRepoKey = repo?.contextId ?? null; + setEnteringAmend(true); + try { + const { data: freshLastCommit, error } = await refetchLastCommit(); + if (useCommitDraftStore.getState().repoKey !== amendRepoKey) return; + if (error || !freshLastCommit) { + toast.error(error?.message || "Could not load the latest commit"); + return; + } + beginAmend( + freshLastCommit.id, + freshLastCommit.summary, + stripTrailingCoAuthorTrailers(freshLastCommit.body), + freshLastCommit.authors.co_authors.map((author) => [ + author.name, + author.email, + ]), + ); + requestAnimationFrame(() => summaryRef.current?.focus()); + } finally { + setEnteringAmend(false); } - }, [ - clearDraft, - co_authors, - createCommit, - description, - gitAdd, - nothingToCommit, - title, - visibleAddablePaths, - ]); + }; + + const removeCoAuthor = (key: string) => { + setCoAuthors(coAuthors.filter((author) => authorKey(author) !== key)); + }; return ( -
-
- - setTitle(e.target.value)} - /> - - - - - - setDescription(e.target.value)} - /> - - - - -
- - - - - + + 0} + aria-label="Commit summary" + className="h-8" + placeholder="Summary (required)" + required + type="text" + value={title} + onChange={(event) => setTitle(event.target.value)} + /> + + + + setDescription(event.target.value)} + /> + + {selectedAuthors.map((author) => { + const key = authorKey(author); + return ( + removeCoAuthor(key)} /> } > - - - - Empty Commit - Amend Last Commit - - - + {author.name} +
+ ) : availability.isRebaseLike ? ( +

+ Use the rebase controls to continue. Commit creation is unavailable. +

+ ) : null} + + + + + + + } + > + + + setEmptyCommitOpen(true)} + > + Empty Commit… + + void enterAmendMode()} + > + Amend Last Commit + + + + + + { + if (!busy) setEmptyCommitOpen(open); + }} + > + + + Create an empty commit? + + This creates a commit on{" "} + {currentBranch?.name ?? "the current branch"} + without including file changes. Your working tree will be left + unchanged. + + + + + } + > + Cancel + + + + +
); }); diff --git a/apps/desktop/src/features/git/rebase/rebase-actions-bar.tsx b/apps/desktop/src/features/git/rebase/rebase-actions-bar.tsx index 785561e4..457bd65b 100644 --- a/apps/desktop/src/features/git/rebase/rebase-actions-bar.tsx +++ b/apps/desktop/src/features/git/rebase/rebase-actions-bar.tsx @@ -30,6 +30,26 @@ import { useCommitDraftStore, } from "@/store/use-commit-draft-store"; +type RebaseDraftIdentity = { + repoKey: string | null; + autofillKey: string | null; +}; + +function getRebaseDraftIdentity(): RebaseDraftIdentity { + const { repoKey, autofillKey } = useCommitDraftStore.getState(); + return { repoKey, autofillKey }; +} + +function clearRebaseDraftIfUnchanged(identity: RebaseDraftIdentity) { + const draft = useCommitDraftStore.getState(); + if ( + draft.repoKey === identity.repoKey && + draft.autofillKey === identity.autofillKey + ) { + draft.clear(); + } +} + export function RebaseActionsBar({ operation }: { operation: RepoOperation }) { const { mutateAsync: continueRebase, isPending: continuing } = useRebaseContinue(); @@ -41,11 +61,11 @@ export function RebaseActionsBar({ operation }: { operation: RepoOperation }) { const title = useCommitDraftStore((s) => s.title); const description = useCommitDraftStore((s) => s.description); - const clearDraft = useCommitDraftStore((s) => s.clear); + const coAuthors = useCommitDraftStore((s) => s.coAuthors); const busy = continuing || skipping || aborting; const hasConflicts = operation.conflictPaths.length > 0; - const draftMessage = joinCommitMessage(title, description); + const draftMessage = joinCommitMessage(title, description, coAuthors); // Only reword requires a message. Edit pauses just need `rebase --continue` // (amend is optional). Fall back to the server-provided commit message when // the draft is empty so Continue isn't blocked by a missed autofill. @@ -81,11 +101,18 @@ export function RebaseActionsBar({ operation }: { operation: RepoOperation }) { disabled={busy} size="sm" onClick={() => { - toast.promise(skip(), { - loading: "Skipping…", - success: "Skipped commit", - error: (e) => e.message || "Skip failed", - }); + const draftIdentity = getRebaseDraftIdentity(); + toast.promise( + skip().then((op) => { + clearRebaseDraftIfUnchanged(draftIdentity); + return op; + }), + { + loading: "Skipping…", + success: "Skipped commit", + error: (e) => e.message || "Skip failed", + }, + ); }} > Skip @@ -98,9 +125,10 @@ export function RebaseActionsBar({ operation }: { operation: RepoOperation }) { } size="sm" onClick={() => { + const draftIdentity = getRebaseDraftIdentity(); toast.promise( continueRebase(continueMessage).then((op) => { - clearDraft(); + clearRebaseDraftIfUnchanged(draftIdentity); return op; }), { @@ -140,8 +168,9 @@ export function RebaseActionsBar({ operation }: { operation: RepoOperation }) { variant="destructive" disabled={aborting} onClick={async () => { + const draftIdentity = getRebaseDraftIdentity(); await abort(); - clearDraft(); + clearRebaseDraftIfUnchanged(draftIdentity); setAbortOpen(false); }} > diff --git a/apps/desktop/src/hooks/use-rebase.ts b/apps/desktop/src/hooks/use-rebase.ts index 58ab0d11..7e36a170 100644 --- a/apps/desktop/src/hooks/use-rebase.ts +++ b/apps/desktop/src/hooks/use-rebase.ts @@ -65,6 +65,7 @@ export function useRebaseStart() { onSuccess: async () => { await repo?.status.invalidate(); await repo?.branches.invalidate(); + await repo?.commit.invalidate(); await repo?.operation.invalidate(); }, onError: (error: Error) => { diff --git a/apps/desktop/src/hooks/use-repository.ts b/apps/desktop/src/hooks/use-repository.ts index 07cf4b5f..0ea4840b 100644 --- a/apps/desktop/src/hooks/use-repository.ts +++ b/apps/desktop/src/hooks/use-repository.ts @@ -1,5 +1,6 @@ import type { AheadBehindStatus, + Author, Branch, BranchInfo, BranchKind, @@ -190,6 +191,26 @@ export function useGetLastCommit(options?: QueryOptions) { }); } +export function useGetCommitAuthors(options?: QueryOptions) { + const repo = useActiveRepositoryState(); + + return useQuery({ + queryKey: repo?.commit.getQueryKey("authors") ?? [ + "repository", + "none", + "commit", + "authors", + ], + queryFn: async () => { + if (!repo) return null; + return await repo.commit.authors(); + }, + enabled: !!repo, + staleTime: 60_000, + ...options, + }); +} + export function useGetCommitHistory(options?: QueryOptions) { const repo = useActiveRepositoryState(); @@ -384,6 +405,7 @@ export function useCreateCommit() { await repo?.commit.invalidate(); await repo?.branches.invalidate("statusAheadBehind"); await repo?.branches.invalidate("hasUncommittedChanges"); + await repo?.operation.invalidate(); }, onError: (error: string) => { toast.error(error); diff --git a/apps/desktop/src/state/domains/repository-state.ts b/apps/desktop/src/state/domains/repository-state.ts index b90f7638..b8be5ee5 100644 --- a/apps/desktop/src/state/domains/repository-state.ts +++ b/apps/desktop/src/state/domains/repository-state.ts @@ -5,6 +5,7 @@ import { type ConflictResolveRequest, CreateCommitParams, commitActivity, + commitAuthors, commitById, createBranch, createCommit, @@ -501,6 +502,19 @@ class Commit extends StateDomain { return data; } + async authors() { + await this.queryClient.cancelQueries({ + queryKey: [...this.baseKey, "authors"], + }); + + const data = await commitAuthors({ + contextId: this.contextId, + }); + + this.queryClient.setQueryData([...this.baseKey, "authors"], data); + return data; + } + async history() { await this.queryClient.cancelQueries({ queryKey: [...this.baseKey, "history"], @@ -538,6 +552,7 @@ class Commit extends StateDomain { key: | "last" | "getCommitById" + | "authors" | "history" | "historyGraph" | "commitActivity", diff --git a/apps/desktop/src/store/use-commit-draft-store.ts b/apps/desktop/src/store/use-commit-draft-store.ts index d9d8bc19..ab2a4c1a 100644 --- a/apps/desktop/src/store/use-commit-draft-store.ts +++ b/apps/desktop/src/store/use-commit-draft-store.ts @@ -1,59 +1,197 @@ +import type { CommitMessage } from "@gitru/commands"; import { create } from "zustand"; +export type CommitDraftMode = "create" | "amend"; + +type CommitDraftSnapshot = { + title: string; + description: string; + coAuthors: CommitMessage["co_authors"]; + autofillKey: string | null; +}; + type CommitDraftState = { /** Active repo/context key — draft clears when this changes. */ repoKey: string | null; title: string; description: string; + coAuthors: CommitMessage["co_authors"]; + mode: CommitDraftMode; + amendCommitId: string | null; + /** Draft to restore if the user cancels amend mode. */ + draftBeforeAmend: CommitDraftSnapshot | null; /** Last autofill identity — skip re-applying the same rebase step. */ autofillKey: string | null; setTitle: (title: string) => void; setDescription: (description: string) => void; - applyAutofill: (key: string, title: string, description: string) => void; + setCoAuthors: (coAuthors: CommitMessage["co_authors"]) => void; + applyAutofill: ( + key: string, + title: string, + description: string, + coAuthors?: CommitMessage["co_authors"], + ) => void; + beginAmend: ( + commitId: string, + title: string, + description: string, + coAuthors: CommitMessage["co_authors"], + ) => void; + cancelAmend: () => void; switchRepo: (repoKey: string | null) => void; clear: () => void; }; +const emptyDraft = { + title: "", + description: "", + coAuthors: [] as CommitMessage["co_authors"], + mode: "create" as const, + amendCommitId: null, + draftBeforeAmend: null, + autofillKey: null, +}; + +const CO_AUTHOR_TRAILER = /^Co-authored-by:\s*(.+?)\s*<([^<>]+)>\s*$/i; +const GIT_TRAILER = /^[A-Za-z0-9][A-Za-z0-9-]*:\s*\S.*$/; + +function extractTrailingCoAuthorTrailers(message: string): { + message: string; + coAuthors: CommitMessage["co_authors"]; +} { + const lines = message.split("\n"); + + while (lines.at(-1)?.trim() === "") lines.pop(); + let trailerStart = lines.length; + let foundTrailer = false; + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (GIT_TRAILER.test(line)) { + trailerStart = index; + foundTrailer = true; + continue; + } + if (foundTrailer && line.trim() === "") { + trailerStart = index; + continue; + } + break; + } + + const trailingBlock = lines.slice(trailerStart); + const coAuthors: CommitMessage["co_authors"] = []; + for (const line of trailingBlock) { + const match = line.match(CO_AUTHOR_TRAILER); + if (match) coAuthors.push([match[1].trim(), match[2].trim()]); + } + if (coAuthors.length === 0) { + return { message: lines.join("\n").trim(), coAuthors: [] }; + } + + const body = lines.slice(0, trailerStart).join("\n").trim(); + const remainingTrailers = trailingBlock + .filter((line) => line.trim() && !CO_AUTHOR_TRAILER.test(line)) + .join("\n"); + + return { + message: [body, remainingTrailers].filter(Boolean).join("\n\n"), + coAuthors, + }; +} + +export function stripTrailingCoAuthorTrailers(message: string): string { + return extractTrailingCoAuthorTrailers(message).message; +} + export function splitCommitMessage(message: string): { title: string; description: string; + coAuthors: CommitMessage["co_authors"]; } { - const cleaned = message - .split("\n") - .filter((line) => !line.startsWith("#")) - .join("\n") - .trim(); - if (!cleaned) return { title: "", description: "" }; + const parsed = extractTrailingCoAuthorTrailers(message); + const cleaned = parsed.message; + const coAuthors = parsed.coAuthors; + if (!cleaned) return { title: "", description: "", coAuthors }; const nl = cleaned.indexOf("\n"); - if (nl < 0) return { title: cleaned, description: "" }; + if (nl < 0) return { title: cleaned, description: "", coAuthors }; return { title: cleaned.slice(0, nl).trim(), description: cleaned.slice(nl + 1).trim(), + coAuthors, }; } -export function joinCommitMessage(title: string, description: string): string { +export function joinCommitMessage( + title: string, + description: string, + coAuthors: CommitMessage["co_authors"] = [], +): string { const t = title.trim(); const d = description.trim(); - if (!d) return t; - if (!t) return d; - return `${t}\n\n${d}`; + const message = d ? (t ? `${t}\n\n${d}` : d) : t; + const trailers = coAuthors + .map(([name, email]) => `Co-authored-by: ${name.trim()} <${email.trim()}>`) + .join("\n"); + + if (!message) return trailers; + if (!trailers) return message; + + const lastDescriptionLine = d.split("\n").at(-1) ?? ""; + const trailerSeparator = + d && GIT_TRAILER.test(lastDescriptionLine) ? "\n" : "\n\n"; + return `${message}${trailerSeparator}${trailers}`; } export const useCommitDraftStore = create((set, get) => ({ repoKey: null, - title: "", - description: "", - autofillKey: null, + ...emptyDraft, setTitle: (title) => set({ title }), setDescription: (description) => set({ description }), - applyAutofill: (key, title, description) => { + setCoAuthors: (coAuthors) => set({ coAuthors }), + applyAutofill: (key, title, description, coAuthors = []) => { if (get().autofillKey === key) return; - set({ autofillKey: key, title, description }); + set({ + amendCommitId: null, + autofillKey: key, + coAuthors, + description, + draftBeforeAmend: null, + mode: "create", + title, + }); + }, + beginAmend: (commitId, title, description, coAuthors) => { + const state = get(); + if (state.mode === "amend") return; + set({ + amendCommitId: commitId, + autofillKey: `amend:${commitId}`, + coAuthors, + description, + draftBeforeAmend: { + title: state.title, + description: state.description, + coAuthors: state.coAuthors, + autofillKey: state.autofillKey, + }, + mode: "amend", + title, + }); + }, + cancelAmend: () => { + const state = get(); + if (state.mode !== "amend") return; + set({ + ...(state.draftBeforeAmend ?? emptyDraft), + amendCommitId: null, + draftBeforeAmend: null, + mode: "create", + }); }, switchRepo: (repoKey) => { if (get().repoKey === repoKey) return; - set({ repoKey, title: "", description: "", autofillKey: null }); + set({ repoKey, ...emptyDraft }); }, - clear: () => set({ title: "", description: "", autofillKey: null }), + clear: () => set(emptyDraft), })); diff --git a/apps/desktop/tests/rebase-draft-transitions.test.tsx b/apps/desktop/tests/rebase-draft-transitions.test.tsx new file mode 100644 index 00000000..26860858 --- /dev/null +++ b/apps/desktop/tests/rebase-draft-transitions.test.tsx @@ -0,0 +1,177 @@ +import type { RepoOperation } from "@gitru/commands"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { RebaseActionsBar } from "../src/features/git/rebase/rebase-actions-bar"; +import { useCommitDraftStore } from "../src/store/use-commit-draft-store"; + +const mocks = vi.hoisted(() => ({ + abort: vi.fn(), + continueRebase: vi.fn(), + loadAbortPreview: vi.fn(), + skip: vi.fn(), + toastPromise: vi.fn((promise: Promise) => promise), +})); + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + promise: mocks.toastPromise, + }, +})); + +vi.mock("@/hooks", () => ({ + useRebaseAbort: () => ({ isPending: false, mutateAsync: mocks.abort }), + useRebaseAbortPreview: () => ({ mutateAsync: mocks.loadAbortPreview }), + useRebaseContinue: () => ({ + isPending: false, + mutateAsync: mocks.continueRebase, + }), + useRebaseSkip: () => ({ isPending: false, mutateAsync: mocks.skip }), +})); + +function operation(overrides: Partial = {}): RepoOperation { + return { + kind: "rebaseInteractive", + isRebasing: true, + pauseReason: "edit", + todo: [], + conflictPaths: [], + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function enterRebaseAmendMode() { + const draft = useCommitDraftStore.getState(); + draft.applyAutofill( + "rebase:edit:1:Edit summary", + "Edit summary", + "Edit body", + [["Old Pair", "old@example.com"]], + ); + draft.beginAmend("edit-head", "Amended summary", "Amended body", [ + ["Amend Pair", "amend@example.com"], + ]); + useCommitDraftStore.getState().setTitle("Unsaved amend message"); +} + +beforeEach(() => { + useCommitDraftStore.setState({ repoKey: "repo-1" }); + useCommitDraftStore.getState().clear(); + mocks.abort.mockResolvedValue( + operation({ kind: "clean", isRebasing: false }), + ); + mocks.continueRebase.mockResolvedValue(operation()); + mocks.loadAbortPreview.mockResolvedValue({ warning: "Abort warning" }); + mocks.skip.mockResolvedValue(operation()); +}); + +describe("rebase draft transitions", () => { + test("Skip keeps the next reword autofill and discards stale amend restoration", async () => { + const user = userEvent.setup(); + enterRebaseAmendMode(); + mocks.skip.mockImplementation(async () => { + useCommitDraftStore + .getState() + .applyAutofill( + "rebase:reword:2:Next summary", + "Next summary", + "# Next details", + [["Next Pair", "next@example.com"]], + ); + return operation({ + pauseReason: "reword", + current: 2, + commitMessage: "Next summary\n\n# Next details", + }); + }); + + render(); + await user.click(screen.getByRole("button", { name: "Skip" })); + + await waitFor(() => expect(mocks.skip).toHaveBeenCalledOnce()); + expect(useCommitDraftStore.getState()).toMatchObject({ + amendCommitId: null, + autofillKey: "rebase:reword:2:Next summary", + coAuthors: [["Next Pair", "next@example.com"]], + description: "# Next details", + draftBeforeAmend: null, + mode: "create", + title: "Next summary", + }); + + useCommitDraftStore.getState().cancelAmend(); + expect(useCommitDraftStore.getState().title).toBe("Next summary"); + }); + + test("Skip clears the stale amend target and message when the rebase finishes", async () => { + const user = userEvent.setup(); + enterRebaseAmendMode(); + mocks.skip.mockResolvedValue( + operation({ kind: "clean", isRebasing: false }), + ); + + render(); + await user.click(screen.getByRole("button", { name: "Skip" })); + + await waitFor(() => { + expect(useCommitDraftStore.getState()).toMatchObject({ + amendCommitId: null, + autofillKey: null, + coAuthors: [], + description: "", + draftBeforeAmend: null, + mode: "create", + title: "", + }); + }); + }); + + test.each([ + "Skip", + "Continue", + ] as const)("a slow %s completion cannot clear a new repository's null-key draft", async (action) => { + const user = userEvent.setup(); + const pending = deferred(); + const mutation = action === "Skip" ? mocks.skip : mocks.continueRebase; + mutation.mockReturnValue(pending.promise); + useCommitDraftStore.getState().setTitle("Old repository draft"); + + render(); + await user.click( + screen.getByRole("button", { name: new RegExp(`^${action}$`) }), + ); + await waitFor(() => expect(mutation).toHaveBeenCalledOnce()); + + act(() => { + const draft = useCommitDraftStore.getState(); + draft.switchRepo("repo-2"); + useCommitDraftStore.getState().setTitle("New repository draft"); + useCommitDraftStore.getState().setDescription("Must survive"); + useCommitDraftStore + .getState() + .setCoAuthors([["New Pair", "new@example.com"]]); + }); + + await act(async () => { + pending.resolve(operation({ kind: "clean", isRebasing: false })); + await pending.promise; + }); + + expect(useCommitDraftStore.getState()).toMatchObject({ + autofillKey: null, + coAuthors: [["New Pair", "new@example.com"]], + description: "Must survive", + repoKey: "repo-2", + title: "New repository draft", + }); + }); +}); diff --git a/apps/desktop/tests/write-commit-box.test.tsx b/apps/desktop/tests/write-commit-box.test.tsx new file mode 100644 index 00000000..8706358d --- /dev/null +++ b/apps/desktop/tests/write-commit-box.test.tsx @@ -0,0 +1,965 @@ +import type { + Author, + CommitInfo, + GetStatusResponse, + RepoOperation, +} from "@gitru/commands"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { + ComponentProps, + MouseEventHandler, + ReactElement, + ReactNode, +} from "react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + getCommitActionAvailability, + WriteCommitBox, +} from "../src/features/git/components/write-commit-box"; +import { + joinCommitMessage, + splitCommitMessage, + useCommitDraftStore, +} from "../src/store/use-commit-draft-store"; + +const mocks = vi.hoisted(() => ({ + authors: [] as Author[], + createCommit: vi.fn(), + gitAdd: vi.fn(), + lastCommit: null as CommitInfo | null, + operation: null as RepoOperation | null, + refetchLastCommit: vi.fn(), + status: { files: [] } as GetStatusResponse, + toastSuccess: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: mocks.toastSuccess, + }, +})); + +vi.mock("@/state/use-active-repository-state", () => ({ + useActiveRepositoryState: () => ({ contextId: "repo-1" }), +})); + +vi.mock("@/hooks", () => ({ + useCreateCommit: () => ({ + isPending: false, + mutateAsync: mocks.createCommit, + }), + useGetCommitAuthors: () => ({ + data: mocks.authors, + isLoading: false, + }), + useGetCurrentBranch: () => ({ data: { name: "main" } }), + useGetLastCommit: () => ({ + data: mocks.lastCommit, + isLoading: false, + refetch: mocks.refetchLastCommit, + }), + useGetRepoOperation: () => ({ + data: mocks.operation, + isLoading: false, + }), + useGetStatus: () => ({ + data: mocks.status, + isLoading: false, + }), + useGitAdd: () => ({ + isPending: false, + mutateAsync: mocks.gitAdd, + }), +})); + +vi.mock("@gitru/ui/components/menu", async () => { + const React = await import("react"); + const MenuContext = React.createContext<{ + open: boolean; + setOpen: (open: boolean) => void; + } | null>(null); + + return { + Menu: ({ children }: { children: ReactNode }) => { + const [open, setOpen] = React.useState(false); + return ( + + {children} + + ); + }, + MenuTrigger: ({ + children, + render, + }: { + children: ReactNode; + render: ReactElement<{ + onClick?: MouseEventHandler; + }>; + }) => { + const context = React.useContext(MenuContext); + if (!context) throw new Error("MenuTrigger must be inside Menu"); + return React.cloneElement( + render, + { + onClick: (event) => { + render.props.onClick?.(event); + context.setOpen(!context.open); + }, + }, + children, + ); + }, + MenuPopup: ({ children }: { children: ReactNode }) => { + const context = React.useContext(MenuContext); + return context?.open ?
{children}
: null; + }, + MenuItem: ({ + children, + closeOnClick: _closeOnClick, + onClick, + ...props + }: ComponentProps<"button"> & { closeOnClick?: boolean }) => { + const context = React.useContext(MenuContext); + return ( + + ); + }, + }; +}); + +vi.mock("@gitru/ui/components/alert-dialog", async () => { + const React = await import("react"); + const AlertDialogContext = React.createContext<{ + open: boolean; + onOpenChange: (open: boolean) => void; + } | null>(null); + + return { + AlertDialog: ({ + children, + open, + onOpenChange, + }: { + children: ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + }) => ( + + {children} + + ), + AlertDialogPopup: ({ children }: { children: ReactNode }) => { + const context = React.useContext(AlertDialogContext); + return context?.open ?
{children}
: null; + }, + AlertDialogHeader: (props: ComponentProps<"div">) =>
, + AlertDialogFooter: (props: ComponentProps<"div">) =>
, + AlertDialogTitle: (props: ComponentProps<"h2">) =>

, + AlertDialogDescription: (props: ComponentProps<"p">) =>

, + AlertDialogClose: ({ + children, + render, + }: { + children: ReactNode; + render: ReactElement<{ + onClick?: MouseEventHandler; + }>; + }) => { + const context = React.useContext(AlertDialogContext); + return React.cloneElement( + render, + { + onClick: (event) => { + render.props.onClick?.(event); + context?.onOpenChange(false); + }, + }, + children, + ); + }, + }; +}); + +vi.mock("@gitru/ui/components/popover", async () => { + const React = await import("react"); + const PopoverContext = React.createContext<{ + open: boolean; + onOpenChange: (open: boolean) => void; + } | null>(null); + + return { + Popover: ({ + children, + open, + onOpenChange, + }: { + children: ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + }) => ( + + {children} + + ), + PopoverTrigger: ({ + children, + render, + }: { + children: ReactNode; + render: ReactElement<{ + onClick?: MouseEventHandler; + }>; + }) => { + const context = React.useContext(PopoverContext); + if (!context) throw new Error("PopoverTrigger must be inside Popover"); + return React.cloneElement( + render, + { + onClick: (event) => { + render.props.onClick?.(event); + context.onOpenChange(!context.open); + }, + }, + children, + ); + }, + PopoverPopup: ({ + children, + align: _align, + side: _side, + viewport: _viewport, + ...props + }: ComponentProps<"div"> & { + align?: string; + side?: string; + viewport?: boolean; + }) => { + const context = React.useContext(PopoverContext); + return context?.open ?

{children}
: null; + }, + PopoverTitle: (props: ComponentProps<"h2">) =>

, + PopoverDescription: (props: ComponentProps<"p">) =>

, + }; +}); + +vi.mock("@gitru/ui/components/combobox", async () => { + const React = await import("react"); + const ComboboxContext = React.createContext<{ + items: Author[]; + query: string; + setQuery: (query: string) => void; + value: Author[]; + onValueChange: (authors: Author[]) => void; + } | null>(null); + + return { + Combobox: ({ + children, + items, + value, + onValueChange, + }: { + children: ReactNode; + items: Author[]; + value: Author[]; + onValueChange: (authors: Author[]) => void; + }) => { + const [query, setQuery] = React.useState(""); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filteredItems = items.filter((item) => + `${item.name} ${item.email}` + .toLocaleLowerCase() + .includes(normalizedQuery), + ); + return ( + + {children} + + ); + }, + ComboboxChips: (props: ComponentProps<"div">) =>

, + ComboboxInput: ({ + showTrigger: _showTrigger, + onChange, + ...props + }: ComponentProps<"input"> & { showTrigger?: boolean }) => { + const context = React.useContext(ComboboxContext); + return ( + { + onChange?.(event); + context?.setQuery(event.target.value); + }} + /> + ); + }, + ComboboxEmpty: ({ children }: { children: ReactNode }) => { + const context = React.useContext(ComboboxContext); + return context?.items.length === 0 ?
{children}
: null; + }, + ComboboxList: ({ + children, + }: { + children: (author: Author) => ReactNode; + }) => { + const context = React.useContext(ComboboxContext); + return
{context?.items.map(children)}
; + }, + ComboboxItem: ({ + children, + value, + }: { + children: ReactNode; + value: Author; + }) => { + const context = React.useContext(ComboboxContext); + const selected = + context?.value.some( + (author) => + author.name === value.name && author.email === value.email, + ) ?? false; + return ( + + ); + }, + }; +}); + +function operation(overrides: Partial = {}): RepoOperation { + return { + kind: "clean", + isRebasing: false, + todo: [], + conflictPaths: [], + ...overrides, + }; +} + +function author(name: string, email: string): Author { + return { name, email }; +} + +function commit(overrides: Partial = {}): CommitInfo { + const currentAuthor = author("Ruru", "ruru@example.com"); + return { + id: "0123456789abcdef", + summary: "Existing summary", + body: "Existing body", + timestamp: 1, + authors: { + author: currentAuthor, + committer: currentAuthor, + co_authors: [], + }, + ...overrides, + }; +} + +function setDraft({ + title, + description = "", + coAuthors = [], +}: { + title: string; + description?: string; + coAuthors?: Array<[string, string]>; +}) { + useCommitDraftStore.setState({ + repoKey: "repo-1", + title, + description, + coAuthors, + mode: "create", + amendCommitId: null, + draftBeforeAmend: null, + autofillKey: null, + }); +} + +async function openCommitOptions(user: ReturnType) { + await user.click(screen.getByRole("button", { name: "Commit options" })); + return screen.getByRole("menu"); +} + +beforeEach(() => { + mocks.authors = [author("Alice", "alice@example.com")]; + mocks.createCommit.mockResolvedValue("new-commit-id"); + mocks.gitAdd.mockResolvedValue(undefined); + mocks.lastCommit = commit(); + mocks.refetchLastCommit.mockImplementation(async () => ({ + data: mocks.lastCommit, + error: null, + })); + mocks.operation = operation(); + mocks.status = { files: [] }; + setDraft({ title: "" }); +}); + +describe("WriteCommitBox", () => { + test("uses a deliberate confirmation for empty commits and never stages visible files", async () => { + const user = userEvent.setup(); + setDraft({ title: "Checkpoint" }); + render(); + + let menu = await openCommitOptions(user); + await user.click( + within(menu).getByRole("menuitem", { name: "Empty Commit…" }), + ); + + let dialog = screen.getByRole("alertdialog"); + expect( + within(dialog).getByRole("heading", { name: "Create an empty commit?" }), + ).toBeInTheDocument(); + expect(mocks.createCommit).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + + menu = await openCommitOptions(user); + await user.click( + within(menu).getByRole("menuitem", { name: "Empty Commit…" }), + ); + dialog = screen.getByRole("alertdialog"); + await user.click( + within(dialog).getByRole("button", { name: "Create empty commit" }), + ); + + await waitFor(() => { + expect(mocks.createCommit).toHaveBeenCalledWith({ + commitMeta: { + title: "Checkpoint", + description: "", + co_authors: [], + }, + allowEmpty: true, + amend: false, + expectedHead: undefined, + }); + }); + expect(mocks.gitAdd).not.toHaveBeenCalled(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + + test("rechecks empty-commit eligibility while confirmation is open", async () => { + const user = userEvent.setup(); + setDraft({ title: "Checkpoint" }); + const { rerender } = render( + , + ); + + const menu = await openCommitOptions(user); + await user.click( + within(menu).getByRole("menuitem", { name: "Empty Commit…" }), + ); + + mocks.status = { + files: [{ path: "newly-staged.txt", status: ["IndexNew"] }], + }; + rerender(); + + const confirm = within(screen.getByRole("alertdialog")).getByRole( + "button", + { name: "Create empty commit" }, + ); + expect(confirm).toBeDisabled(); + await user.click(confirm); + expect(mocks.createCommit).not.toHaveBeenCalled(); + expect(mocks.gitAdd).not.toHaveBeenCalled(); + }); + + test("sends no expected HEAD for a regular commit", async () => { + const user = userEvent.setup(); + mocks.status = { + files: [{ path: "staged.txt", status: ["IndexModified"] }], + }; + setDraft({ title: "Regular commit", description: "Body" }); + render(); + + await user.click(screen.getByRole("button", { name: "Commit to main" })); + + await waitFor(() => { + expect(mocks.createCommit).toHaveBeenCalledWith({ + commitMeta: { + title: "Regular commit", + description: "Body", + co_authors: [], + }, + allowEmpty: false, + amend: false, + expectedHead: undefined, + }); + }); + }); + + test.each([ + { staged: false, modeLabel: "Amending 0123456 (message only)" }, + { staged: true, modeLabel: "Amending 0123456 with staged changes" }, + ])("prefills and submits amend mode without auto-staging (staged: $staged)", async ({ + staged, + modeLabel, + }) => { + const user = userEvent.setup(); + const existingCoAuthor = author("Existing Pair", "pair@example.com"); + mocks.lastCommit = commit({ + body: [ + "Existing body", + "# Kept body line", + "", + "Co-authored-by: Existing Pair ", + "Signed-off-by: Ruru ", + ].join("\n"), + authors: { + author: author("Ruru", "ruru@example.com"), + committer: author("Ruru", "ruru@example.com"), + co_authors: [existingCoAuthor], + }, + }); + mocks.status = staged + ? { + files: [{ path: "staged.txt", status: ["IndexModified"] }], + } + : { files: [] }; + setDraft({ title: "Draft summary", description: "Draft body" }); + render(); + + const menu = await openCommitOptions(user); + await user.click( + within(menu).getByRole("menuitem", { name: "Amend Last Commit" }), + ); + + expect(screen.getByRole("textbox", { name: "Commit summary" })).toHaveValue( + "Existing summary", + ); + expect( + screen.getByRole("textbox", { name: "Commit description" }), + ).toHaveValue( + "Existing body\n# Kept body line\n\nSigned-off-by: Ruru ", + ); + expect(screen.getByRole("status")).toHaveTextContent(modeLabel); + expect( + screen.getByRole("button", { name: "Remove co-author Existing Pair" }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Amend last commit" })); + + await waitFor(() => { + expect(mocks.createCommit).toHaveBeenCalledWith({ + commitMeta: { + title: "Existing summary", + description: + "Existing body\n# Kept body line\n\nSigned-off-by: Ruru ", + co_authors: [["Existing Pair", "pair@example.com"]], + }, + allowEmpty: false, + amend: true, + expectedHead: "0123456789abcdef", + }); + }); + expect(mocks.gitAdd).not.toHaveBeenCalled(); + }); + + test("canceling amend restores the draft that was being written", async () => { + const user = userEvent.setup(); + setDraft({ + title: "Draft summary", + description: "Draft body", + coAuthors: [["Draft Pair", "draft@example.com"]], + }); + render(); + + const menu = await openCommitOptions(user); + await user.click( + within(menu).getByRole("menuitem", { name: "Amend Last Commit" }), + ); + await user.click(screen.getByRole("button", { name: "Cancel amend" })); + + expect(screen.getByRole("textbox", { name: "Commit summary" })).toHaveValue( + "Draft summary", + ); + expect( + screen.getByRole("textbox", { name: "Commit description" }), + ).toHaveValue("Draft body"); + expect( + screen.getByRole("button", { name: "Remove co-author Draft Pair" }), + ).toBeInTheDocument(); + }); + + test("refetches the paused commit before entering rebase amend mode", async () => { + const user = userEvent.setup(); + mocks.lastCommit = commit({ + id: "old-tip", + summary: "Before rebase", + }); + const freshCommit = commit({ + id: "fresh-replayed-tip", + summary: "Paused commit", + body: "Paused body", + authors: { + author: author("Ruru", "ruru@example.com"), + committer: author("Ruru", "ruru@example.com"), + co_authors: [author("Fresh Pair", "fresh@example.com")], + }, + }); + mocks.refetchLastCommit.mockResolvedValue({ + data: freshCommit, + error: null, + }); + mocks.operation = operation({ + kind: "rebaseInteractive", + isRebasing: true, + pauseReason: "edit", + pausedAt: "original-paused-commit", + commitMessage: "Paused commit\n\nPaused body", + }); + setDraft({ title: "Before operation refresh" }); + render(); + + const menu = await openCommitOptions(user); + await user.click( + within(menu).getByRole("menuitem", { name: "Amend Last Commit" }), + ); + + await waitFor(() => { + expect( + screen.getByRole("textbox", { name: "Commit summary" }), + ).toHaveValue("Paused commit"); + }); + expect(mocks.refetchLastCommit).toHaveBeenCalledOnce(); + expect( + screen.getByRole("button", { name: "Remove co-author Fresh Pair" }), + ).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent( + "Amending fresh-r (message only)", + ); + expect(useCommitDraftStore.getState().amendCommitId).toBe( + "fresh-replayed-tip", + ); + }); + + test("a rejected expected-HEAD amend preserves the full draft and amend mode", async () => { + const user = userEvent.setup(); + const existingCoAuthor = author("Existing Pair", "pair@example.com"); + mocks.lastCommit = commit({ + authors: { + author: author("Ruru", "ruru@example.com"), + committer: author("Ruru", "ruru@example.com"), + co_authors: [existingCoAuthor], + }, + }); + mocks.createCommit.mockRejectedValue( + new Error("HEAD changed since amend mode was opened"), + ); + setDraft({ title: "Draft summary" }); + render(); + + const menu = await openCommitOptions(user); + await user.click( + within(menu).getByRole("menuitem", { name: "Amend Last Commit" }), + ); + const summary = screen.getByRole("textbox", { name: "Commit summary" }); + const description = screen.getByRole("textbox", { + name: "Commit description", + }); + await user.clear(summary); + await user.type(summary, "Retried summary"); + await user.clear(description); + await user.type(description, "Retried body"); + await user.click(screen.getByRole("button", { name: "Amend last commit" })); + + await waitFor(() => expect(mocks.createCommit).toHaveBeenCalledOnce()); + expect(mocks.createCommit).toHaveBeenCalledWith({ + commitMeta: { + title: "Retried summary", + description: "Retried body", + co_authors: [["Existing Pair", "pair@example.com"]], + }, + allowEmpty: false, + amend: true, + expectedHead: "0123456789abcdef", + }); + expect(summary).toHaveValue("Retried summary"); + expect(description).toHaveValue("Retried body"); + expect( + screen.getByRole("button", { name: "Cancel amend" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Remove co-author Existing Pair" }), + ).toBeInTheDocument(); + expect(mocks.gitAdd).not.toHaveBeenCalled(); + }); + + test("fails closed and preserves the draft when amend mode has no expected HEAD", async () => { + const user = userEvent.setup(); + setDraft({ + title: "Unprotected amend", + description: "Must stay", + coAuthors: [["Pair", "pair@example.com"]], + }); + useCommitDraftStore.setState({ + mode: "amend", + amendCommitId: null, + draftBeforeAmend: null, + }); + render(); + + const submit = screen.getByRole("button", { name: "Amend last commit" }); + expect(submit).toBeDisabled(); + await user.click(submit); + + expect(mocks.createCommit).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox", { name: "Commit summary" })).toHaveValue( + "Unprotected amend", + ); + expect( + screen.getByRole("textbox", { name: "Commit description" }), + ).toHaveValue("Must stay"); + expect( + screen.getByRole("button", { name: "Remove co-author Pair" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel amend" })).toBeVisible(); + }); + + test("selects co-authors from a searchable picker and exposes the selection", async () => { + const user = userEvent.setup(); + mocks.authors = [ + author("Alice", "alice@example.com"), + author("Bob", "bob@example.com"), + ]; + setDraft({ title: "Pair work" }); + render(); + + await user.click(screen.getByRole("button", { name: "Add co-authors" })); + const search = screen.getByRole("searchbox", { name: "Search co-authors" }); + await user.type(search, "bob@example.com"); + expect(screen.queryByRole("option", { name: /Alice/ })).toBeNull(); + await user.click(screen.getByRole("option", { name: /Bob/ })); + + await user.clear(search); + await user.type(search, "Alice"); + await user.click(screen.getByRole("option", { name: /Alice/ })); + + expect( + screen.getByRole("button", { name: "Remove co-author Alice" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Remove co-author Bob" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Manage co-authors, 2 selected" }), + ).toBeInTheDocument(); + }); + + test("deduplicates same-email authors into one option and one trailer", async () => { + const user = userEvent.setup(); + mocks.authors = [ + author("Alice", "pair@example.com"), + author("Alicia", "PAIR@example.com"), + ]; + mocks.status = { + files: [{ path: "staged.txt", status: ["IndexModified"] }], + }; + setDraft({ title: "Pair work" }); + render(); + + await user.click(screen.getByRole("button", { name: "Add co-authors" })); + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent("Alice"); + await user.click(options[0]); + await user.click(screen.getByRole("button", { name: "Commit to main" })); + + await waitFor(() => { + expect(mocks.createCommit).toHaveBeenCalledWith({ + commitMeta: { + title: "Pair work", + description: "", + co_authors: [["Alice", "pair@example.com"]], + }, + allowEmpty: false, + amend: false, + expectedHead: undefined, + }); + }); + }); + + test("requires a non-whitespace summary and removes the inert Sparkles action", () => { + setDraft({ title: " " }); + render(); + + expect( + screen.getByRole("button", { name: "Add visible & Commit" }), + ).toBeDisabled(); + expect( + screen.queryByRole("button", { name: "Password requirements" }), + ).not.toBeInTheDocument(); + }); +}); + +describe("getCommitActionAvailability", () => { + test("blocks every commit mode when a clean operation reports conflicts", () => { + expect( + getCommitActionAvailability( + operation({ kind: "clean", conflictPaths: ["stash-conflict.txt"] }), + ), + ).toEqual({ + create: false, + empty: false, + amend: false, + isRebaseLike: false, + }); + }); + + test.each([ + "merge", + "revert", + "cherryPick", + ] as const)("allows regular commits after a resolved %s", (kind) => { + expect(getCommitActionAvailability(operation({ kind }))).toEqual({ + create: true, + empty: false, + amend: false, + isRebaseLike: false, + }); + }); + + test.each([ + "merge", + "revert", + "cherryPick", + ] as const)("blocks regular commits while a %s still has conflicts", (kind) => { + expect( + getCommitActionAvailability( + operation({ kind, conflictPaths: ["conflicted.txt"] }), + ).create, + ).toBe(false); + }); + + test("blocks ordinary commits during apply-mailbox and unknown operations", () => { + expect( + getCommitActionAvailability(operation({ kind: "applyMailbox" })).create, + ).toBe(false); + expect( + getCommitActionAvailability(operation({ kind: "other" })).create, + ).toBe(false); + }); + + test("allows only amend at a conflict-free rebase edit pause", () => { + expect( + getCommitActionAvailability( + operation({ + kind: "rebaseInteractive", + isRebasing: true, + pauseReason: "edit", + }), + ), + ).toEqual({ + create: false, + empty: false, + amend: true, + isRebaseLike: true, + }); + }); + + test("blocks amend at incompatible rebase pauses or while conflicts remain", () => { + expect( + getCommitActionAvailability( + operation({ + kind: "rebase", + isRebasing: true, + pauseReason: "reword", + }), + ).amend, + ).toBe(false); + expect( + getCommitActionAvailability( + operation({ + kind: "rebase", + isRebasing: true, + pauseReason: "edit", + conflictPaths: ["conflicted.txt"], + }), + ).amend, + ).toBe(false); + }); +}); + +test("rebuilds commit messages with co-author trailers", () => { + expect( + joinCommitMessage("Pair change", "Explains the change", [ + ["Alice", "alice@example.com"], + ["Bob", "bob@example.com"], + ]), + ).toBe( + "Pair change\n\nExplains the change\n\nCo-authored-by: Alice \nCo-authored-by: Bob ", + ); + expect( + joinCommitMessage("fix: preserve trailer spacing", "", [ + ["Alice", "alice@example.com"], + ]), + ).toBe( + "fix: preserve trailer spacing\n\nCo-authored-by: Alice ", + ); +}); + +test("extracts co-authors from a mixed trailing trailer block", () => { + const parsed = splitCommitMessage( + "Pair change\n\n# Details\nBody\n\nCo-authored-by: Alice \nSigned-off-by: Ruru \nChange-Id: I123456", + ); + expect(parsed).toEqual({ + title: "Pair change", + description: + "# Details\nBody\n\nSigned-off-by: Ruru \nChange-Id: I123456", + coAuthors: [["Alice", "alice@example.com"]], + }); + + const rebuilt = joinCommitMessage( + parsed.title, + parsed.description, + parsed.coAuthors, + ); + expect(rebuilt).toBe( + "Pair change\n\n# Details\nBody\n\nSigned-off-by: Ruru \nChange-Id: I123456\nCo-authored-by: Alice ", + ); + expect(splitCommitMessage(rebuilt)).toEqual(parsed); +}); diff --git a/crates/git/parsers/commit.rs b/crates/git/parsers/commit.rs index 0a056c1b..7d09231b 100644 --- a/crates/git/parsers/commit.rs +++ b/crates/git/parsers/commit.rs @@ -114,7 +114,9 @@ pub fn extract_co_authors(message: &str) -> Vec { for line in message.lines() { let line = line.trim(); - if (line.starts_with("Co-authored-by:") || line.starts_with("Co-Authored-By:")) + if line + .split_once(':') + .is_some_and(|(key, _)| key.eq_ignore_ascii_case("Co-authored-by")) && let Some(author) = parse_author_line(line) { co_authors.push(author); @@ -125,10 +127,11 @@ pub fn extract_co_authors(message: &str) -> Vec { } pub fn parse_author_line(line: &str) -> Option { - let line = line.split(':').nth(1)?.trim(); + let line = line.split_once(':')?.1.trim(); if let Some(email_start) = line.rfind('<') && let Some(email_end) = line.rfind('>') + && email_start < email_end { let name = line[..email_start].trim().to_string(); let email = line[email_start + 1..email_end].trim().to_string(); diff --git a/crates/git/runner.rs b/crates/git/runner.rs index 6ce87821..42f298a6 100644 --- a/crates/git/runner.rs +++ b/crates/git/runner.rs @@ -7,7 +7,7 @@ use std::sync::{ }; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::Mutex as AsyncMutex; +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; use tokio::time::{sleep, timeout}; #[derive(Clone, Copy)] @@ -40,6 +40,15 @@ pub struct GitCommandRunner { repo_path: PathBuf, } +/// A sequence of Git commands executed while holding the runner's per-repository lock. +/// +/// Use the transaction methods rather than calling [`GitCommandRunner`] again while this +/// value is alive; the regular runner methods would try to acquire the same lock again. +pub struct GitCommandTransaction { + repo_path: PathBuf, + _guard: OwnedMutexGuard<()>, +} + // TODO(ruru-m07): Consider allowing configuration of additional tool directories via environment variable or config file const DEFAULT_TOOL_DIRS: &[&str] = &[ "/opt/homebrew/bin", @@ -69,6 +78,17 @@ impl GitCommandRunner { run_git_command_async(&self.repo_path, args, None, options, &[]).await } + /// Hold the same per-repository lock used by the regular runner methods across + /// multiple commands that must be observed as one Gitru-internal operation. + pub async fn transaction(&self) -> Result { + let repo_lock = command_lock_for_repo(&self.repo_path)?; + let guard = repo_lock.lock_owned().await; + Ok(GitCommandTransaction { + repo_path: self.repo_path.clone(), + _guard: guard, + }) + } + /// Like [`Self::run_with_options`], but sets extra process environment variables. pub async fn run_with_env( &self, @@ -126,6 +146,35 @@ impl GitCommandRunner { } } +impl GitCommandTransaction { + pub async fn run_with_options( + &mut self, + args: &[&str], + options: GitRunOptions, + ) -> Result { + run_git_command_async_unlocked(&self.repo_path, args, None, options, &[]).await + } + + pub async fn run_with_input( + &mut self, + args: &[&str], + input: &str, + options: GitRunOptions, + ) -> Result { + run_git_command_async_unlocked(&self.repo_path, args, Some(input.as_bytes()), options, &[]) + .await + } + + pub async fn run_with_env( + &mut self, + args: &[&str], + options: GitRunOptions, + env: &[(&str, &str)], + ) -> Result { + run_git_command_async_unlocked(&self.repo_path, args, None, options, env).await + } +} + pub(crate) fn git_binary_path() -> Result { resolve_program_path("git") } @@ -721,6 +770,59 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn transaction_holds_repo_lock_across_commands() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + let output = std::process::Command::new("git") + .current_dir(temp_dir.path()) + .args(["init"]) + .output() + .expect("failed to init git"); + assert!(output.status.success(), "git init failed"); + + let runner = GitCommandRunner::new(temp_dir.path().to_str().unwrap()) + .expect("failed to create runner"); + let mut transaction = runner.transaction().await.expect("transaction lock"); + + let object_id = transaction + .run_with_input( + &["hash-object", "--stdin"], + "transaction input", + GitRunOptions::default_read(), + ) + .await + .expect("transaction command should not reacquire its own lock"); + assert!(!object_id.is_empty()); + + let waiting_runner = runner.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let mut waiting = tokio::spawn(async move { + started_tx.send(()).expect("start receiver dropped"); + waiting_runner + .run_with_options( + &["rev-parse", "--is-inside-work-tree"], + GitRunOptions::default_read(), + ) + .await + }); + started_rx.await.expect("waiting command did not start"); + + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut waiting) + .await + .is_err(), + "regular runner command should wait for the transaction lock" + ); + + drop(transaction); + let output = tokio::time::timeout(Duration::from_secs(2), waiting) + .await + .expect("waiting command did not resume") + .expect("waiting task failed") + .expect("waiting Git command failed"); + assert_eq!(output, "true"); + } + #[test] #[cfg(unix)] fn resolve_program_path_prefers_earlier_dirs() { diff --git a/crates/git/service/commit.rs b/crates/git/service/commit.rs index 37ac6e68..4d592ad9 100644 --- a/crates/git/service/commit.rs +++ b/crates/git/service/commit.rs @@ -1,11 +1,18 @@ use crate::cache::{CachePolicy, TTL_COMMIT_BY_ID, TTL_LAST_COMMIT}; use crate::context::RepoContext; -use crate::models::commit::{CommitInfo, CommitMessage, FullCommitInfo}; -use crate::parsers::commit::{parse_commit_record, parse_shortstat}; +use crate::models::commit::{Author, CommitInfo, CommitMessage, FullCommitInfo}; +use crate::models::operation::{RepoOperation, RepoOperationKind}; +use crate::models::rebase::RebasePauseReason; +use crate::parsers::commit::{COMMIT_STANDARD_FORMAT, parse_commit_record, parse_shortstat}; +use crate::parsers::history::parse_history_records; use crate::parsers::status::parse_name_status_z; use crate::runner::GitRunOptions; +use crate::service::operation::OperationService; +use std::collections::HashSet; use std::sync::Arc; +const RECENT_COMMIT_AUTHOR_LIMIT: &str = "100"; + pub struct CommitService { ctx: Arc, } @@ -106,7 +113,11 @@ impl CommitService { &self, commit_meta: &CommitMessage, allow_empty: bool, + amend: bool, + expected_head: Option<&str>, ) -> Result { + validate_commit_request(commit_meta, allow_empty, amend, expected_head)?; + let is_bare = self .ctx .runner @@ -121,55 +132,148 @@ impl CommitService { return Err("Cannot commit in a bare repository".to_string()); } - let message = self.build_commit_message(commit_meta); + // Keep the operation snapshot, amend lease, commit, and resulting HEAD read + // in one Gitru-internal critical section. Hooks and signing helpers still run + // normally inside `git commit`; arbitrary external Git processes are not gated. + let mut transaction = self.ctx.runner.transaction().await?; + let operation = OperationService::new(self.ctx.clone()).get_repo_operation()?; + validate_commit_operation(&operation, allow_empty, amend)?; + + if amend { + let expected_head = expected_head + .map(str::trim) + .filter(|head| !head.is_empty()) + .ok_or_else(|| "Expected HEAD is required when amending a commit".to_string())?; + let current_head = transaction + .run_with_options(&["rev-parse", "HEAD"], GitRunOptions::default_read()) + .await?; + if !current_head.eq_ignore_ascii_case(expected_head) { + return Err(format!( + "Cannot amend because HEAD changed since the commit was loaded (expected {}, found {}). Refresh and try again.", + short_oid(expected_head), + short_oid(¤t_head) + )); + } + } + + let message = Self::build_commit_message(commit_meta); let mut args = vec!["commit", "-F", "-"]; if allow_empty { args.push("--allow-empty"); + // `--allow-empty` alone still commits staged changes. `--only` with no + // paths makes Git create the empty commit from HEAD while preserving + // the user's real index exactly as it was. + args.push("--only"); + } + if amend { + args.push("--amend"); } - let output = self - .ctx - .runner - .run_with_input( - &args, - &message, - GitRunOptions::default_read().allow_exit_codes(&[1]), - ) + transaction + .run_with_input(&args, &message, GitRunOptions::default_read()) .await .map_err(|err| normalize_commit_error(&err))?; - if output.contains("nothing to commit") { - return Err("Nothing to commit (index matches HEAD)".to_string()); + let commit_id = transaction + .run_with_options(&["rev-parse", "HEAD"], GitRunOptions::default_read()) + .await?; + + self.ctx.cache.invalidate_all(); + drop(transaction); + Ok(commit_id) + } + + #[logger::logger] + pub async fn commit_authors(&self) -> Result, String> { + // Keep libgit2's non-Send config handle out of the future across the Git log await. + let configured_author = { + let repo = git2::Repository::open(&self.ctx.repo_path) + .map_err(|err| format!("Failed to open repository: {err}"))?; + let config = repo + .config() + .map_err(|err| format!("Failed to read Git configuration: {err}"))?; + match ( + config.get_string("user.name"), + config.get_string("user.email"), + ) { + (Ok(name), Ok(email)) => Some((name, email)), + _ => None, + } + }; + + let mut authors = Vec::new(); + let mut seen_emails = HashSet::new(); + + if let Some((name, email)) = configured_author { + push_unique_author(&mut authors, &mut seen_emails, name, email); } - let commit_id = self + let format_arg = format!("--format={COMMIT_STANDARD_FORMAT}"); + let output = self .ctx .runner - .run_with_options(&["rev-parse", "HEAD"], GitRunOptions::default_read()) - .await - .unwrap_or_default(); + .run_with_options( + &[ + "log", + "--all", + "-n", + RECENT_COMMIT_AUTHOR_LIMIT, + &format_arg, + ], + GitRunOptions::default_read(), + ) + .await?; + + for commit in parse_history_records(&output)? { + push_unique_author( + &mut authors, + &mut seen_emails, + commit.authors.author.name, + commit.authors.author.email, + ); + for co_author in commit.authors.co_authors { + push_unique_author( + &mut authors, + &mut seen_emails, + co_author.name, + co_author.email, + ); + } + } - self.ctx.cache.invalidate_all(); - Ok(commit_id) + Ok(authors) } - fn build_commit_message(&self, commit_meta: &CommitMessage) -> String { + fn build_commit_message(commit_meta: &CommitMessage) -> String { let mut msg = String::new(); + let description = commit_meta + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()); msg.push_str(commit_meta.title.trim()); msg.push('\n'); - if let Some(desc) = commit_meta.description.as_deref() { + if let Some(description) = description { msg.push('\n'); - msg.push_str(desc.trim()); + msg.push_str(description); msg.push('\n'); } if !commit_meta.co_authors.is_empty() { - msg.push('\n'); + // A trailer block is one paragraph. Keep the usual blank separator + // after prose, but extend an existing block instead of splitting it. + if description.is_none_or(|description| !ends_with_git_trailer(description)) { + msg.push('\n'); + } for (name, email) in &commit_meta.co_authors { - msg.push_str(&format!("Co-authored-by: {name} <{email}>\n")); + msg.push_str(&format!( + "Co-authored-by: {} <{}>\n", + name.trim(), + email.trim() + )); } } @@ -177,6 +281,189 @@ impl CommitService { } } +fn ends_with_git_trailer(description: &str) -> bool { + description + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .is_some_and(is_git_trailer_line) +} + +fn is_git_trailer_line(line: &str) -> bool { + let Some((token, value)) = line.split_once(':') else { + return false; + }; + let mut token_chars = token.chars(); + + token_chars + .next() + .is_some_and(|ch| ch.is_ascii_alphanumeric()) + && token_chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + && !value.trim_start().is_empty() +} + +fn validate_commit_request( + commit_meta: &CommitMessage, + allow_empty: bool, + amend: bool, + expected_head: Option<&str>, +) -> Result<(), String> { + if amend && allow_empty { + return Err("Amend and allow-empty cannot be used together".to_string()); + } + match (amend, expected_head.map(str::trim)) { + (true, None | Some("")) => { + return Err("Expected HEAD is required when amending a commit".to_string()); + } + (false, Some(_)) => { + return Err("Expected HEAD can only be provided when amending a commit".to_string()); + } + _ => {} + } + + let title = commit_meta.title.trim(); + if title.is_empty() { + return Err("Commit summary is required".to_string()); + } + if title.contains('\n') || title.contains('\r') { + return Err("Commit summary must be a single line".to_string()); + } + + for (index, (name, email)) in commit_meta.co_authors.iter().enumerate() { + validate_co_author(name, email) + .map_err(|err| format!("Invalid co-author {}: {err}", index + 1))?; + } + + Ok(()) +} + +fn validate_co_author(name: &str, email: &str) -> Result<(), String> { + let name = name.trim(); + if name.is_empty() { + return Err("name is required".to_string()); + } + if name.contains(['<', '>']) || name.chars().any(char::is_control) { + return Err("name contains characters that are not valid in a Git trailer".to_string()); + } + + // Check the untrimmed input first so leading or trailing control characters + // cannot be normalized into an apparently valid trailer address. + if email.chars().any(char::is_control) { + return Err("email contains characters that are not valid in a Git trailer".to_string()); + } + + let email = email.trim(); + if email.is_empty() { + return Err("email is required".to_string()); + } + if email.contains(['<', '>']) || email.chars().any(char::is_whitespace) { + return Err("email contains characters that are not valid in a Git trailer".to_string()); + } + + let mut parts = email.split('@'); + let local = parts.next().unwrap_or_default(); + let domain = parts.next().unwrap_or_default(); + if local.is_empty() || domain.is_empty() || parts.next().is_some() { + return Err("email must contain one @ with text on both sides".to_string()); + } + + Ok(()) +} + +fn validate_commit_operation( + operation: &RepoOperation, + allow_empty: bool, + amend: bool, +) -> Result<(), String> { + if !operation.conflict_paths.is_empty() { + let action = if amend { + "amend a commit" + } else if allow_empty { + "create an empty commit" + } else { + "create a commit" + }; + return Err(format!( + "Cannot {action} while {} has unresolved conflicts; resolve all conflicted paths first", + operation_label(&operation.kind) + )); + } + + if allow_empty && operation.kind != RepoOperationKind::Clean { + return Err(format!( + "Cannot create an empty commit while {} is in progress", + operation_label(&operation.kind) + )); + } + + if amend { + let clean = operation.kind == RepoOperationKind::Clean; + let rebase_edit = operation.is_rebasing + && operation.pause_reason == Some(RebasePauseReason::Edit) + && operation.conflict_paths.is_empty(); + if !clean && !rebase_edit { + return Err(format!( + "Cannot amend a commit while {} is in progress", + operation_label(&operation.kind) + )); + } + return Ok(()); + } + + if operation.is_rebasing + || matches!( + operation.kind, + RepoOperationKind::ApplyMailbox | RepoOperationKind::Other + ) + { + return Err(format!( + "Cannot create a commit while {} is in progress; use the operation controls instead", + operation_label(&operation.kind) + )); + } + + Ok(()) +} + +fn operation_label(kind: &RepoOperationKind) -> &'static str { + match kind { + RepoOperationKind::Clean => "no operation", + RepoOperationKind::Merge => "a merge", + RepoOperationKind::Revert => "a revert", + RepoOperationKind::CherryPick => "a cherry-pick", + RepoOperationKind::Bisect => "a bisect", + RepoOperationKind::Rebase + | RepoOperationKind::RebaseInteractive + | RepoOperationKind::RebaseMerge => "a rebase", + RepoOperationKind::ApplyMailbox => "mailbox application", + RepoOperationKind::Other => "another Git operation", + } +} + +fn push_unique_author( + authors: &mut Vec, + seen_emails: &mut HashSet, + name: String, + email: String, +) { + let name = name.trim(); + let email = email.trim(); + if validate_co_author(name, email).is_err() { + return; + } + + if seen_emails.insert(email.to_ascii_lowercase()) { + authors.push(Author { + name: name.to_string(), + email: email.to_string(), + }); + } +} + +fn short_oid(oid: &str) -> String { + oid.chars().take(12).collect() +} + fn normalize_commit_error(err: &str) -> String { if let Some(helper) = signing_helper_from_error(err) { return format!( @@ -220,6 +507,12 @@ fn parse_cannot_run_helper(err: &str) -> Option { mod tests { use super::*; + fn operation(kind: RepoOperationKind) -> RepoOperation { + let mut operation = RepoOperation::clean(); + operation.kind = kind; + operation + } + #[test] fn normalize_commit_error_reports_missing_helper() { let err = "error: cannot run gg: No such file or directory\nerror: gg failed to sign the data: (no gpg output)\nfatal: failed to write commit object"; @@ -236,4 +529,143 @@ mod tests { assert_eq!(normalized, err); } + + #[test] + fn commit_message_separates_prose_from_co_author_trailers() { + let message = CommitService::build_commit_message(&CommitMessage { + title: "Pair change".to_string(), + description: Some("Explains the change".to_string()), + co_authors: vec![("Alice".to_string(), "alice@example.com".to_string())], + }); + + assert_eq!( + message, + "Pair change\n\nExplains the change\n\nCo-authored-by: Alice \n" + ); + } + + #[test] + fn commit_message_extends_existing_trailer_block() { + let message = CommitService::build_commit_message(&CommitMessage { + title: "Pair change".to_string(), + description: Some( + "Explains the change\n\nSigned-off-by: Ruru ".to_string(), + ), + co_authors: vec![("Alice".to_string(), "alice@example.com".to_string())], + }); + + assert_eq!( + message, + "Pair change\n\nExplains the change\n\nSigned-off-by: Ruru \nCo-authored-by: Alice \n" + ); + } + + #[test] + fn validate_co_author_rejects_invalid_trailer_characters() { + assert!(validate_co_author("", "alice@example.com").is_err()); + assert!(validate_co_author("Alice ", "alice@example.com").is_err()); + assert!(validate_co_author("Alice\nCo-authored-by: Mallory", "alice@example.com").is_err()); + assert!(validate_co_author("Alice", "alice @example.com").is_err()); + assert!(validate_co_author("Alice", "alice@example.com\nInjected").is_err()); + assert!(validate_co_author("Alice", "alice@example.com\n").is_err()); + assert!(validate_co_author("Alice", "alice@example.com\u{0007}").is_err()); + assert!(validate_co_author("Alice", "alice@example.com").is_ok()); + } + + #[test] + fn request_validation_requires_expected_head_only_for_amend() { + let message = CommitMessage { + title: "Valid summary".to_string(), + description: None, + co_authors: vec![], + }; + + assert!(validate_commit_request(&message, false, true, None).is_err()); + assert!(validate_commit_request(&message, false, true, Some(" ")).is_err()); + assert!(validate_commit_request(&message, false, false, Some("abc123")).is_err()); + assert!(validate_commit_request(&message, false, true, Some("abc123")).is_ok()); + } + + #[test] + fn operation_validation_preserves_standard_completion_commits() { + for kind in [ + RepoOperationKind::Clean, + RepoOperationKind::Merge, + RepoOperationKind::Revert, + RepoOperationKind::CherryPick, + RepoOperationKind::Bisect, + ] { + assert!(validate_commit_operation(&operation(kind), false, false).is_ok()); + } + } + + #[test] + fn operation_validation_blocks_ordinary_commits_in_incompatible_states() { + for kind in [ + RepoOperationKind::Rebase, + RepoOperationKind::RebaseInteractive, + RepoOperationKind::RebaseMerge, + RepoOperationKind::ApplyMailbox, + RepoOperationKind::Other, + ] { + let mut operation = operation(kind); + operation.is_rebasing = matches!( + operation.kind, + RepoOperationKind::Rebase + | RepoOperationKind::RebaseInteractive + | RepoOperationKind::RebaseMerge + ); + assert!(validate_commit_operation(&operation, false, false).is_err()); + } + } + + #[test] + fn operation_validation_blocks_completion_commits_until_conflicts_are_resolved() { + for kind in [ + RepoOperationKind::Merge, + RepoOperationKind::Revert, + RepoOperationKind::CherryPick, + ] { + let mut operation = operation(kind); + operation.conflict_paths.push("conflicted.txt".to_string()); + + let error = validate_commit_operation(&operation, false, false).unwrap_err(); + assert!(error.contains("unresolved conflicts")); + + operation.conflict_paths.clear(); + assert!(validate_commit_operation(&operation, false, false).is_ok()); + } + } + + #[test] + fn operation_validation_only_allows_special_modes_when_compatible() { + assert!( + validate_commit_operation(&operation(RepoOperationKind::Clean), true, false).is_ok() + ); + assert!( + validate_commit_operation(&operation(RepoOperationKind::Merge), true, false).is_err() + ); + assert!( + validate_commit_operation(&operation(RepoOperationKind::Merge), false, true).is_err() + ); + + let mut clean_with_conflicts = operation(RepoOperationKind::Clean); + clean_with_conflicts + .conflict_paths + .push("stash-conflict.txt".to_string()); + assert!(validate_commit_operation(&clean_with_conflicts, false, false).is_err()); + assert!(validate_commit_operation(&clean_with_conflicts, true, false).is_err()); + assert!(validate_commit_operation(&clean_with_conflicts, false, true).is_err()); + + let mut rebase_edit = operation(RepoOperationKind::RebaseInteractive); + rebase_edit.is_rebasing = true; + rebase_edit.pause_reason = Some(RebasePauseReason::Edit); + assert!(validate_commit_operation(&rebase_edit, false, true).is_ok()); + assert!(validate_commit_operation(&rebase_edit, false, false).is_err()); + + rebase_edit + .conflict_paths + .push("conflicted.txt".to_string()); + assert!(validate_commit_operation(&rebase_edit, false, true).is_err()); + } } diff --git a/crates/git/service/graph.rs b/crates/git/service/graph.rs index add57a95..afb9c6fa 100644 --- a/crates/git/service/graph.rs +++ b/crates/git/service/graph.rs @@ -9,6 +9,7 @@ use crate::models::history::{ CommitActivityItem, CommitActivityResponse, GraphPaging, GraphRef as GraphRefDto, GraphRow, GraphRowType, HistoryGraphResponse, ParentEdge, Swimlane as SwimlaneDto, }; +use crate::parsers::commit::extract_co_authors; use crate::parsers::graph::{LOG_FORMAT, parse_log_entries}; use crate::runner::{git_binary_path, git_path_env}; @@ -692,38 +693,6 @@ fn filter_refs(refs: &[GraphRefDto], kind: GraphRefType, only_head: bool) -> Vec .collect() } -fn extract_co_authors(body: &str) -> Vec { - let mut co_authors = Vec::new(); - - for line in body.lines() { - let line = line.trim(); - if (line.starts_with("Co-authored-by:") || line.starts_with("Co-Authored-By:")) - && let Some(author) = parse_author_line(line) - { - co_authors.push(author); - } - } - - co_authors -} - -fn parse_author_line(line: &str) -> Option { - let line = line.split(':').nth(1)?.trim(); - - if let Some(email_start) = line.rfind('<') - && let Some(email_end) = line.rfind('>') - { - let name = line[..email_start].trim().to_string(); - let email = line[email_start + 1..email_end].trim().to_string(); - return Some(Author { name, email }); - } - - Some(Author { - name: line.to_string(), - email: String::new(), - }) -} - fn run_git_command(repo_path: &str, args: &[&str]) -> Result { let git_binary = git_binary_path()?; let git_path_env = git_path_env()?; diff --git a/crates/git/service/operation.rs b/crates/git/service/operation.rs index 1ed01be1..1b103e6e 100644 --- a/crates/git/service/operation.rs +++ b/crates/git/service/operation.rs @@ -54,9 +54,10 @@ impl OperationService { ) { let mut op = RepoOperation::clean(); op.kind = kind; - if !matches!(op.kind, RepoOperationKind::Clean) { - op.conflict_paths = conflict_paths_from_index(&repo)?; - } + // An unmerged index can outlive the sequencer that produced it (for + // example, a conflicted stash apply leaves RepositoryState::Clean). + // Always report those paths so commit operations fail closed. + op.conflict_paths = conflict_paths_from_index(&repo)?; return Ok(op); } diff --git a/crates/git/service/rebase.rs b/crates/git/service/rebase.rs index 803e0148..c801b78a 100644 --- a/crates/git/service/rebase.rs +++ b/crates/git/service/rebase.rs @@ -13,7 +13,7 @@ use crate::{ RebaseProgressPhase, RebaseStartRequest, }, }, - runner::GitRunOptions, + runner::{GitCommandTransaction, GitRunOptions}, service::operation::{ GITRU_REBASE_DIR, OperationService, conflict_paths_from_index, read_trimmed, }, @@ -103,13 +103,17 @@ impl RebaseService { request: RebaseStartRequest, app: Option, ) -> Result { + let transaction = self.ctx.runner.transaction().await?; let path = self.ctx.repo_path.clone(); - let result = - tokio::task::spawn_blocking(move || start_rebase_blocking(&path, request, app)) - .await - .map_err(|e| format!("Rebase task join error: {e}"))?; + let (transaction, result) = tokio::task::spawn_blocking(move || { + let result = start_rebase_blocking(&path, request, app); + (transaction, result) + }) + .await + .map_err(|e| format!("Rebase task join error: {e}"))?; self.ctx.cache.invalidate_all(); + drop(transaction); result } @@ -118,16 +122,21 @@ impl RebaseService { message: Option, app: Option, ) -> Result { + let mut transaction = self.ctx.runner.transaction().await?; + // Select the implementation from state observed under the same lock as the + // mutation. This prevents an amend/continue race at an edit pause. let op = self.get_repo_operation()?; match op.engine { Some(RebaseEngine::Gitru) => { let path = self.ctx.repo_path.clone(); - let result = tokio::task::spawn_blocking(move || { - continue_gitru_blocking(&path, message, app) + let (transaction, result) = tokio::task::spawn_blocking(move || { + let result = continue_gitru_blocking(&path, message, app); + (transaction, result) }) .await .map_err(|e| format!("Rebase continue join error: {e}"))?; self.ctx.cache.invalidate_all(); + drop(transaction); result } Some(RebaseEngine::Git) | None => { @@ -139,21 +148,29 @@ impl RebaseService { if !use_cli { let path = self.ctx.repo_path.clone(); let msg = message.clone(); - let git2_result = tokio::task::spawn_blocking(move || { - continue_native_git2_blocking(&path, msg) - }) - .await - .map_err(|e| format!("Rebase continue join error: {e}"))?; + let (returned_transaction, git2_result) = + tokio::task::spawn_blocking(move || { + let result = continue_native_git2_blocking(&path, msg); + (transaction, result) + }) + .await + .map_err(|e| format!("Rebase continue join error: {e}"))?; + transaction = returned_transaction; if git2_result.is_ok() { self.ctx.cache.invalidate_all(); + drop(transaction); return git2_result; } } // Exit code 1 is normal when continue pauses again on conflicts. let cli_result = self - .cli_rebase_command(&["rebase", "--continue"], message.as_deref()) + .cli_rebase_command( + &mut transaction, + &["rebase", "--continue"], + message.as_deref(), + ) .await; self.ctx.cache.invalidate_all(); match cli_result { @@ -176,18 +193,24 @@ impl RebaseService { } pub async fn skip(&self, app: Option) -> Result { + let mut transaction = self.ctx.runner.transaction().await?; let op = self.get_repo_operation()?; match op.engine { Some(RebaseEngine::Gitru) => { let path = self.ctx.repo_path.clone(); - let result = tokio::task::spawn_blocking(move || skip_gitru_blocking(&path, app)) - .await - .map_err(|e| format!("Rebase skip join error: {e}"))?; + let (transaction, result) = tokio::task::spawn_blocking(move || { + let result = skip_gitru_blocking(&path, app); + (transaction, result) + }) + .await + .map_err(|e| format!("Rebase skip join error: {e}"))?; self.ctx.cache.invalidate_all(); + drop(transaction); result } Some(RebaseEngine::Git) | None => { - self.cli_rebase_command(&["rebase", "--skip"], None).await?; + self.cli_rebase_command(&mut transaction, &["rebase", "--skip"], None) + .await?; self.ctx.cache.invalidate_all(); let op = self.get_repo_operation()?; if !op.is_rebasing { @@ -199,18 +222,23 @@ impl RebaseService { } pub async fn abort(&self, app: Option) -> Result { + let mut transaction = self.ctx.runner.transaction().await?; let op = self.get_repo_operation()?; match op.engine { Some(RebaseEngine::Gitru) => { let path = self.ctx.repo_path.clone(); - let result = tokio::task::spawn_blocking(move || abort_gitru_blocking(&path, app)) - .await - .map_err(|e| format!("Rebase abort join error: {e}"))?; + let (transaction, result) = tokio::task::spawn_blocking(move || { + let result = abort_gitru_blocking(&path, app); + (transaction, result) + }) + .await + .map_err(|e| format!("Rebase abort join error: {e}"))?; self.ctx.cache.invalidate_all(); + drop(transaction); result } Some(RebaseEngine::Git) | None => { - self.cli_rebase_command(&["rebase", "--abort"], None) + self.cli_rebase_command(&mut transaction, &["rebase", "--abort"], None) .await?; self.ctx.cache.invalidate_all(); let _ = restore_autostash_at(&self.ctx.repo_path); @@ -246,7 +274,24 @@ impl RebaseService { }) } - pub fn update_todo(&self, entries: Vec) -> Result { + pub async fn update_todo( + &self, + entries: Vec, + ) -> Result { + let transaction = self.ctx.runner.transaction().await?; + let result = self.update_todo_locked(entries); + drop(transaction); + result + } + + pub async fn set_commit_message(&self, message: &str) -> Result<(), String> { + let transaction = self.ctx.runner.transaction().await?; + let result = self.set_commit_message_locked(message); + drop(transaction); + result + } + + fn update_todo_locked(&self, entries: Vec) -> Result { let gitru_dir = self.operation().gitru_rebase_dir()?; if gitru_dir.is_dir() { validate_todo(&entries)?; @@ -275,7 +320,7 @@ impl RebaseService { self.get_repo_operation() } - pub fn set_commit_message(&self, message: &str) -> Result<(), String> { + fn set_commit_message_locked(&self, message: &str) -> Result<(), String> { let dir = self.operation().gitru_rebase_dir()?; if dir.is_dir() { fs::write(dir.join("message"), message) @@ -293,33 +338,19 @@ impl RebaseService { Err("No rebase message file available".to_string()) } - pub fn resolve_conflict(&self, request: ConflictResolveRequest) -> Result<(), String> { + pub async fn resolve_conflict(&self, request: ConflictResolveRequest) -> Result<(), String> { crate::runner::validate_relative_path(&request.path)?; - let repo = open_repo(&self.ctx.repo_path)?; - let path = request.path.as_str(); - match request.strategy { - ConflictResolveStrategy::Ours => { - checkout_stage(&repo, path, 2)?; - add_path(&repo, path)?; - } - ConflictResolveStrategy::Theirs => { - checkout_stage(&repo, path, 3)?; - add_path(&repo, path)?; - } - ConflictResolveStrategy::Union => { - let ours = stage_blob_content(&repo, path, 2)?; - let theirs = stage_blob_content(&repo, path, 3)?; - let combined = format!("{ours}\n======= union ======\n{theirs}"); - let abs = Path::new(&self.ctx.repo_path).join(path); - if let Some(parent) = abs.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create parent for {path}: {e}"))?; - } - fs::write(&abs, combined).map_err(|e| format!("Failed to write union: {e}"))?; - add_path(&repo, path)?; - } - } - Ok(()) + let transaction = self.ctx.runner.transaction().await?; + let repo_path = self.ctx.repo_path.clone(); + let (transaction, result) = tokio::task::spawn_blocking(move || { + let result = resolve_conflict_blocking(&repo_path, request); + (transaction, result) + }) + .await + .map_err(|e| format!("Conflict resolution task join error: {e}"))?; + self.ctx.cache.invalidate_all(); + drop(transaction); + result } fn is_native_interactive_rebase(&self) -> bool { @@ -331,6 +362,7 @@ impl RebaseService { async fn cli_rebase_command( &self, + transaction: &mut GitCommandTransaction, args: &[&str], message: Option<&str>, ) -> Result { @@ -345,8 +377,7 @@ impl RebaseService { .map_err(|e| format!("Failed to write rebase message: {e}"))?; } } - self.ctx - .runner + transaction .run_with_env( args, GitRunOptions::default_read() @@ -358,6 +389,37 @@ impl RebaseService { } } +fn resolve_conflict_blocking( + repo_path: &str, + request: ConflictResolveRequest, +) -> Result<(), String> { + let repo = open_repo(repo_path)?; + let path = request.path.as_str(); + match request.strategy { + ConflictResolveStrategy::Ours => { + checkout_stage(&repo, path, 2)?; + add_path(&repo, path)?; + } + ConflictResolveStrategy::Theirs => { + checkout_stage(&repo, path, 3)?; + add_path(&repo, path)?; + } + ConflictResolveStrategy::Union => { + let ours = stage_blob_content(&repo, path, 2)?; + let theirs = stage_blob_content(&repo, path, 3)?; + let combined = format!("{ours}\n======= union ======\n{theirs}"); + let abs = Path::new(repo_path).join(path); + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create parent for {path}: {e}"))?; + } + fs::write(&abs, combined).map_err(|e| format!("Failed to write union: {e}"))?; + add_path(&repo, path)?; + } + } + Ok(()) +} + fn open_repo(path: &str) -> Result { Repository::open(path).map_err(|e| format!("Failed to open repository: {e}")) } diff --git a/crates/git/service/rebase_tests.rs b/crates/git/service/rebase_tests.rs index 1d0f6a82..cc9b0fc2 100644 --- a/crates/git/service/rebase_tests.rs +++ b/crates/git/service/rebase_tests.rs @@ -79,6 +79,40 @@ fn detects_clean_repo() { )); } +#[test] +#[serial] +fn detects_conflicted_index_even_when_repository_state_is_clean() { + let (_dir, path) = init_repo(); + + fs::write(path.join("file.txt"), "stashed side\n").unwrap(); + git(&path, &["stash", "push", "-m", "conflicting stash"]); + fs::write(path.join("file.txt"), "committed side\n").unwrap(); + git(&path, &["add", "file.txt"]); + git(&path, &["commit", "-m", "diverge from stash"]); + + let apply = Command::new("git") + .args(["stash", "apply"]) + .current_dir(&path) + .output() + .expect("git stash apply failed to start"); + assert!( + !apply.status.success(), + "stash apply should leave an unmerged index" + ); + + let repo = git2::Repository::open(&path).unwrap(); + assert_eq!(repo.state(), git2::RepositoryState::Clean); + drop(repo); + + let ctx = Arc::new(RepoContext::new(path.to_str().unwrap()).unwrap()); + let op = OperationService::new(ctx).get_repo_operation().unwrap(); + assert!(matches!( + op.kind, + crate::models::operation::RepoOperationKind::Clean + )); + assert_eq!(op.conflict_paths, vec!["file.txt"]); +} + #[test] #[serial] fn aborts_external_rebase_and_restores_git_autostash() { @@ -168,6 +202,59 @@ fn plain_git2_rebase_succeeds() { assert_eq!(git(&path, &["branch", "--show-current"]), "feature"); } +#[test] +#[serial] +fn direct_rebase_start_waits_for_runner_transaction() { + let (_dir, path) = init_repo(); + git(&path, &["checkout", "-b", "feature"]); + let feature_commit = commit_file(&path, "a.txt", "a\n", "feat a"); + let original_head = git(&path, &["rev-parse", "HEAD"]); + let onto = git(&path, &["rev-parse", "main"]); + + let ctx = Arc::new(RepoContext::new(path.to_str().unwrap()).unwrap()); + let runner = ctx.runner.clone(); + let rebase = RebaseService::new(ctx); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + let transaction = runner.transaction().await.unwrap(); + let request = RebaseStartRequest { + onto, + upstream: None, + entries: Some(vec![RebasePlanEntry { + action: RebaseAction::Edit, + commit: feature_commit, + message: Some("feat a".into()), + }]), + autostash: false, + }; + + let mut start = Box::pin(rebase.start(request, None)); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), start.as_mut()) + .await + .is_err(), + "direct libgit2 rebase should wait for the runner transaction" + ); + assert_eq!(git(&path, &["rev-parse", "HEAD"]), original_head); + assert!(!path.join(".git/gitru-rebase").exists()); + + drop(transaction); + let operation = tokio::time::timeout(std::time::Duration::from_secs(10), start) + .await + .expect("rebase start did not resume") + .expect("rebase start failed"); + assert!(operation.is_rebasing); + assert_eq!( + operation.pause_reason, + Some(crate::models::rebase::RebasePauseReason::Edit) + ); + }); +} + #[test] #[serial] fn abort_plain_git2_rebase_restores_the_original_branch() { @@ -435,7 +522,11 @@ fn update_native_interactive_todo_actions() { if let Some(last) = entries.last_mut() { last.action = RebaseAction::Drop; } - let updated = rebase.update_todo(entries).unwrap(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let updated = rt.block_on(rebase.update_todo(entries)).unwrap(); assert!( updated .todo diff --git a/crates/git/tests/commit_service.rs b/crates/git/tests/commit_service.rs index d1e8fd1f..0e36914b 100644 --- a/crates/git/tests/commit_service.rs +++ b/crates/git/tests/commit_service.rs @@ -9,6 +9,8 @@ use git::context::RepoContext; use git::models::commit::CommitMessage; use git::service::commit::CommitService; use serial_test::serial; +use std::io::Write; +use std::process::{Command, Stdio}; use std::sync::Arc; fn setup_commit_service(repo: &TestRepo) -> CommitService { @@ -87,7 +89,7 @@ fn create_commit_simple() { description: None, co_authors: vec![], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); @@ -113,13 +115,19 @@ fn create_commit_with_co_author() { description: None, co_authors: vec![("Alice".to_string(), "alice@example.com".to_string())], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); - // Verify the commit was created with co-author + // Verify the canonical trailer is parsed back into structured history data. let last = service.last_commit().await.unwrap(); - assert!(last.body.contains("Co-authored-by:") || last.summary.contains("Pair")); + assert!( + last.body + .contains("Co-authored-by: Alice ") + ); + assert_eq!(last.authors.co_authors.len(), 1); + assert_eq!(last.authors.co_authors[0].name, "Alice"); + assert_eq!(last.authors.co_authors[0].email, "alice@example.com"); }); } @@ -142,7 +150,7 @@ fn create_commit_with_multiple_co_authors() { ("Bob".to_string(), "bob@example.com".to_string()), ], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); }); @@ -162,7 +170,7 @@ fn create_commit_nothing_staged() { description: None, co_authors: vec![], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; // Should fail - nothing to commit assert!(result.is_err()); @@ -175,6 +183,11 @@ fn create_commit_allow_empty() { run_async(async { let repo = TestRepo::new(); repo.commit_file("README.md", "# Test", "Initial"); + let previous_head = repo.head_commit(); + let previous_tree = repo.git(&["rev-parse", "HEAD^{tree}"]); + repo.create_file("staged.txt", "must remain staged"); + repo.add("staged.txt"); + repo.create_file("unstaged.txt", "must remain uncommitted"); let service = setup_commit_service(&repo); let msg = CommitMessage { @@ -182,10 +195,401 @@ fn create_commit_allow_empty() { description: None, co_authors: vec![], }; - let result = service.create_commit(&msg, true).await; + let result = service.create_commit(&msg, true, false, None).await; + + let commit_id = result.expect("allow-empty commit should succeed"); + assert_ne!(commit_id, previous_head); + assert_eq!(repo.git(&["rev-list", "--count", "HEAD"]), "2"); + assert_eq!(repo.git(&["rev-parse", "HEAD^{tree}"]), previous_tree); + assert_eq!( + repo.git(&["ls-tree", "--name-only", "HEAD", "--", "staged.txt"]), + "" + ); + assert_eq!( + repo.git(&["ls-tree", "--name-only", "HEAD", "--", "unstaged.txt"]), + "" + ); + assert!(repo.is_staged("staged.txt")); + assert_eq!(repo.git(&["show", ":staged.txt"]), "must remain staged"); + assert_eq!( + service.last_commit().await.unwrap().summary, + "Allow empty commit" + ); + }); +} - // Should succeed with allow_empty=true - assert!(result.is_ok()); +#[test] +#[serial] +fn amend_commit_message_only_replaces_head() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial commit"); + repo.commit_file("second.txt", "content", "Original message"); + let previous_head = repo.head_commit(); + let previous_tree = repo.git(&["rev-parse", "HEAD^{tree}"]); + let previous_parent = repo.git(&["rev-parse", "HEAD^"]); + + let service = setup_commit_service(&repo); + let msg = CommitMessage { + title: "Updated message".to_string(), + description: Some("Updated body".to_string()), + co_authors: vec![], + }; + let commit_id = service + .create_commit(&msg, false, true, Some(previous_head.as_str())) + .await + .expect("message-only amend should succeed"); + + assert_ne!(commit_id, previous_head); + assert_eq!(repo.git(&["rev-list", "--count", "HEAD"]), "2"); + assert_eq!(repo.git(&["rev-parse", "HEAD^{tree}"]), previous_tree); + assert_eq!(repo.git(&["rev-parse", "HEAD^"]), previous_parent); + let last = service.last_commit().await.unwrap(); + assert_eq!(last.summary, "Updated message"); + assert!(last.body.contains("Updated body")); + }); +} + +#[test] +#[serial] +fn amend_commit_round_trips_mixed_git_trailers() { + run_async(async { + let repo = TestRepo::new(); + repo.create_file("README.md", "# Test"); + repo.add("README.md"); + repo.commit( + "Pair change\n\nExplains the change\n\nSigned-off-by: Ruru \nCo-authored-by: Alice ", + ); + + let service = setup_commit_service(&repo); + let original = service.last_commit().await.unwrap(); + assert_eq!(original.authors.co_authors.len(), 1); + + let description = original + .body + .lines() + .filter(|line| !line.starts_with("Co-authored-by:")) + .collect::>() + .join("\n") + .trim() + .to_string(); + let co_authors = original + .authors + .co_authors + .iter() + .map(|author| (author.name.clone(), author.email.clone())) + .collect(); + let previous_head = repo.head_commit(); + + service + .create_commit( + &CommitMessage { + title: "Amended pair change".to_string(), + description: Some(description), + co_authors, + }, + false, + true, + Some(previous_head.as_str()), + ) + .await + .expect("mixed-trailer amend should succeed"); + + let amended_message = repo.git(&["show", "-s", "--format=%B", "HEAD"]); + assert_eq!( + amended_message, + "Amended pair change\n\nExplains the change\n\nSigned-off-by: Ruru \nCo-authored-by: Alice " + ); + + let mut parser = Command::new("git") + .current_dir(repo.path()) + .args(["interpret-trailers", "--parse"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to start git interpret-trailers"); + parser + .stdin + .take() + .expect("interpret-trailers stdin should be piped") + .write_all(amended_message.as_bytes()) + .expect("failed to write commit message to interpret-trailers"); + let output = parser + .wait_with_output() + .expect("failed to wait for git interpret-trailers"); + assert!( + output.status.success(), + "git interpret-trailers failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + "Signed-off-by: Ruru \nCo-authored-by: Alice " + ); + + let amended = service.last_commit().await.unwrap(); + assert_eq!(amended.authors.co_authors.len(), 1); + assert_eq!(amended.authors.co_authors[0].name, "Alice"); + assert_eq!(amended.authors.co_authors[0].email, "alice@example.com"); + }); +} + +#[test] +#[serial] +fn amend_commit_includes_staged_content() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Original message"); + let previous_head = repo.head_commit(); + repo.create_file("staged.txt", "amended content"); + repo.add("staged.txt"); + + let service = setup_commit_service(&repo); + let msg = CommitMessage { + title: "Amended with staged content".to_string(), + description: None, + co_authors: vec![], + }; + let commit_id = service + .create_commit(&msg, false, true, Some(previous_head.as_str())) + .await + .expect("staged amend should succeed"); + + assert_ne!(commit_id, previous_head); + assert_eq!(repo.git(&["rev-list", "--count", "HEAD"]), "1"); + assert_eq!(repo.git(&["show", "HEAD:staged.txt"]), "amended content"); + }); +} + +#[test] +#[serial] +fn create_commit_rejects_amend_with_allow_empty() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial"); + let previous_head = repo.head_commit(); + let service = setup_commit_service(&repo); + let msg = CommitMessage { + title: "Invalid options".to_string(), + description: None, + co_authors: vec![], + }; + + let error = service + .create_commit(&msg, true, true, Some(previous_head.as_str())) + .await + .unwrap_err(); + + assert!(error.contains("cannot be used together")); + assert_eq!(repo.head_commit(), previous_head); + }); +} + +#[test] +#[serial] +fn amend_commit_rejects_changed_head_and_preserves_index() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial commit"); + let expected_head = repo.head_commit(); + repo.commit_file("second.txt", "content", "Newer commit"); + let current_head = repo.head_commit(); + repo.create_file("staged.txt", "must remain staged"); + repo.add("staged.txt"); + + let service = setup_commit_service(&repo); + let msg = CommitMessage { + title: "Stale amend attempt".to_string(), + description: None, + co_authors: vec![], + }; + + let error = service + .create_commit(&msg, false, true, Some(expected_head.as_str())) + .await + .unwrap_err(); + + assert!(error.contains("HEAD changed")); + assert!(error.contains(&expected_head[..12])); + assert!(error.contains(¤t_head[..12])); + assert_eq!(repo.head_commit(), current_head); + assert_eq!(repo.git(&["rev-list", "--count", "HEAD"]), "2"); + assert!(repo.is_staged("staged.txt")); + assert_eq!(repo.git(&["show", ":staged.txt"]), "must remain staged"); + }); +} + +#[test] +#[serial] +fn amend_commit_requires_expected_head() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial commit"); + let previous_head = repo.head_commit(); + + let service = setup_commit_service(&repo); + let msg = CommitMessage { + title: "Amend without lease".to_string(), + description: None, + co_authors: vec![], + }; + + let error = service + .create_commit(&msg, false, true, None) + .await + .unwrap_err(); + + assert!(error.contains("Expected HEAD is required")); + assert_eq!(repo.head_commit(), previous_head); + }); +} + +#[test] +#[serial] +fn create_commit_validates_summary_and_co_authors() { + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial"); + let previous_head = repo.head_commit(); + let service = setup_commit_service(&repo); + + let blank = CommitMessage { + title: " ".to_string(), + description: None, + co_authors: vec![], + }; + assert!( + service + .create_commit(&blank, false, false, None) + .await + .unwrap_err() + .contains("summary is required") + ); + + let multiline = CommitMessage { + title: "First line\nSecond line".to_string(), + description: None, + co_authors: vec![], + }; + assert!( + service + .create_commit(&multiline, false, false, None) + .await + .unwrap_err() + .contains("single line") + ); + + let invalid_co_author = CommitMessage { + title: "Valid summary".to_string(), + description: None, + co_authors: vec![("Alice".to_string(), "not-an-email".to_string())], + }; + assert!( + service + .create_commit(&invalid_co_author, false, false, None) + .await + .unwrap_err() + .contains("email must contain one @") + ); + assert_eq!(repo.head_commit(), previous_head); + }); +} + +#[cfg(unix)] +#[test] +#[serial] +fn create_commit_propagates_exit_one_hook_failure() { + use std::os::unix::fs::PermissionsExt; + + run_async(async { + let repo = TestRepo::new(); + repo.commit_file("README.md", "# Test", "Initial"); + let previous_head = repo.head_commit(); + repo.create_file("staged.txt", "content"); + repo.add("staged.txt"); + + let hook = repo.path().join(".git/hooks/pre-commit"); + std::fs::write(&hook, "#!/bin/sh\necho blocked-by-test-hook >&2\nexit 1\n").unwrap(); + let mut permissions = std::fs::metadata(&hook).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&hook, permissions).unwrap(); + + let service = setup_commit_service(&repo); + let msg = CommitMessage { + title: "Should fail".to_string(), + description: None, + co_authors: vec![], + }; + let error = service + .create_commit(&msg, false, false, None) + .await + .unwrap_err(); + + assert!(error.contains("blocked-by-test-hook")); + assert_eq!(repo.head_commit(), previous_head); + }); +} + +#[test] +#[serial] +fn commit_authors_returns_configured_and_recent_authors() { + run_async(async { + let repo = TestRepo::new(); + repo.git(&["config", "user.name", "Recent Author"]); + repo.git(&["config", "user.email", "recent@example.com"]); + repo.commit_file("README.md", "# Test", "Older commit"); + + repo.git(&["config", "user.name", "Configured Author"]); + repo.git(&["config", "user.email", "configured@example.com"]); + repo.create_file("collaboration.txt", "content"); + repo.add("collaboration.txt"); + + let service = setup_commit_service(&repo); + let msg = CommitMessage { + title: "Collaborative commit".to_string(), + description: None, + co_authors: vec![ + ( + "Configured Alias".to_string(), + "CONFIGURED@example.com".to_string(), + ), + ("Pair Author".to_string(), "pair@example.com".to_string()), + ], + }; + service + .create_commit(&msg, false, false, None) + .await + .unwrap(); + + let authors = service.commit_authors().await.unwrap(); + let emails: Vec<&str> = authors.iter().map(|author| author.email.as_str()).collect(); + + assert_eq!(authors[0].name, "Configured Author"); + assert_eq!(authors[0].email, "configured@example.com"); + assert_eq!( + emails, + vec![ + "configured@example.com", + "pair@example.com", + "recent@example.com" + ] + ); + }); +} + +#[test] +#[serial] +fn commit_authors_in_unborn_repo_returns_configured_identity() { + run_async(async { + let repo = TestRepo::new(); + let service = setup_commit_service(&repo); + + let authors = service.commit_authors().await.unwrap(); + + assert_eq!(authors.len(), 1); + assert_eq!(authors[0].name, "Test User"); + assert_eq!(authors[0].email, "test@example.com"); }); } @@ -270,7 +674,7 @@ fn create_commit_multiline_message() { ), co_authors: vec![], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); @@ -296,7 +700,7 @@ fn create_commit_special_characters_in_message() { description: None, co_authors: vec![], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); @@ -321,7 +725,7 @@ fn create_commit_unicode_message() { description: None, co_authors: vec![], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); @@ -350,7 +754,7 @@ fn commit_on_different_branch() { description: None, co_authors: vec![], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); @@ -379,7 +783,7 @@ fn commit_with_description() { ), co_authors: vec![], }; - let result = service.create_commit(&msg, false).await; + let result = service.create_commit(&msg, false, false, None).await; assert!(result.is_ok()); diff --git a/packages/commands/src/.typecache b/packages/commands/src/.typecache index fa8ba9e6..d1eb97e9 100644 --- a/packages/commands/src/.typecache +++ b/packages/commands/src/.typecache @@ -1,7 +1,7 @@ { "version": 1, - "commands_hash": "8958207ae1c63ce7", + "commands_hash": "19a4e66b98fd9526", "structs_hash": "427ff297a8652f7c", "config_hash": "e14d37710ecfcc74", - "combined_hash": "b9dc49844ff8d843" + "combined_hash": "86ee659b6187f4" } \ No newline at end of file diff --git a/packages/commands/src/commands.ts b/packages/commands/src/commands.ts index 224da68e..4313dc3c 100644 --- a/packages/commands/src/commands.ts +++ b/packages/commands/src/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.4.2 - * Generated at: 2026-09-11T05:17:50.458215+00:00 + * Generated at: 2026-09-20T14:09:16.367335+00:00 * Generator: zod * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -25,15 +25,15 @@ export interface CommandHooks { onSettled?: () => void; } -export async function repositoryOrigin(params: types.RepositoryOriginParams, hooks?: CommandHooks): Promise { +export async function gitVersion(params: types.GitVersionParams, hooks?: CommandHooks): Promise { try { - const result = types.RepositoryOriginParamsSchema.safeParse(params); + const result = types.GitVersionParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('repository_origin', result.data); + const data = await invoke('git_version', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -47,15 +47,15 @@ export async function repositoryOrigin(params: types.RepositoryOriginParams, hoo } -export async function startPickaxe(params: types.StartPickaxeParams, hooks?: CommandHooks): Promise { +export async function getStatus(params: types.GetStatusParams, hooks?: CommandHooks): Promise { try { - const result = types.StartPickaxeParamsSchema.safeParse(params); + const result = types.GetStatusParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('start_pickaxe', result.data); + const data = await invoke('get_status', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -69,15 +69,15 @@ export async function startPickaxe(params: types.StartPickaxeParams, hooks?: Com } -export async function cancelPickaxe(params: types.CancelPickaxeParams, hooks?: CommandHooks): Promise { +export async function gitFetch(params: types.GitFetchParams, hooks?: CommandHooks): Promise { try { - const result = types.CancelPickaxeParamsSchema.safeParse(params); + const result = types.GitFetchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('cancel_pickaxe', result.data); + const data = await invoke('git_fetch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -91,15 +91,15 @@ export async function cancelPickaxe(params: types.CancelPickaxeParams, hooks?: C } -export async function listRepositories(params: types.ListRepositoriesParams, hooks?: CommandHooks): Promise { +export async function gitAdd(params: types.GitAddParams, hooks?: CommandHooks): Promise { try { - const result = types.ListRepositoriesParamsSchema.safeParse(params); + const result = types.GitAddParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('list_repositories', result.data); + const data = await invoke('git_add', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -113,15 +113,15 @@ export async function listRepositories(params: types.ListRepositoriesParams, hoo } -export async function addRepository(params: types.AddRepositoryParams, hooks?: CommandHooks): Promise { +export async function gitRemove(params: types.GitRemoveParams, hooks?: CommandHooks): Promise { try { - const result = types.AddRepositoryParamsSchema.safeParse(params); + const result = types.GitRemoveParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('add_repository', result.data); + const data = await invoke('git_remove', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -135,15 +135,15 @@ export async function addRepository(params: types.AddRepositoryParams, hooks?: C } -export async function removeRepository(params: types.RemoveRepositoryParams, hooks?: CommandHooks): Promise { +export async function gitDiscard(params: types.GitDiscardParams, hooks?: CommandHooks): Promise { try { - const result = types.RemoveRepositoryParamsSchema.safeParse(params); + const result = types.GitDiscardParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('remove_repository', result.data); + const data = await invoke('git_discard', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -157,15 +157,15 @@ export async function removeRepository(params: types.RemoveRepositoryParams, hoo } -export async function refreshRepositoryInfo(params: types.RefreshRepositoryInfoParams, hooks?: CommandHooks): Promise { +export async function gitApplyPatchBlock(params: types.GitApplyPatchBlockParams, hooks?: CommandHooks): Promise { try { - const result = types.RefreshRepositoryInfoParamsSchema.safeParse(params); + const result = types.GitApplyPatchBlockParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('refresh_repository_info', result.data); + const data = await invoke('git_apply_patch_block', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -179,15 +179,15 @@ export async function refreshRepositoryInfo(params: types.RefreshRepositoryInfoP } -export async function getRepoOperation(params: types.GetRepoOperationParams, hooks?: CommandHooks): Promise { +export async function readWorktreeFile(params: types.ReadWorktreeFileParams, hooks?: CommandHooks): Promise { try { - const result = types.GetRepoOperationParamsSchema.safeParse(params); + const result = types.ReadWorktreeFileParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_repo_operation', result.data); + const data = await invoke('read_worktree_file', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -201,15 +201,15 @@ export async function getRepoOperation(params: types.GetRepoOperationParams, hoo } -export async function rebasePlan(params: types.RebasePlanParams, hooks?: CommandHooks): Promise { +export async function writeWorktreeFile(params: types.WriteWorktreeFileParams, hooks?: CommandHooks): Promise { try { - const result = types.RebasePlanParamsSchema.safeParse(params); + const result = types.WriteWorktreeFileParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_plan', result.data); + const data = await invoke('write_worktree_file', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -223,15 +223,15 @@ export async function rebasePlan(params: types.RebasePlanParams, hooks?: Command } -export async function rebaseStart(params: types.RebaseStartParams, hooks?: CommandHooks): Promise { +export async function repositoryOrigin(params: types.RepositoryOriginParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseStartParamsSchema.safeParse(params); + const result = types.RepositoryOriginParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_start', result.data); + const data = await invoke('repository_origin', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -245,15 +245,15 @@ export async function rebaseStart(params: types.RebaseStartParams, hooks?: Comma } -export async function rebaseContinue(params: types.RebaseContinueParams, hooks?: CommandHooks): Promise { +export async function openExternalUrl(params: types.OpenExternalUrlParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseContinueParamsSchema.safeParse(params); + const result = types.OpenExternalUrlParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_continue', result.data); + const data = await invoke('open_external_url', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -267,15 +267,15 @@ export async function rebaseContinue(params: types.RebaseContinueParams, hooks?: } -export async function rebaseSkip(params: types.RebaseSkipParams, hooks?: CommandHooks): Promise { +export async function getPatchByFilePath(params: types.GetPatchByFilePathParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseSkipParamsSchema.safeParse(params); + const result = types.GetPatchByFilePathParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_skip', result.data); + const data = await invoke('get_patch_by_file_path', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -289,15 +289,15 @@ export async function rebaseSkip(params: types.RebaseSkipParams, hooks?: Command } -export async function rebaseAbort(params: types.RebaseAbortParams, hooks?: CommandHooks): Promise { +export async function listRepositories(params: types.ListRepositoriesParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseAbortParamsSchema.safeParse(params); + const result = types.ListRepositoriesParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_abort', result.data); + const data = await invoke('list_repositories', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -311,15 +311,15 @@ export async function rebaseAbort(params: types.RebaseAbortParams, hooks?: Comma } -export async function rebaseAbortPreview(params: types.RebaseAbortPreviewParams, hooks?: CommandHooks): Promise { +export async function addRepository(params: types.AddRepositoryParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseAbortPreviewParamsSchema.safeParse(params); + const result = types.AddRepositoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_abort_preview', result.data); + const data = await invoke('add_repository', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -333,15 +333,15 @@ export async function rebaseAbortPreview(params: types.RebaseAbortPreviewParams, } -export async function rebaseUpdateTodo(params: types.RebaseUpdateTodoParams, hooks?: CommandHooks): Promise { +export async function removeRepository(params: types.RemoveRepositoryParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseUpdateTodoParamsSchema.safeParse(params); + const result = types.RemoveRepositoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_update_todo', result.data); + const data = await invoke('remove_repository', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -355,15 +355,15 @@ export async function rebaseUpdateTodo(params: types.RebaseUpdateTodoParams, hoo } -export async function rebaseSetCommitMessage(params: types.RebaseSetCommitMessageParams, hooks?: CommandHooks): Promise { +export async function refreshRepositoryInfo(params: types.RefreshRepositoryInfoParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseSetCommitMessageParamsSchema.safeParse(params); + const result = types.RefreshRepositoryInfoParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_set_commit_message', result.data); + const data = await invoke('refresh_repository_info', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -377,15 +377,15 @@ export async function rebaseSetCommitMessage(params: types.RebaseSetCommitMessag } -export async function rebaseResolveConflict(params: types.RebaseResolveConflictParams, hooks?: CommandHooks): Promise { +export async function history(params: types.HistoryParams, hooks?: CommandHooks): Promise { try { - const result = types.RebaseResolveConflictParamsSchema.safeParse(params); + const result = types.HistoryParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rebase_resolve_conflict', result.data); + const data = await invoke('history', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -399,15 +399,15 @@ export async function rebaseResolveConflict(params: types.RebaseResolveConflictP } -export async function checkForUpdateByChannel(params: types.CheckForUpdateByChannelParams, hooks?: CommandHooks): Promise { +export async function historyGraph(params: types.HistoryGraphParams, hooks?: CommandHooks): Promise { try { - const result = types.CheckForUpdateByChannelParamsSchema.safeParse(params); + const result = types.HistoryGraphParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('check_for_update_by_channel', result.data); + const data = await invoke('history_graph', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -421,15 +421,15 @@ export async function checkForUpdateByChannel(params: types.CheckForUpdateByChan } -export async function downloadAndInstallUpdateByChannel(params: types.DownloadAndInstallUpdateByChannelParams, hooks?: CommandHooks): Promise { +export async function commitActivity(params: types.CommitActivityParams, hooks?: CommandHooks): Promise { try { - const result = types.DownloadAndInstallUpdateByChannelParamsSchema.safeParse(params); + const result = types.CommitActivityParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('download_and_install_update_by_channel', result.data); + const data = await invoke('commit_activity', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -443,15 +443,15 @@ export async function downloadAndInstallUpdateByChannel(params: types.DownloadAn } -export async function gitVersion(params: types.GitVersionParams, hooks?: CommandHooks): Promise { +export async function currentBranch(params: types.CurrentBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.GitVersionParamsSchema.safeParse(params); + const result = types.CurrentBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_version', result.data); + const data = await invoke('current_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -465,15 +465,15 @@ export async function gitVersion(params: types.GitVersionParams, hooks?: Command } -export async function getStatus(params: types.GetStatusParams, hooks?: CommandHooks): Promise { +export async function listBranches(params: types.ListBranchesParams, hooks?: CommandHooks): Promise { try { - const result = types.GetStatusParamsSchema.safeParse(params); + const result = types.ListBranchesParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_status', result.data); + const data = await invoke('list_branches', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -487,15 +487,15 @@ export async function getStatus(params: types.GetStatusParams, hooks?: CommandHo } -export async function gitFetch(params: types.GitFetchParams, hooks?: CommandHooks): Promise { +export async function statusAheadBehind(params: types.StatusAheadBehindParams, hooks?: CommandHooks): Promise { try { - const result = types.GitFetchParamsSchema.safeParse(params); + const result = types.StatusAheadBehindParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_fetch', result.data); + const data = await invoke('status_ahead_behind', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -509,15 +509,15 @@ export async function gitFetch(params: types.GitFetchParams, hooks?: CommandHook } -export async function gitAdd(params: types.GitAddParams, hooks?: CommandHooks): Promise { +export async function getBranchInfo(params: types.GetBranchInfoParams, hooks?: CommandHooks): Promise { try { - const result = types.GitAddParamsSchema.safeParse(params); + const result = types.GetBranchInfoParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_add', result.data); + const data = await invoke('get_branch_info', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -531,15 +531,15 @@ export async function gitAdd(params: types.GitAddParams, hooks?: CommandHooks): Promise { +export async function switchBranch(params: types.SwitchBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.GitRemoveParamsSchema.safeParse(params); + const result = types.SwitchBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_remove', result.data); + const data = await invoke('switch_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -553,15 +553,15 @@ export async function gitRemove(params: types.GitRemoveParams, hooks?: CommandHo } -export async function gitDiscard(params: types.GitDiscardParams, hooks?: CommandHooks): Promise { +export async function createBranch(params: types.CreateBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.GitDiscardParamsSchema.safeParse(params); + const result = types.CreateBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_discard', result.data); + const data = await invoke('create_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -575,15 +575,15 @@ export async function gitDiscard(params: types.GitDiscardParams, hooks?: Command } -export async function gitApplyPatchBlock(params: types.GitApplyPatchBlockParams, hooks?: CommandHooks): Promise { +export async function renameBranch(params: types.RenameBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.GitApplyPatchBlockParamsSchema.safeParse(params); + const result = types.RenameBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('git_apply_patch_block', result.data); + const data = await invoke('rename_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -597,15 +597,15 @@ export async function gitApplyPatchBlock(params: types.GitApplyPatchBlockParams, } -export async function readWorktreeFile(params: types.ReadWorktreeFileParams, hooks?: CommandHooks): Promise { +export async function deleteLocalBranch(params: types.DeleteLocalBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.ReadWorktreeFileParamsSchema.safeParse(params); + const result = types.DeleteLocalBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('read_worktree_file', result.data); + const data = await invoke('delete_local_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -619,15 +619,15 @@ export async function readWorktreeFile(params: types.ReadWorktreeFileParams, hoo } -export async function writeWorktreeFile(params: types.WriteWorktreeFileParams, hooks?: CommandHooks): Promise { +export async function deleteRemoteBranch(params: types.DeleteRemoteBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.WriteWorktreeFileParamsSchema.safeParse(params); + const result = types.DeleteRemoteBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('write_worktree_file', result.data); + const data = await invoke('delete_remote_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -641,15 +641,15 @@ export async function writeWorktreeFile(params: types.WriteWorktreeFileParams, h } -export async function lastCommit(params: types.LastCommitParams, hooks?: CommandHooks): Promise { +export async function setBranchUpstream(params: types.SetBranchUpstreamParams, hooks?: CommandHooks): Promise { try { - const result = types.LastCommitParamsSchema.safeParse(params); + const result = types.SetBranchUpstreamParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('last_commit', result.data); + const data = await invoke('set_branch_upstream', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -663,15 +663,15 @@ export async function lastCommit(params: types.LastCommitParams, hooks?: Command } -export async function commitById(params: types.CommitByIdParams, hooks?: CommandHooks): Promise { +export async function unsetBranchUpstream(params: types.UnsetBranchUpstreamParams, hooks?: CommandHooks): Promise { try { - const result = types.CommitByIdParamsSchema.safeParse(params); + const result = types.UnsetBranchUpstreamParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('commit_by_id', result.data); + const data = await invoke('unset_branch_upstream', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -685,15 +685,15 @@ export async function commitById(params: types.CommitByIdParams, hooks?: Command } -export async function createCommit(params: types.CreateCommitParams, hooks?: CommandHooks): Promise { +export async function push(params: types.PushParams, hooks?: CommandHooks): Promise { try { - const result = types.CreateCommitParamsSchema.safeParse(params); + const result = types.PushParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('create_commit', result.data); + const data = await invoke('push', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -707,15 +707,15 @@ export async function createCommit(params: types.CreateCommitParams, hooks?: Com } -export async function currentBranch(params: types.CurrentBranchParams, hooks?: CommandHooks): Promise { +export async function publishBranch(params: types.PublishBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.CurrentBranchParamsSchema.safeParse(params); + const result = types.PublishBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('current_branch', result.data); + const data = await invoke('publish_branch', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -729,15 +729,15 @@ export async function currentBranch(params: types.CurrentBranchParams, hooks?: C } -export async function listBranches(params: types.ListBranchesParams, hooks?: CommandHooks): Promise { +export async function pull(params: types.PullParams, hooks?: CommandHooks): Promise { try { - const result = types.ListBranchesParamsSchema.safeParse(params); + const result = types.PullParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('list_branches', result.data); + const data = await invoke('pull', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -751,15 +751,15 @@ export async function listBranches(params: types.ListBranchesParams, hooks?: Com } -export async function statusAheadBehind(params: types.StatusAheadBehindParams, hooks?: CommandHooks): Promise { +export async function hasUncommittedChanges(params: types.HasUncommittedChangesParams, hooks?: CommandHooks): Promise { try { - const result = types.StatusAheadBehindParamsSchema.safeParse(params); + const result = types.HasUncommittedChangesParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('status_ahead_behind', result.data); + const data = await invoke('has_uncommitted_changes', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -773,15 +773,15 @@ export async function statusAheadBehind(params: types.StatusAheadBehindParams, h } -export async function getBranchInfo(params: types.GetBranchInfoParams, hooks?: CommandHooks): Promise { +export async function currentBranchStash(params: types.CurrentBranchStashParams, hooks?: CommandHooks): Promise { try { - const result = types.GetBranchInfoParamsSchema.safeParse(params); + const result = types.CurrentBranchStashParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_branch_info', result.data); + const data = await invoke('current_branch_stash', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -795,15 +795,15 @@ export async function getBranchInfo(params: types.GetBranchInfoParams, hooks?: C } -export async function switchBranch(params: types.SwitchBranchParams, hooks?: CommandHooks): Promise { +export async function popCurrentBranchStash(params: types.PopCurrentBranchStashParams, hooks?: CommandHooks): Promise { try { - const result = types.SwitchBranchParamsSchema.safeParse(params); + const result = types.PopCurrentBranchStashParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('switch_branch', result.data); + const data = await invoke('pop_current_branch_stash', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -817,15 +817,15 @@ export async function switchBranch(params: types.SwitchBranchParams, hooks?: Com } -export async function createBranch(params: types.CreateBranchParams, hooks?: CommandHooks): Promise { +export async function startPickaxe(params: types.StartPickaxeParams, hooks?: CommandHooks): Promise { try { - const result = types.CreateBranchParamsSchema.safeParse(params); + const result = types.StartPickaxeParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('create_branch', result.data); + const data = await invoke('start_pickaxe', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -839,15 +839,15 @@ export async function createBranch(params: types.CreateBranchParams, hooks?: Com } -export async function renameBranch(params: types.RenameBranchParams, hooks?: CommandHooks): Promise { +export async function cancelPickaxe(params: types.CancelPickaxeParams, hooks?: CommandHooks): Promise { try { - const result = types.RenameBranchParamsSchema.safeParse(params); + const result = types.CancelPickaxeParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('rename_branch', result.data); + const data = await invoke('cancel_pickaxe', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -861,15 +861,15 @@ export async function renameBranch(params: types.RenameBranchParams, hooks?: Com } -export async function deleteLocalBranch(params: types.DeleteLocalBranchParams, hooks?: CommandHooks): Promise { +export async function checkForUpdateByChannel(params: types.CheckForUpdateByChannelParams, hooks?: CommandHooks): Promise { try { - const result = types.DeleteLocalBranchParamsSchema.safeParse(params); + const result = types.CheckForUpdateByChannelParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('delete_local_branch', result.data); + const data = await invoke('check_for_update_by_channel', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -883,15 +883,15 @@ export async function deleteLocalBranch(params: types.DeleteLocalBranchParams, h } -export async function deleteRemoteBranch(params: types.DeleteRemoteBranchParams, hooks?: CommandHooks): Promise { +export async function downloadAndInstallUpdateByChannel(params: types.DownloadAndInstallUpdateByChannelParams, hooks?: CommandHooks): Promise { try { - const result = types.DeleteRemoteBranchParamsSchema.safeParse(params); + const result = types.DownloadAndInstallUpdateByChannelParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('delete_remote_branch', result.data); + const data = await invoke('download_and_install_update_by_channel', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -905,15 +905,15 @@ export async function deleteRemoteBranch(params: types.DeleteRemoteBranchParams, } -export async function setBranchUpstream(params: types.SetBranchUpstreamParams, hooks?: CommandHooks): Promise { +export async function getRepoOperation(params: types.GetRepoOperationParams, hooks?: CommandHooks): Promise { try { - const result = types.SetBranchUpstreamParamsSchema.safeParse(params); + const result = types.GetRepoOperationParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('set_branch_upstream', result.data); + const data = await invoke('get_repo_operation', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -927,15 +927,15 @@ export async function setBranchUpstream(params: types.SetBranchUpstreamParams, h } -export async function unsetBranchUpstream(params: types.UnsetBranchUpstreamParams, hooks?: CommandHooks): Promise { +export async function rebasePlan(params: types.RebasePlanParams, hooks?: CommandHooks): Promise { try { - const result = types.UnsetBranchUpstreamParamsSchema.safeParse(params); + const result = types.RebasePlanParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('unset_branch_upstream', result.data); + const data = await invoke('rebase_plan', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -949,15 +949,15 @@ export async function unsetBranchUpstream(params: types.UnsetBranchUpstreamParam } -export async function push(params: types.PushParams, hooks?: CommandHooks): Promise { +export async function rebaseStart(params: types.RebaseStartParams, hooks?: CommandHooks): Promise { try { - const result = types.PushParamsSchema.safeParse(params); + const result = types.RebaseStartParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('push', result.data); + const data = await invoke('rebase_start', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -971,15 +971,15 @@ export async function push(params: types.PushParams, hooks?: CommandHooks): Promise { +export async function rebaseContinue(params: types.RebaseContinueParams, hooks?: CommandHooks): Promise { try { - const result = types.PublishBranchParamsSchema.safeParse(params); + const result = types.RebaseContinueParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('publish_branch', result.data); + const data = await invoke('rebase_continue', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -993,15 +993,15 @@ export async function publishBranch(params: types.PublishBranchParams, hooks?: C } -export async function pull(params: types.PullParams, hooks?: CommandHooks): Promise { +export async function rebaseSkip(params: types.RebaseSkipParams, hooks?: CommandHooks): Promise { try { - const result = types.PullParamsSchema.safeParse(params); + const result = types.RebaseSkipParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('pull', result.data); + const data = await invoke('rebase_skip', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1015,15 +1015,15 @@ export async function pull(params: types.PullParams, hooks?: CommandHooks): Promise { +export async function rebaseAbort(params: types.RebaseAbortParams, hooks?: CommandHooks): Promise { try { - const result = types.HasUncommittedChangesParamsSchema.safeParse(params); + const result = types.RebaseAbortParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('has_uncommitted_changes', result.data); + const data = await invoke('rebase_abort', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1037,15 +1037,15 @@ export async function hasUncommittedChanges(params: types.HasUncommittedChangesP } -export async function currentBranchStash(params: types.CurrentBranchStashParams, hooks?: CommandHooks): Promise { +export async function rebaseAbortPreview(params: types.RebaseAbortPreviewParams, hooks?: CommandHooks): Promise { try { - const result = types.CurrentBranchStashParamsSchema.safeParse(params); + const result = types.RebaseAbortPreviewParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('current_branch_stash', result.data); + const data = await invoke('rebase_abort_preview', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1059,15 +1059,15 @@ export async function currentBranchStash(params: types.CurrentBranchStashParams, } -export async function popCurrentBranchStash(params: types.PopCurrentBranchStashParams, hooks?: CommandHooks): Promise { +export async function rebaseUpdateTodo(params: types.RebaseUpdateTodoParams, hooks?: CommandHooks): Promise { try { - const result = types.PopCurrentBranchStashParamsSchema.safeParse(params); + const result = types.RebaseUpdateTodoParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('pop_current_branch_stash', result.data); + const data = await invoke('rebase_update_todo', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1081,15 +1081,15 @@ export async function popCurrentBranchStash(params: types.PopCurrentBranchStashP } -export async function stashList(params: types.StashListParams, hooks?: CommandHooks): Promise { +export async function rebaseSetCommitMessage(params: types.RebaseSetCommitMessageParams, hooks?: CommandHooks): Promise { try { - const result = types.StashListParamsSchema.safeParse(params); + const result = types.RebaseSetCommitMessageParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_list', result.data); + const data = await invoke('rebase_set_commit_message', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1103,15 +1103,15 @@ export async function stashList(params: types.StashListParams, hooks?: CommandHo } -export async function stashQuickStat(params: types.StashQuickStatParams, hooks?: CommandHooks): Promise { +export async function rebaseResolveConflict(params: types.RebaseResolveConflictParams, hooks?: CommandHooks): Promise { try { - const result = types.StashQuickStatParamsSchema.safeParse(params); + const result = types.RebaseResolveConflictParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_quick_stat', result.data); + const data = await invoke('rebase_resolve_conflict', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1125,15 +1125,15 @@ export async function stashQuickStat(params: types.StashQuickStatParams, hooks?: } -export async function stashShow(params: types.StashShowParams, hooks?: CommandHooks): Promise { +export async function lastCommit(params: types.LastCommitParams, hooks?: CommandHooks): Promise { try { - const result = types.StashShowParamsSchema.safeParse(params); + const result = types.LastCommitParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_show', result.data); + const data = await invoke('last_commit', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1147,15 +1147,15 @@ export async function stashShow(params: types.StashShowParams, hooks?: CommandHo } -export async function stashPush(params: types.StashPushParams, hooks?: CommandHooks): Promise { +export async function commitById(params: types.CommitByIdParams, hooks?: CommandHooks): Promise { try { - const result = types.StashPushParamsSchema.safeParse(params); + const result = types.CommitByIdParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_push', result.data); + const data = await invoke('commit_by_id', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1169,15 +1169,15 @@ export async function stashPush(params: types.StashPushParams, hooks?: CommandHo } -export async function stashPop(params: types.StashPopParams, hooks?: CommandHooks): Promise { +export async function createCommit(params: types.CreateCommitParams, hooks?: CommandHooks): Promise { try { - const result = types.StashPopParamsSchema.safeParse(params); + const result = types.CreateCommitParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_pop', result.data); + const data = await invoke('create_commit', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1191,15 +1191,15 @@ export async function stashPop(params: types.StashPopParams, hooks?: CommandHook } -export async function stashApply(params: types.StashApplyParams, hooks?: CommandHooks): Promise { +export async function commitAuthors(params: types.CommitAuthorsParams, hooks?: CommandHooks): Promise { try { - const result = types.StashApplyParamsSchema.safeParse(params); + const result = types.CommitAuthorsParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_apply', result.data); + const data = await invoke('commit_authors', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1213,15 +1213,15 @@ export async function stashApply(params: types.StashApplyParams, hooks?: Command } -export async function stashDrop(params: types.StashDropParams, hooks?: CommandHooks): Promise { +export async function stashList(params: types.StashListParams, hooks?: CommandHooks): Promise { try { - const result = types.StashDropParamsSchema.safeParse(params); + const result = types.StashListParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_drop', result.data); + const data = await invoke('stash_list', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1235,15 +1235,15 @@ export async function stashDrop(params: types.StashDropParams, hooks?: CommandHo } -export async function stashClear(params: types.StashClearParams, hooks?: CommandHooks): Promise { +export async function stashQuickStat(params: types.StashQuickStatParams, hooks?: CommandHooks): Promise { try { - const result = types.StashClearParamsSchema.safeParse(params); + const result = types.StashQuickStatParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_clear', result.data); + const data = await invoke('stash_quick_stat', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1257,15 +1257,15 @@ export async function stashClear(params: types.StashClearParams, hooks?: Command } -export async function stashBranch(params: types.StashBranchParams, hooks?: CommandHooks): Promise { +export async function stashShow(params: types.StashShowParams, hooks?: CommandHooks): Promise { try { - const result = types.StashBranchParamsSchema.safeParse(params); + const result = types.StashShowParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_branch', result.data); + const data = await invoke('stash_show', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1279,15 +1279,15 @@ export async function stashBranch(params: types.StashBranchParams, hooks?: Comma } -export async function stashRestoreFile(params: types.StashRestoreFileParams, hooks?: CommandHooks): Promise { +export async function stashPush(params: types.StashPushParams, hooks?: CommandHooks): Promise { try { - const result = types.StashRestoreFileParamsSchema.safeParse(params); + const result = types.StashPushParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('stash_restore_file', result.data); + const data = await invoke('stash_push', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1301,15 +1301,15 @@ export async function stashRestoreFile(params: types.StashRestoreFileParams, hoo } -export async function history(params: types.HistoryParams, hooks?: CommandHooks): Promise { +export async function stashPop(params: types.StashPopParams, hooks?: CommandHooks): Promise { try { - const result = types.HistoryParamsSchema.safeParse(params); + const result = types.StashPopParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('history', result.data); + const data = await invoke('stash_pop', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1323,15 +1323,15 @@ export async function history(params: types.HistoryParams, hooks?: CommandHooks< } -export async function historyGraph(params: types.HistoryGraphParams, hooks?: CommandHooks): Promise { +export async function stashApply(params: types.StashApplyParams, hooks?: CommandHooks): Promise { try { - const result = types.HistoryGraphParamsSchema.safeParse(params); + const result = types.StashApplyParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('history_graph', result.data); + const data = await invoke('stash_apply', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1345,15 +1345,15 @@ export async function historyGraph(params: types.HistoryGraphParams, hooks?: Com } -export async function commitActivity(params: types.CommitActivityParams, hooks?: CommandHooks): Promise { +export async function stashDrop(params: types.StashDropParams, hooks?: CommandHooks): Promise { try { - const result = types.CommitActivityParamsSchema.safeParse(params); + const result = types.StashDropParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('commit_activity', result.data); + const data = await invoke('stash_drop', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1367,15 +1367,15 @@ export async function commitActivity(params: types.CommitActivityParams, hooks?: } -export async function openExternalUrl(params: types.OpenExternalUrlParams, hooks?: CommandHooks): Promise { +export async function stashClear(params: types.StashClearParams, hooks?: CommandHooks): Promise { try { - const result = types.OpenExternalUrlParamsSchema.safeParse(params); + const result = types.StashClearParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('open_external_url', result.data); + const data = await invoke('stash_clear', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { @@ -1389,15 +1389,37 @@ export async function openExternalUrl(params: types.OpenExternalUrlParams, hooks } -export async function getPatchByFilePath(params: types.GetPatchByFilePathParams, hooks?: CommandHooks): Promise { +export async function stashBranch(params: types.StashBranchParams, hooks?: CommandHooks): Promise { try { - const result = types.GetPatchByFilePathParamsSchema.safeParse(params); + const result = types.StashBranchParamsSchema.safeParse(params); if (!result.success) { hooks?.onValidationError?.(result.error); throw result.error; } - const data = await invoke('get_patch_by_file_path', result.data); + const data = await invoke('stash_branch', result.data); + hooks?.onSuccess?.(data); + return data; + } catch (error) { + if (!(error instanceof ZodError)) { + hooks?.onInvokeError?.(error); + } + throw error; + } finally { + hooks?.onSettled?.(); + } +} + + +export async function stashRestoreFile(params: types.StashRestoreFileParams, hooks?: CommandHooks): Promise { + try { + const result = types.StashRestoreFileParamsSchema.safeParse(params); + + if (!result.success) { + hooks?.onValidationError?.(result.error); + throw result.error; + } + const data = await invoke('stash_restore_file', result.data); hooks?.onSuccess?.(data); return data; } catch (error) { diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index c2382143..cc0bf456 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.4.2 - * Generated at: 2026-09-11T05:17:50.458802+00:00 + * Generated at: 2026-09-20T14:09:16.367800+00:00 * Generator: zod * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/packages/commands/src/types.ts b/packages/commands/src/types.ts index 8eec4cfb..1e235974 100644 --- a/packages/commands/src/types.ts +++ b/packages/commands/src/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.4.2 - * Generated at: 2026-09-11T05:17:50.457453+00:00 + * Generated at: 2026-09-20T14:09:16.366770+00:00 * Generator: zod * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -10,140 +10,24 @@ import { z } from 'zod'; -export const RepositoryInfoSchema = z.object({ - id: z.string(), - name: z.string(), - path: z.string(), - origin: z.string().optional(), - current_branch: z.string().optional(), - ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), - has_uncommitted_changes: z.coerce.boolean(), - last_updated: z.coerce.number(), -}); - -export type RepositoryInfo = z.infer; - -export const UpdateCheckResponseSchema = z.object({ - available: z.coerce.boolean(), - channel: z.string(), - current_version: z.string(), - version: z.string().optional(), - notes: z.string().optional(), - pub_date: z.string().optional(), -}); - -export type UpdateCheckResponse = z.infer; - -export const AssetDiffKindSchema = z.enum(["Image", "Binary"]); - -export const AssetDiffEntrySchema = z.object({ - absolute_path: z.string(), - mime: z.string(), - bytes: z.coerce.number(), - logical_path: z.string(), - contents_base64: z.string(), -}); - -export type AssetDiffEntry = z.infer; - -export const AssetDiffSchema = z.object({ - kind: AssetDiffKindSchema, - before: AssetDiffEntrySchema.optional(), - after: AssetDiffEntrySchema.optional(), -}); - -export type AssetDiff = z.infer; - -export const RepoOperationKindSchema = z.enum(["clean", "merge", "revert", "cherryPick", "bisect", "rebase", "rebaseInteractive", "rebaseMerge", "applyMailbox", "other"]); - -export const StashEntrySchema = z.object({ - index: z.coerce.number(), - reference: z.string(), - message: z.string(), - branch: z.string().optional(), - is_gitru: z.coerce.boolean(), -}); - -export type StashEntry = z.infer; - -export const RebaseTodoStatusSchema = z.enum(["pending", "current", "done", "skipped"]); - -export const GraphRefKindSchema = z.enum(["Local", "Remote", "Tag", "Stash", "Other"]); - -export const AuthorSchema = z.object({ - name: z.string(), - email: z.string(), -}); - -export type Author = z.infer; - -export const CommitAuthorsSchema = z.object({ - author: AuthorSchema, - committer: AuthorSchema, - co_authors: z.array(AuthorSchema), -}); - -export type CommitAuthors = z.infer; - -export const CommitActivityQuerySchema = z.object({ - limit: z.coerce.number(), - include_local: z.coerce.boolean(), - include_remotes: z.coerce.boolean(), - include_tags: z.coerce.boolean(), - include_stash: z.coerce.boolean(), -}); - -export type CommitActivityQuery = z.infer; - -export const RebasePauseReasonSchema = z.enum(["conflict", "edit", "reword", "waiting"]); +export const RebaseActionSchema = z.enum(["pick", "reword", "edit", "squash", "fixup", "drop"]); -export const CommitInfoSchema = z.object({ - id: z.string(), - summary: z.string(), - body: z.string(), - timestamp: z.coerce.number(), - authors: CommitAuthorsSchema, +export const RebasePlanEntrySchema = z.object({ + action: RebaseActionSchema, + commit: z.string(), + message: z.string().optional(), }); -export type CommitInfo = z.infer; +export type RebasePlanEntry = z.infer; -export const BranchInfoSchema = z.object({ - name: z.string(), - display_name: z.string(), - is_remote: z.coerce.boolean(), - is_head: z.coerce.boolean(), - commit: CommitInfoSchema, +export const RebasePlanSchema = z.object({ + onto: z.string(), upstream: z.string().optional(), - ahead: z.coerce.number().optional(), - behind: z.coerce.number().optional(), - is_protected: z.coerce.boolean(), - is_merged: z.coerce.boolean(), -}); - -export type BranchInfo = z.infer; - -export const CommitStatsSchema = z.object({ - insertions: z.coerce.number(), - deletions: z.coerce.number(), - files_changed: z.coerce.number(), -}); - -export type CommitStats = z.infer; - -export const RebaseActionSchema = z.enum(["pick", "reword", "edit", "squash", "fixup", "drop"]); - -export const RepoSitoryStoreSchema = z.object({ - id: z.string(), - name: z.string(), - path: z.string(), - origin: z.string().optional(), - current_branch: z.string().optional(), - ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), - has_uncommitted_changes: z.coerce.boolean(), - last_updated: z.coerce.number(), + entries: z.array(RebasePlanEntrySchema), + autostash: z.coerce.boolean(), }); -export type RepoSitoryStore = z.infer; +export type RebasePlan = z.infer; export const ConflictResolveStrategySchema = z.enum(["ours", "theirs", "union"]); @@ -154,8 +38,6 @@ export const ConflictResolveRequestSchema = z.object({ export type ConflictResolveRequest = z.infer; -export const GraphRowTypeSchema = z.enum(["Commit", "Stash"]); - export const FileStatusKindSchema = z.enum(["IndexNew", "IndexModified", "IndexDeleted", "IndexRenamed", "IndexTypechange", "WorktreeNew", "WorktreeModified", "WorktreeDeleted", "WorktreeRenamed", "WorktreeTypechange", "WorktreeUnreadable", "Conflicted"]); export const FileStatusSchema = z.object({ @@ -166,58 +48,42 @@ export const FileStatusSchema = z.object({ export type FileStatus = z.infer; -export const BranchKindSchema = z.enum(["Local", "Remote"]); - -export const DiffScopeSchema = z.enum(["Worktree", "Staged", "Unstaged"]); - -export const StashQuickStatSchema = z.object({ - reference: z.string(), - files_changed: z.coerce.number(), +export const CommitStatsSchema = z.object({ insertions: z.coerce.number(), deletions: z.coerce.number(), + files_changed: z.coerce.number(), }); -export type StashQuickStat = z.infer; - -export const StashShowResponseSchema = z.object({ - reference: z.string(), - stat: StashQuickStatSchema, - files: z.array(FileStatusSchema), -}); - -export type StashShowResponse = z.infer; +export type CommitStats = z.infer; -export const RebaseTodoEntrySchema = z.object({ - index: z.coerce.number(), - action: RebaseActionSchema, - commit: z.string(), - shortCommit: z.string(), - message: z.string(), - status: RebaseTodoStatusSchema, - authoredAt: z.string().optional(), +export const AuthorSchema = z.object({ + name: z.string(), + email: z.string(), }); -export type RebaseTodoEntry = z.infer; +export type Author = z.infer; -export const DiffTextFileSchema = z.object({ - name: z.string(), - contents: z.string(), - byte_length: z.coerce.number(), - encoding: z.string(), +export const CommitAuthorsSchema = z.object({ + author: AuthorSchema, + committer: AuthorSchema, + co_authors: z.array(AuthorSchema), }); -export type DiffTextFile = z.infer; +export type CommitAuthors = z.infer; -export const FileDiffSchema = z.object({ - patch: z.string(), - asset_diff: AssetDiffSchema.optional(), - oldFile: DiffTextFileSchema.optional(), - newFile: DiffTextFileSchema.optional(), +export const FullCommitInfoSchema = z.object({ + id: z.string(), + timestamp: z.coerce.number(), + summary: z.string(), + body: z.string(), + authors: CommitAuthorsSchema, + stats: CommitStatsSchema, + files: z.array(FileStatusSchema), }); -export type FileDiff = z.infer; +export type FullCommitInfo = z.infer; -export const RebaseEngineSchema = z.enum(["git", "gitru"]); +export const GraphRowTypeSchema = z.enum(["Commit", "Stash"]); export const ParentEdgeSchema = z.object({ oid: z.string(), @@ -234,6 +100,8 @@ export const SwimlaneSchema = z.object({ export type Swimlane = z.infer; +export const GraphRefKindSchema = z.enum(["Local", "Remote", "Tag", "Stash", "Other"]); + export const GraphRefSchema = z.object({ name: z.string(), display_name: z.string(), @@ -243,18 +111,6 @@ export const GraphRefSchema = z.object({ export type GraphRef = z.infer; -export const FullCommitInfoSchema = z.object({ - id: z.string(), - timestamp: z.coerce.number(), - summary: z.string(), - body: z.string(), - authors: CommitAuthorsSchema, - stats: CommitStatsSchema, - files: z.array(FileStatusSchema), -}); - -export type FullCommitInfo = z.infer; - export const GraphRowSchema = z.object({ oid: z.string(), lane: z.coerce.number(), @@ -273,122 +129,160 @@ export const GraphRowSchema = z.object({ export type GraphRow = z.infer; -export const CommitMessageSchema = z.object({ - title: z.string(), - description: z.string().optional(), - co_authors: z.array(z.tuple([z.string(), z.string()])), +export const GraphPagingSchema = z.object({ + starting_cursor: z.string().optional(), + has_more: z.coerce.boolean(), }); -export type CommitMessage = z.infer; +export type GraphPaging = z.infer; -export const RebasePlanEntrySchema = z.object({ - action: RebaseActionSchema, - commit: z.string(), - message: z.string().optional(), +export const HistoryGraphResponseSchema = z.object({ + rows: z.array(GraphRowSchema), + cursor: z.string().optional(), + graph_state: z.string().optional(), + has_more: z.coerce.boolean(), + paging: GraphPagingSchema, }); -export type RebasePlanEntry = z.infer; +export type HistoryGraphResponse = z.infer; -export const RebaseAbortPreviewSchema = z.object({ - origHead: z.string().optional(), - headName: z.string().optional(), - onto: z.string().optional(), - current: z.coerce.number().optional(), - total: z.coerce.number().optional(), - commitsApplied: z.coerce.number(), - warning: z.string(), +export const SessionActionRequestSchema = z.object({ + sessionId: z.string(), }); -export type RebaseAbortPreview = z.infer; +export type SessionActionRequest = z.infer; -export const RebaseStartRequestSchema = z.object({ - onto: z.string(), - upstream: z.string().optional(), - entries: z.array(RebasePlanEntrySchema).optional(), - autostash: z.coerce.boolean(), +export const UncommittedChangesStrategySchema = z.enum(["StashOnCurrentBranch", "BringChanges"]); + +export const RebaseEngineSchema = z.enum(["git", "gitru"]); + +export const CommitActivityItemSchema = z.object({ + oid: z.string(), + timestamp: z.coerce.number(), + insertions: z.coerce.number(), + deletions: z.coerce.number(), }); -export type RebaseStartRequest = z.infer; +export type CommitActivityItem = z.infer; -export const UncommittedChangesStrategySchema = z.enum(["StashOnCurrentBranch", "BringChanges"]); +export const RebaseTodoStatusSchema = z.enum(["pending", "current", "done", "skipped"]); -export const BranchSchema = z.object({ +export const AssetDiffEntrySchema = z.object({ + absolute_path: z.string(), + mime: z.string(), + bytes: z.coerce.number(), + logical_path: z.string(), + contents_base64: z.string(), +}); + +export type AssetDiffEntry = z.infer; + +export const AssetDiffKindSchema = z.enum(["Image", "Binary"]); + +export const StashEntrySchema = z.object({ + index: z.coerce.number(), + reference: z.string(), + message: z.string(), + branch: z.string().optional(), + is_gitru: z.coerce.boolean(), +}); + +export type StashEntry = z.infer; + +export const RepositoryInfoSchema = z.object({ + id: z.string(), name: z.string(), - display_name: z.string(), - is_remote: z.coerce.boolean(), - is_detached: z.coerce.boolean(), + path: z.string(), + origin: z.string().optional(), + current_branch: z.string().optional(), + ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), + has_uncommitted_changes: z.coerce.boolean(), + last_updated: z.coerce.number(), }); -export type Branch = z.infer; +export type RepositoryInfo = z.infer; -export const GraphPagingSchema = z.object({ - starting_cursor: z.string().optional(), - has_more: z.coerce.boolean(), +export const RepoSitoryStoreSchema = z.object({ + id: z.string(), + name: z.string(), + path: z.string(), + origin: z.string().optional(), + current_branch: z.string().optional(), + ahead_behind: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), + has_uncommitted_changes: z.coerce.boolean(), + last_updated: z.coerce.number(), +}); + +export type RepoSitoryStore = z.infer; + +export const StashQuickStatSchema = z.object({ + reference: z.string(), + files_changed: z.coerce.number(), + insertions: z.coerce.number(), + deletions: z.coerce.number(), +}); + +export type StashQuickStat = z.infer; + +export const SessionNavigationInfoSchema = z.object({ + session_id: z.string(), + can_go_back: z.coerce.boolean(), + can_go_forward: z.coerce.boolean(), + current_path: z.string().optional(), }); -export type GraphPaging = z.infer; +export type SessionNavigationInfo = z.infer; -export const HistoryGraphResponseSchema = z.object({ - rows: z.array(GraphRowSchema), - cursor: z.string().optional(), - graph_state: z.string().optional(), - has_more: z.coerce.boolean(), - paging: GraphPagingSchema, +export const CommitMessageSchema = z.object({ + title: z.string(), + description: z.string().optional(), + co_authors: z.array(z.tuple([z.string(), z.string()])), }); -export type HistoryGraphResponse = z.infer; +export type CommitMessage = z.infer; -export const SessionActionRequestSchema = z.object({ - sessionId: z.string(), +export const BranchKindSchema = z.enum(["Local", "Remote"]); + +export const RebaseAbortPreviewSchema = z.object({ + origHead: z.string().optional(), + headName: z.string().optional(), + onto: z.string().optional(), + current: z.coerce.number().optional(), + total: z.coerce.number().optional(), + commitsApplied: z.coerce.number(), + warning: z.string(), }); -export type SessionActionRequest = z.infer; +export type RebaseAbortPreview = z.infer; -export const BranchStashSchema = z.object({ - reference: z.string(), - message: z.string(), - from_branch: z.string(), - to_branch: z.string(), - files_changed: z.coerce.number(), - insertions: z.coerce.number(), - deletions: z.coerce.number(), +export const RebaseUpdateTodoRequestSchema = z.object({ + entries: z.array(RebasePlanEntrySchema), }); -export type BranchStash = z.infer; - -export const PatchActionSchema = z.enum(["Stage", "Unstage", "Discard"]); +export type RebaseUpdateTodoRequest = z.infer; -export const RepositoryOriginSchema = z.object({ - remote_name: z.string(), - remote_url: z.string(), - host: z.string().optional(), - provider: z.string().optional(), - owner: z.string().optional(), - repo: z.string().optional(), - protocol: z.string(), +export const PatchRangeSchema = z.object({ + start: z.coerce.number().optional(), + count: z.coerce.number(), }); -export type RepositoryOrigin = z.infer; +export type PatchRange = z.infer; -export const GetStatusResponseSchema = z.object({ - files: z.array(FileStatusSchema), -}); +export const RebasePauseReasonSchema = z.enum(["conflict", "edit", "reword", "waiting"]); -export type GetStatusResponse = z.infer; +export const RepoOperationKindSchema = z.enum(["clean", "merge", "revert", "cherryPick", "bisect", "rebase", "rebaseInteractive", "rebaseMerge", "applyMailbox", "other"]); -export const HistoryQuerySchema = z.object({ - cursor: z.string().optional(), - limit: z.coerce.number(), - search: z.string().optional(), - branch: z.string().optional(), - graph_state: z.string().optional(), - include_local: z.coerce.boolean(), - include_remotes: z.coerce.boolean(), - include_tags: z.coerce.boolean(), - include_stash: z.coerce.boolean(), +export const RebaseTodoEntrySchema = z.object({ + index: z.coerce.number(), + action: RebaseActionSchema, + commit: z.string(), + shortCommit: z.string(), + message: z.string(), + status: RebaseTodoStatusSchema, + authoredAt: z.string().optional(), }); -export type HistoryQuery = z.infer; +export type RebaseTodoEntry = z.infer; export const RepoOperationSchema = z.object({ kind: RepoOperationKindSchema, @@ -410,18 +304,15 @@ export const RepoOperationSchema = z.object({ export type RepoOperation = z.infer; -export const RebaseUpdateTodoRequestSchema = z.object({ - entries: z.array(RebasePlanEntrySchema), -}); - -export type RebaseUpdateTodoRequest = z.infer; - -export const SessionPushToHistoryRequestSchema = z.object({ - sessionId: z.string(), - routePath: z.string(), +export const CommitActivityQuerySchema = z.object({ + limit: z.coerce.number(), + include_local: z.coerce.boolean(), + include_remotes: z.coerce.boolean(), + include_tags: z.coerce.boolean(), + include_stash: z.coerce.boolean(), }); -export type SessionPushToHistoryRequest = z.infer; +export type CommitActivityQuery = z.infer; export const PickaxeQuerySchema = z.object({ query: z.string(), @@ -438,47 +329,69 @@ export const PickaxeQuerySchema = z.object({ export type PickaxeQuery = z.infer; -export const RebasePlanSchema = z.object({ - onto: z.string(), +export const CommitInfoSchema = z.object({ + id: z.string(), + summary: z.string(), + body: z.string(), + timestamp: z.coerce.number(), + authors: CommitAuthorsSchema, +}); + +export type CommitInfo = z.infer; + +export const BranchInfoSchema = z.object({ + name: z.string(), + display_name: z.string(), + is_remote: z.coerce.boolean(), + is_head: z.coerce.boolean(), + commit: CommitInfoSchema, upstream: z.string().optional(), - entries: z.array(RebasePlanEntrySchema), - autostash: z.coerce.boolean(), + ahead: z.coerce.number().optional(), + behind: z.coerce.number().optional(), + is_protected: z.coerce.boolean(), + is_merged: z.coerce.boolean(), }); -export type RebasePlan = z.infer; +export type BranchInfo = z.infer; -export const SessionNavigationInfoSchema = z.object({ - session_id: z.string(), - can_go_back: z.coerce.boolean(), - can_go_forward: z.coerce.boolean(), - current_path: z.string().optional(), +export const BranchSchema = z.object({ + name: z.string(), + display_name: z.string(), + is_remote: z.coerce.boolean(), + is_detached: z.coerce.boolean(), }); -export type SessionNavigationInfo = z.infer; +export type Branch = z.infer; -export const PatchRangeSchema = z.object({ - start: z.coerce.number().optional(), - count: z.coerce.number(), +export const DiffScopeSchema = z.enum(["Worktree", "Staged", "Unstaged"]); + +export const PatchActionSchema = z.enum(["Stage", "Unstage", "Discard"]); + +export const StashShowResponseSchema = z.object({ + reference: z.string(), + stat: StashQuickStatSchema, + files: z.array(FileStatusSchema), }); -export type PatchRange = z.infer; +export type StashShowResponse = z.infer; -export const CommitActivityItemSchema = z.object({ - oid: z.string(), - timestamp: z.coerce.number(), - insertions: z.coerce.number(), - deletions: z.coerce.number(), +export const SessionPushToHistoryRequestSchema = z.object({ + sessionId: z.string(), + routePath: z.string(), }); -export type CommitActivityItem = z.infer; +export type SessionPushToHistoryRequest = z.infer; -export const CommitActivityResponseSchema = z.object({ - items: z.array(CommitActivityItemSchema), - head_index: z.coerce.number().optional(), - total: z.coerce.number(), +export const UpdateCheckResponseSchema = z.object({ + available: z.coerce.boolean(), + channel: z.string(), + current_version: z.string(), + version: z.string().optional(), + notes: z.string().optional(), + pub_date: z.string().optional(), }); -export type CommitActivityResponse = z.infer; +export type UpdateCheckResponse = z.infer; export const AheadBehindStatusSchema = z.object({ ahead: z.coerce.number(), @@ -493,82 +406,93 @@ export const AheadBehindStatusSchema = z.object({ export type AheadBehindStatus = z.infer; - -export const RepositoryOriginParamsSchema = z.object({ - contextId: z.string(), +export const RepositoryOriginSchema = z.object({ + remote_name: z.string(), + remote_url: z.string(), + host: z.string().optional(), + provider: z.string().optional(), + owner: z.string().optional(), + repo: z.string().optional(), + protocol: z.string(), }); -export const StartPickaxeParamsSchema = z.object({ - contextId: z.string(),query: PickaxeQuerySchema, -}); +export type RepositoryOrigin = z.infer; -export const CancelPickaxeParamsSchema = z.object({ - operationId: z.string(), +export const RebaseStartRequestSchema = z.object({ + onto: z.string(), + upstream: z.string().optional(), + entries: z.array(RebasePlanEntrySchema).optional(), + autostash: z.coerce.boolean(), }); -export const ListRepositoriesParamsSchema = z.object({ - refreshStale: z.coerce.boolean().optional().optional(), -}); +export type RebaseStartRequest = z.infer; -export const AddRepositoryParamsSchema = z.object({ - repo: RepositoryInfoSchema, +export const AssetDiffSchema = z.object({ + kind: AssetDiffKindSchema, + before: AssetDiffEntrySchema.optional(), + after: AssetDiffEntrySchema.optional(), }); -export const RemoveRepositoryParamsSchema = z.object({ - repoId: z.string(), -}); +export type AssetDiff = z.infer; -export const RefreshRepositoryInfoParamsSchema = z.object({ - repoId: z.string(), +export const DiffTextFileSchema = z.object({ + name: z.string(), + contents: z.string(), + byte_length: z.coerce.number(), + encoding: z.string(), }); -export const GetRepoOperationParamsSchema = z.object({ - contextId: z.string(), -}); +export type DiffTextFile = z.infer; -export const RebasePlanParamsSchema = z.object({ - contextId: z.string(),onto: z.string(),upstream: z.string().optional().optional(), +export const FileDiffSchema = z.object({ + patch: z.string(), + asset_diff: AssetDiffSchema.optional(), + oldFile: DiffTextFileSchema.optional(), + newFile: DiffTextFileSchema.optional(), }); -export const RebaseStartParamsSchema = z.object({ - contextId: z.string(),request: RebaseStartRequestSchema, -}); +export type FileDiff = z.infer; -export const RebaseContinueParamsSchema = z.object({ - contextId: z.string(),message: z.string().optional().optional(), +export const BranchStashSchema = z.object({ + reference: z.string(), + message: z.string(), + from_branch: z.string(), + to_branch: z.string(), + files_changed: z.coerce.number(), + insertions: z.coerce.number(), + deletions: z.coerce.number(), }); -export const RebaseSkipParamsSchema = z.object({ - contextId: z.string(), -}); +export type BranchStash = z.infer; -export const RebaseAbortParamsSchema = z.object({ - contextId: z.string(), +export const GetStatusResponseSchema = z.object({ + files: z.array(FileStatusSchema), }); -export const RebaseAbortPreviewParamsSchema = z.object({ - contextId: z.string(), -}); +export type GetStatusResponse = z.infer; -export const RebaseUpdateTodoParamsSchema = z.object({ - contextId: z.string(),request: RebaseUpdateTodoRequestSchema, +export const HistoryQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number(), + search: z.string().optional(), + branch: z.string().optional(), + graph_state: z.string().optional(), + include_local: z.coerce.boolean(), + include_remotes: z.coerce.boolean(), + include_tags: z.coerce.boolean(), + include_stash: z.coerce.boolean(), }); -export const RebaseSetCommitMessageParamsSchema = z.object({ - contextId: z.string(),message: z.string(), -}); +export type HistoryQuery = z.infer; -export const RebaseResolveConflictParamsSchema = z.object({ - contextId: z.string(),request: ConflictResolveRequestSchema, +export const CommitActivityResponseSchema = z.object({ + items: z.array(CommitActivityItemSchema), + head_index: z.coerce.number().optional(), + total: z.coerce.number(), }); -export const CheckForUpdateByChannelParamsSchema = z.object({ - channel: z.string(), -}); +export type CommitActivityResponse = z.infer; -export const DownloadAndInstallUpdateByChannelParamsSchema = z.object({ - channel: z.string(), -}); export const GitVersionParamsSchema = z.object({ contextId: z.string(), @@ -606,16 +530,44 @@ export const WriteWorktreeFileParamsSchema = z.object({ contextId: z.string(),path: z.string(),contents: z.string(), }); -export const LastCommitParamsSchema = z.object({ +export const RepositoryOriginParamsSchema = z.object({ contextId: z.string(), }); -export const CommitByIdParamsSchema = z.object({ - contextId: z.string(),hash: z.string(), +export const OpenExternalUrlParamsSchema = z.object({ + url: z.string(), }); -export const CreateCommitParamsSchema = z.object({ - contextId: z.string(),commitMeta: CommitMessageSchema,allowEmpty: z.coerce.boolean(), +export const GetPatchByFilePathParamsSchema = z.object({ + contextId: z.string(),filePath: z.string(),fileNewPath: z.string().optional().optional(),status: z.array(FileStatusKindSchema).optional().optional(),stashReference: z.string().optional().optional(),commitHash: z.string().optional().optional(),parentIndex: z.coerce.number().optional().optional(),diffScope: DiffScopeSchema.optional().optional(), +}); + +export const ListRepositoriesParamsSchema = z.object({ + refreshStale: z.coerce.boolean().optional().optional(), +}); + +export const AddRepositoryParamsSchema = z.object({ + repo: RepositoryInfoSchema, +}); + +export const RemoveRepositoryParamsSchema = z.object({ + repoId: z.string(), +}); + +export const RefreshRepositoryInfoParamsSchema = z.object({ + repoId: z.string(), +}); + +export const HistoryParamsSchema = z.object({ + contextId: z.string(),skip: z.coerce.number(),limit: z.coerce.number(), +}); + +export const HistoryGraphParamsSchema = z.object({ + contextId: z.string(),query: HistoryQuerySchema, +}); + +export const CommitActivityParamsSchema = z.object({ + contextId: z.string(),query: CommitActivityQuerySchema, }); export const CurrentBranchParamsSchema = z.object({ @@ -686,6 +638,78 @@ export const PopCurrentBranchStashParamsSchema = z.object({ contextId: z.string(), }); +export const StartPickaxeParamsSchema = z.object({ + contextId: z.string(),query: PickaxeQuerySchema, +}); + +export const CancelPickaxeParamsSchema = z.object({ + operationId: z.string(), +}); + +export const CheckForUpdateByChannelParamsSchema = z.object({ + channel: z.string(), +}); + +export const DownloadAndInstallUpdateByChannelParamsSchema = z.object({ + channel: z.string(), +}); + +export const GetRepoOperationParamsSchema = z.object({ + contextId: z.string(), +}); + +export const RebasePlanParamsSchema = z.object({ + contextId: z.string(),onto: z.string(),upstream: z.string().optional().optional(), +}); + +export const RebaseStartParamsSchema = z.object({ + contextId: z.string(),request: RebaseStartRequestSchema, +}); + +export const RebaseContinueParamsSchema = z.object({ + contextId: z.string(),message: z.string().optional().optional(), +}); + +export const RebaseSkipParamsSchema = z.object({ + contextId: z.string(), +}); + +export const RebaseAbortParamsSchema = z.object({ + contextId: z.string(), +}); + +export const RebaseAbortPreviewParamsSchema = z.object({ + contextId: z.string(), +}); + +export const RebaseUpdateTodoParamsSchema = z.object({ + contextId: z.string(),request: RebaseUpdateTodoRequestSchema, +}); + +export const RebaseSetCommitMessageParamsSchema = z.object({ + contextId: z.string(),message: z.string(), +}); + +export const RebaseResolveConflictParamsSchema = z.object({ + contextId: z.string(),request: ConflictResolveRequestSchema, +}); + +export const LastCommitParamsSchema = z.object({ + contextId: z.string(), +}); + +export const CommitByIdParamsSchema = z.object({ + contextId: z.string(),hash: z.string(), +}); + +export const CreateCommitParamsSchema = z.object({ + contextId: z.string(),commitMeta: CommitMessageSchema,allowEmpty: z.coerce.boolean(),amend: z.coerce.boolean(),expectedHead: z.string().optional().optional(), +}); + +export const CommitAuthorsParamsSchema = z.object({ + contextId: z.string(), +}); + export const StashListParamsSchema = z.object({ contextId: z.string(), }); @@ -726,26 +750,6 @@ export const StashRestoreFileParamsSchema = z.object({ contextId: z.string(),reference: z.string(),filePath: z.string(), }); -export const HistoryParamsSchema = z.object({ - contextId: z.string(),skip: z.coerce.number(),limit: z.coerce.number(), -}); - -export const HistoryGraphParamsSchema = z.object({ - contextId: z.string(),query: HistoryQuerySchema, -}); - -export const CommitActivityParamsSchema = z.object({ - contextId: z.string(),query: CommitActivityQuerySchema, -}); - -export const OpenExternalUrlParamsSchema = z.object({ - url: z.string(), -}); - -export const GetPatchByFilePathParamsSchema = z.object({ - contextId: z.string(),filePath: z.string(),fileNewPath: z.string().optional().optional(),status: z.array(FileStatusKindSchema).optional().optional(),stashReference: z.string().optional().optional(),commitHash: z.string().optional().optional(),parentIndex: z.coerce.number().optional().optional(),diffScope: DiffScopeSchema.optional().optional(), -}); - export const AddLocalGitRepoParamsSchema = z.object({ repoPath: z.string(), }); @@ -797,44 +801,6 @@ export const SessionClearHistoryParamsSchema = z.object({ -export type RepositoryOriginParams = z.infer; - -export type StartPickaxeParams = z.infer; - -export type CancelPickaxeParams = z.infer; - -export type ListRepositoriesParams = z.infer; - -export type AddRepositoryParams = z.infer; - -export type RemoveRepositoryParams = z.infer; - -export type RefreshRepositoryInfoParams = z.infer; - -export type GetRepoOperationParams = z.infer; - -export type RebasePlanParams = z.infer; - -export type RebaseStartParams = z.infer; - -export type RebaseContinueParams = z.infer; - -export type RebaseSkipParams = z.infer; - -export type RebaseAbortParams = z.infer; - -export type RebaseAbortPreviewParams = z.infer; - -export type RebaseUpdateTodoParams = z.infer; - -export type RebaseSetCommitMessageParams = z.infer; - -export type RebaseResolveConflictParams = z.infer; - -export type CheckForUpdateByChannelParams = z.infer; - -export type DownloadAndInstallUpdateByChannelParams = z.infer; - export type GitVersionParams = z.infer; export type GetStatusParams = z.infer; @@ -853,11 +819,25 @@ export type ReadWorktreeFileParams = z.infer; -export type LastCommitParams = z.infer; +export type RepositoryOriginParams = z.infer; -export type CommitByIdParams = z.infer; +export type OpenExternalUrlParams = z.infer; -export type CreateCommitParams = z.infer; +export type GetPatchByFilePathParams = z.infer; + +export type ListRepositoriesParams = z.infer; + +export type AddRepositoryParams = z.infer; + +export type RemoveRepositoryParams = z.infer; + +export type RefreshRepositoryInfoParams = z.infer; + +export type HistoryParams = z.infer; + +export type HistoryGraphParams = z.infer; + +export type CommitActivityParams = z.infer; export type CurrentBranchParams = z.infer; @@ -893,6 +873,42 @@ export type CurrentBranchStashParams = z.infer; +export type StartPickaxeParams = z.infer; + +export type CancelPickaxeParams = z.infer; + +export type CheckForUpdateByChannelParams = z.infer; + +export type DownloadAndInstallUpdateByChannelParams = z.infer; + +export type GetRepoOperationParams = z.infer; + +export type RebasePlanParams = z.infer; + +export type RebaseStartParams = z.infer; + +export type RebaseContinueParams = z.infer; + +export type RebaseSkipParams = z.infer; + +export type RebaseAbortParams = z.infer; + +export type RebaseAbortPreviewParams = z.infer; + +export type RebaseUpdateTodoParams = z.infer; + +export type RebaseSetCommitMessageParams = z.infer; + +export type RebaseResolveConflictParams = z.infer; + +export type LastCommitParams = z.infer; + +export type CommitByIdParams = z.infer; + +export type CreateCommitParams = z.infer; + +export type CommitAuthorsParams = z.infer; + export type StashListParams = z.infer; export type StashQuickStatParams = z.infer; @@ -913,16 +929,6 @@ export type StashBranchParams = z.infer; export type StashRestoreFileParams = z.infer; -export type HistoryParams = z.infer; - -export type HistoryGraphParams = z.infer; - -export type CommitActivityParams = z.infer; - -export type OpenExternalUrlParams = z.infer; - -export type GetPatchByFilePathParams = z.infer; - export type AddLocalGitRepoParams = z.infer; export type CloneRepositoryParams = z.infer;