import { useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api/core'; import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation'; import { queryClient } from '@yaakapp/app/lib/queryClient'; import { useMemo } from 'react'; import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git'; import { showToast } from '@yaakapp/app/lib/toast'; export * from './bindings/gen_git'; export * from './bindings/gen_models'; export interface GitCredentials { username: string; password: string; } export type DivergedStrategy = 'force_reset' | 'merge' | 'cancel'; export type UncommittedChangesStrategy = 'reset' | 'cancel'; export interface GitCallbacks { addRemote: () => Promise; promptCredentials: ( result: Extract, ) => Promise; promptDiverged: ( result: Extract, ) => Promise; promptUncommittedChanges: () => Promise; forceSync: () => Promise; } const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] }); export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) { const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]); const fetchAll = useQuery({ queryKey: ['git', 'fetch_all', dir, refreshKey], queryFn: () => invoke('cmd_git_fetch_all', { dir }), refetchInterval: 10 * 60_000, }); return [ { remotes: useQuery({ queryKey: ['git', 'remotes', dir, refreshKey], queryFn: () => getRemotes(dir), placeholderData: (prev) => prev, }), log: useQuery({ queryKey: ['git', 'log', dir, refreshKey], queryFn: () => invoke('cmd_git_log', { dir }), placeholderData: (prev) => prev, }), status: useQuery({ refetchOnMount: true, queryKey: ['git', 'status', dir, refreshKey, fetchAll.dataUpdatedAt], queryFn: () => invoke('cmd_git_status', { dir }), placeholderData: (prev) => prev, }), }, mutations, ] as const; } export const gitMutations = (dir: string, callbacks: GitCallbacks) => { const push = async () => { const remotes = await getRemotes(dir); if (remotes.length === 0) { const remote = await callbacks.addRemote(); if (remote == null) throw new Error('No remote found'); } const result = await invoke('cmd_git_push', { dir }); if (result.type !== 'needs_credentials') return result; // Needs credentials, prompt for them const creds = await callbacks.promptCredentials(result); if (creds == null) throw new Error('Canceled'); await invoke('cmd_git_add_credential', { remoteUrl: result.url, username: creds.username, password: creds.password, }); // Push again return invoke('cmd_git_push', { dir }); }; const handleError = (err: unknown) => { showToast({ id: `${err}`, message: `${err}`, color: 'danger', timeout: 5000, }); } return { init: createFastMutation({ mutationKey: ['git', 'init'], mutationFn: () => invoke('cmd_git_initialize', { dir }), onSuccess, }), add: createFastMutation({ mutationKey: ['git', 'add', dir], mutationFn: (args) => invoke('cmd_git_add', { dir, ...args }), onSuccess, }), addRemote: createFastMutation({ mutationKey: ['git', 'add-remote'], mutationFn: (args) => invoke('cmd_git_add_remote', { dir, ...args }), onSuccess, }), rmRemote: createFastMutation({ mutationKey: ['git', 'rm-remote', dir], mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }), onSuccess, }), createBranch: createFastMutation({ mutationKey: ['git', 'branch', dir], mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }), onSuccess, }), mergeBranch: createFastMutation({ mutationKey: ['git', 'merge', dir], mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }), onSuccess, }), deleteBranch: createFastMutation({ mutationKey: ['git', 'delete-branch', dir], mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }), onSuccess, }), deleteRemoteBranch: createFastMutation({ mutationKey: ['git', 'delete-remote-branch', dir], mutationFn: (args) => invoke('cmd_git_delete_remote_branch', { dir, ...args }), onSuccess, }), renameBranch: createFastMutation({ mutationKey: ['git', 'rename-branch', dir], mutationFn: (args) => invoke('cmd_git_rename_branch', { dir, ...args }), onSuccess, }), checkout: createFastMutation({ mutationKey: ['git', 'checkout', dir], mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }), onSuccess, }), commit: createFastMutation({ mutationKey: ['git', 'commit', dir], mutationFn: (args) => invoke('cmd_git_commit', { dir, ...args }), onSuccess, }), commitAndPush: createFastMutation({ mutationKey: ['git', 'commit_push', dir], mutationFn: async (args) => { await invoke('cmd_git_commit', { dir, ...args }); return push(); }, onSuccess, }), push: createFastMutation({ mutationKey: ['git', 'push', dir], mutationFn: push, onSuccess, }), pull: createFastMutation({ mutationKey: ['git', 'pull', dir], async mutationFn() { const result = await invoke('cmd_git_pull', { dir }); if (result.type === 'needs_credentials') { const creds = await callbacks.promptCredentials(result); if (creds == null) throw new Error('Canceled'); await invoke('cmd_git_add_credential', { remoteUrl: result.url, username: creds.username, password: creds.password, }); // Pull again after credentials return invoke('cmd_git_pull', { dir }); } if (result.type === 'uncommitted_changes') { callbacks.promptUncommittedChanges().then(async (strategy) => { if (strategy === 'cancel') return; await invoke('cmd_git_reset_changes', { dir }); return invoke('cmd_git_pull', { dir }); }).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError); } if (result.type === 'diverged') { callbacks.promptDiverged(result).then((strategy) => { if (strategy === 'cancel') return; if (strategy === 'force_reset') { return invoke('cmd_git_pull_force_reset', { dir, remote: result.remote, branch: result.branch, }); } return invoke('cmd_git_pull_merge', { dir, remote: result.remote, branch: result.branch, }); }).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError); } return result; }, onSuccess, }), unstage: createFastMutation({ mutationKey: ['git', 'unstage', dir], mutationFn: (args) => invoke('cmd_git_unstage', { dir, ...args }), onSuccess, }), resetChanges: createFastMutation({ mutationKey: ['git', 'reset-changes', dir], mutationFn: () => invoke('cmd_git_reset_changes', { dir }), onSuccess, }), } as const; }; async function getRemotes(dir: string) { return invoke('cmd_git_remotes', { dir }); } /** * Clone a git repository, prompting for credentials if needed. */ export async function gitClone( url: string, dir: string, promptCredentials: (args: { url: string; error: string | null }) => Promise, ): Promise { const result = await invoke('cmd_git_clone', { url, dir }); if (result.type !== 'needs_credentials') return result; // Prompt for credentials const creds = await promptCredentials({ url: result.url, error: result.error }); if (creds == null) return {type: 'cancelled'}; // Store credentials and retry await invoke('cmd_git_add_credential', { remoteUrl: result.url, username: creds.username, password: creds.password, }); return invoke('cmd_git_clone', { url, dir }); }