From 9c52652a5e8219820e7989d27759fbbd69fecb99 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 17 Nov 2025 15:22:39 -0800 Subject: [PATCH] Move a bunch of git ops to use the git binary (#302) --- src-tauri/yaak-common/src/lib.rs | 2 +- src-tauri/yaak-git/bindings/gen_git.ts | 10 +- src-tauri/yaak-git/build.rs | 4 + src-tauri/yaak-git/index.ts | 224 ++++-- src-tauri/yaak-git/permissions/default.toml | 4 + src-tauri/yaak-git/src/add.rs | 16 + src-tauri/yaak-git/src/binary.rs | 16 + src-tauri/yaak-git/src/branch.rs | 26 +- src-tauri/yaak-git/src/callbacks.rs | 76 -- src-tauri/yaak-git/src/commands.rs | 41 +- src-tauri/yaak-git/src/commit.rs | 20 + src-tauri/yaak-git/src/credential.rs | 47 ++ src-tauri/yaak-git/src/error.rs | 9 + src-tauri/yaak-git/src/fetch.rs | 39 +- src-tauri/yaak-git/src/git.rs | 673 ------------------ src-tauri/yaak-git/src/init.rs | 14 + src-tauri/yaak-git/src/lib.rs | 24 +- src-tauri/yaak-git/src/log.rs | 73 ++ src-tauri/yaak-git/src/pull.rs | 112 ++- src-tauri/yaak-git/src/push.rs | 94 ++- src-tauri/yaak-git/src/remotes.rs | 53 ++ src-tauri/yaak-git/src/status.rs | 172 +++++ src-tauri/yaak-git/src/unstage.rs | 28 + src-tauri/yaak-git/src/util.rs | 59 +- src-web/components/CreateWorkspaceDialog.tsx | 12 +- src-web/components/Dialogs.tsx | 8 +- src-web/components/DynamicForm.tsx | 49 +- src-web/components/EnvironmentEditDialog.tsx | 1 + src-web/components/Sidebar.tsx | 24 +- src-web/components/core/Banner.tsx | 2 +- src-web/components/core/Icon.tsx | 2 + src-web/components/core/PlainInput.tsx | 13 +- src-web/components/core/Prompt.tsx | 43 +- src-web/components/core/Toast.tsx | 4 +- .../components/{ => git}/GitCommitDialog.tsx | 58 +- src-web/components/{ => git}/GitDropdown.tsx | 91 ++- src-web/components/git/GitRemotesDialog.tsx | 67 ++ src-web/components/git/callbacks.tsx | 48 ++ src-web/components/git/git-util.ts | 30 + .../components/git/showAddRemoteDialog.tsx | 20 + src-web/components/graphql/GraphQLEditor.tsx | 8 +- src-web/lib/prompt-form.tsx | 39 + src-web/lib/prompt.ts | 59 +- 43 files changed, 1238 insertions(+), 1176 deletions(-) create mode 100644 src-tauri/yaak-git/src/add.rs create mode 100644 src-tauri/yaak-git/src/binary.rs delete mode 100644 src-tauri/yaak-git/src/callbacks.rs create mode 100644 src-tauri/yaak-git/src/commit.rs create mode 100644 src-tauri/yaak-git/src/credential.rs delete mode 100644 src-tauri/yaak-git/src/git.rs create mode 100644 src-tauri/yaak-git/src/init.rs create mode 100644 src-tauri/yaak-git/src/log.rs create mode 100644 src-tauri/yaak-git/src/remotes.rs create mode 100644 src-tauri/yaak-git/src/status.rs create mode 100644 src-tauri/yaak-git/src/unstage.rs rename src-web/components/{ => git}/GitCommitDialog.tsx (87%) rename src-web/components/{ => git}/GitDropdown.tsx (84%) create mode 100644 src-web/components/git/GitRemotesDialog.tsx create mode 100644 src-web/components/git/callbacks.tsx create mode 100644 src-web/components/git/git-util.ts create mode 100644 src-web/components/git/showAddRemoteDialog.tsx create mode 100644 src-web/lib/prompt-form.tsx diff --git a/src-tauri/yaak-common/src/lib.rs b/src-tauri/yaak-common/src/lib.rs index 6229a01f..26684eff 100644 --- a/src-tauri/yaak-common/src/lib.rs +++ b/src-tauri/yaak-common/src/lib.rs @@ -1,4 +1,4 @@ pub mod window; pub mod platform; pub mod api_client; -pub mod error; \ No newline at end of file +pub mod error; diff --git a/src-tauri/yaak-git/bindings/gen_git.ts b/src-tauri/yaak-git/bindings/gen_git.ts index 9308c283..e1d7ef8a 100644 --- a/src-tauri/yaak-git/bindings/gen_git.ts +++ b/src-tauri/yaak-git/bindings/gen_git.ts @@ -1,18 +1,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SyncModel } from "./gen_models.js"; +import type { SyncModel } from "./gen_models"; export type GitAuthor = { name: string | null, email: string | null, }; export type GitCommit = { author: GitAuthor, when: string, message: string | null, }; +export type GitRemote = { name: string, url: string | null, }; + export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change"; export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, }; export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array, origins: Array, localBranches: Array, remoteBranches: Array, }; -export type PullResult = { receivedBytes: number, receivedObjects: number, }; +export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, }; -export type PushResult = "success" | "nothing_to_push"; - -export type PushType = "branch" | "tag"; +export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, }; diff --git a/src-tauri/yaak-git/build.rs b/src-tauri/yaak-git/build.rs index 63112b82..7811ebd2 100644 --- a/src-tauri/yaak-git/build.rs +++ b/src-tauri/yaak-git/build.rs @@ -1,5 +1,7 @@ const COMMANDS: &[&str] = &[ "add", + "add_credential", + "add_remote", "branch", "checkout", "commit", @@ -10,6 +12,8 @@ const COMMANDS: &[&str] = &[ "merge_branch", "pull", "push", + "remotes", + "rm_remote", "status", "unstage", ]; diff --git a/src-tauri/yaak-git/index.ts b/src-tauri/yaak-git/index.ts index 3e61add7..37b3a369 100644 --- a/src-tauri/yaak-git/index.ts +++ b/src-tauri/yaak-git/index.ts @@ -1,96 +1,168 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api/core'; -import { GitCommit, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git'; +import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation'; +import { queryClient } from '@yaakapp/app/lib/queryClient'; +import { useMemo } from 'react'; +import { GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git'; export * from './bindings/gen_git'; -export function useGit(dir: string) { - const queryClient = useQueryClient(); - const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] }); +export interface GitCredentials { + username: string; + password: string; +} +export interface GitCallbacks { + addRemote: () => Promise; + promptCredentials: ( + result: Extract, + ) => Promise; +} + +const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] }); + +export function useGit(dir: string, callbacks: GitCallbacks) { + const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]); return [ { - log: useQuery({ + remotes: useQuery({ + queryKey: ['git', 'remotes', dir], + queryFn: () => getRemotes(dir), + }), + log: useQuery({ queryKey: ['git', 'log', dir], queryFn: () => invoke('plugin:yaak-git|log', { dir }), }), - status: useQuery({ + status: useQuery({ refetchOnMount: true, queryKey: ['git', 'status', dir], queryFn: () => invoke('plugin:yaak-git|status', { dir }), }), }, - { - add: useMutation({ - mutationKey: ['git', 'add', dir], - mutationFn: (args) => invoke('plugin:yaak-git|add', { dir, ...args }), - onSuccess, - }), - branch: useMutation({ - mutationKey: ['git', 'branch', dir], - mutationFn: (args) => invoke('plugin:yaak-git|branch', { dir, ...args }), - onSuccess, - }), - mergeBranch: useMutation({ - mutationKey: ['git', 'merge', dir], - mutationFn: (args) => invoke('plugin:yaak-git|merge_branch', { dir, ...args }), - onSuccess, - }), - deleteBranch: useMutation({ - mutationKey: ['git', 'delete-branch', dir], - mutationFn: (args) => invoke('plugin:yaak-git|delete_branch', { dir, ...args }), - onSuccess, - }), - checkout: useMutation({ - mutationKey: ['git', 'checkout', dir], - mutationFn: (args) => invoke('plugin:yaak-git|checkout', { dir, ...args }), - onSuccess, - }), - commit: useMutation({ - mutationKey: ['git', 'commit', dir], - mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }), - onSuccess, - }), - commitAndPush: useMutation({ - mutationKey: ['git', 'commit_push', dir], - mutationFn: async (args) => { - await invoke('plugin:yaak-git|commit', { dir, ...args }); - return invoke('plugin:yaak-git|push', { dir }); - }, - onSuccess, - }), - fetchAll: useMutation({ - mutationKey: ['git', 'checkout', dir], - mutationFn: () => invoke('plugin:yaak-git|fetch_all', { dir }), - onSuccess, - }), - push: useMutation({ - mutationKey: ['git', 'push', dir], - mutationFn: () => invoke('plugin:yaak-git|push', { dir }), - onSuccess, - }), - pull: useMutation({ - mutationKey: ['git', 'pull', dir], - mutationFn: () => invoke('plugin:yaak-git|pull', { dir }), - onSuccess, - }), - unstage: useMutation({ - mutationKey: ['git', 'unstage', dir], - mutationFn: (args) => invoke('plugin:yaak-git|unstage', { dir, ...args }), - onSuccess, - }), - init: useGitInit(), - }, + mutations, ] as const; } -export function useGitInit() { - const queryClient = useQueryClient(); - const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] }); +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'); + } - return useMutation({ - mutationKey: ['git', 'init'], - mutationFn: (args) => invoke('plugin:yaak-git|initialize', { ...args }), - onSuccess, - }); + const result = await invoke('plugin:yaak-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('plugin:yaak-git|add_credential', { + dir, + remoteUrl: result.url, + username: creds.username, + password: creds.password, + }); + + // Push again + return invoke('plugin:yaak-git|push', { dir }); + }; + + return { + init: createFastMutation({ + mutationKey: ['git', 'init'], + mutationFn: () => invoke('plugin:yaak-git|initialize', { dir }), + onSuccess, + }), + add: createFastMutation({ + mutationKey: ['git', 'add', dir], + mutationFn: (args) => invoke('plugin:yaak-git|add', { dir, ...args }), + onSuccess, + }), + addRemote: createFastMutation({ + mutationKey: ['git', 'add-remote'], + mutationFn: (args) => invoke('plugin:yaak-git|add_remote', { dir, ...args }), + onSuccess, + }), + rmRemote: createFastMutation({ + mutationKey: ['git', 'rm-remote', dir], + mutationFn: (args) => invoke('plugin:yaak-git|rm_remote', { dir, ...args }), + onSuccess, + }), + branch: createFastMutation({ + mutationKey: ['git', 'branch', dir], + mutationFn: (args) => invoke('plugin:yaak-git|branch', { dir, ...args }), + onSuccess, + }), + mergeBranch: createFastMutation({ + mutationKey: ['git', 'merge', dir], + mutationFn: (args) => invoke('plugin:yaak-git|merge_branch', { dir, ...args }), + onSuccess, + }), + deleteBranch: createFastMutation({ + mutationKey: ['git', 'delete-branch', dir], + mutationFn: (args) => invoke('plugin:yaak-git|delete_branch', { dir, ...args }), + onSuccess, + }), + checkout: createFastMutation({ + mutationKey: ['git', 'checkout', dir], + mutationFn: (args) => invoke('plugin:yaak-git|checkout', { dir, ...args }), + onSuccess, + }), + commit: createFastMutation({ + mutationKey: ['git', 'commit', dir], + mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }), + onSuccess, + }), + commitAndPush: createFastMutation({ + mutationKey: ['git', 'commit_push', dir], + mutationFn: async (args) => { + await invoke('plugin:yaak-git|commit', { dir, ...args }); + return push(); + }, + onSuccess, + }), + fetchAll: createFastMutation({ + mutationKey: ['git', 'checkout', dir], + mutationFn: () => invoke('plugin:yaak-git|fetch_all', { dir }), + onSuccess, + }), + push: createFastMutation({ + mutationKey: ['git', 'push', dir], + mutationFn: push, + onSuccess, + }), + pull: createFastMutation({ + mutationKey: ['git', 'pull', dir], + async mutationFn() { + const result = await invoke('plugin:yaak-git|pull', { 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('plugin:yaak-git|add_credential', { + dir, + remoteUrl: result.url, + username: creds.username, + password: creds.password, + }); + + // Pull again + return invoke('plugin:yaak-git|pull', { dir }); + }, + onSuccess, + }), + unstage: createFastMutation({ + mutationKey: ['git', 'unstage', dir], + mutationFn: (args) => invoke('plugin:yaak-git|unstage', { dir, ...args }), + onSuccess, + }), + } as const; +}; + +async function getRemotes(dir: string) { + return invoke('plugin:yaak-git|remotes', { dir }); } diff --git a/src-tauri/yaak-git/permissions/default.toml b/src-tauri/yaak-git/permissions/default.toml index 357e106e..89230531 100644 --- a/src-tauri/yaak-git/permissions/default.toml +++ b/src-tauri/yaak-git/permissions/default.toml @@ -2,6 +2,8 @@ description = "Default permissions for the plugin" permissions = [ "allow-add", + "allow-add-credential", + "allow-add-remote", "allow-branch", "allow-checkout", "allow-commit", @@ -12,6 +14,8 @@ permissions = [ "allow-merge-branch", "allow-pull", "allow-push", + "allow-remotes", + "allow-rm-remote", "allow-status", "allow-unstage", ] diff --git a/src-tauri/yaak-git/src/add.rs b/src-tauri/yaak-git/src/add.rs new file mode 100644 index 00000000..bdfc0db6 --- /dev/null +++ b/src-tauri/yaak-git/src/add.rs @@ -0,0 +1,16 @@ +use crate::error::Result; +use crate::repository::open_repo; +use git2::IndexAddOption; +use log::info; +use std::path::Path; + +pub(crate) fn git_add(dir: &Path, rela_path: &Path) -> Result<()> { + let repo = open_repo(dir)?; + let mut index = repo.index()?; + + info!("Staging file {rela_path:?} to {dir:?}"); + index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?; + index.write()?; + + Ok(()) +} diff --git a/src-tauri/yaak-git/src/binary.rs b/src-tauri/yaak-git/src/binary.rs new file mode 100644 index 00000000..54258203 --- /dev/null +++ b/src-tauri/yaak-git/src/binary.rs @@ -0,0 +1,16 @@ +use crate::error::Error::GitNotFound; +use crate::error::Result; +use std::path::Path; +use std::process::Command; + +pub(crate) fn new_binary_command(dir: &Path) -> Result { + let status = Command::new("git").arg("--version").status(); + + if let Err(_) = status { + return Err(GitNotFound); + } + + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(dir); + Ok(cmd) +} diff --git a/src-tauri/yaak-git/src/branch.rs b/src-tauri/yaak-git/src/branch.rs index 9889ed7d..bfa9918d 100644 --- a/src-tauri/yaak-git/src/branch.rs +++ b/src-tauri/yaak-git/src/branch.rs @@ -2,26 +2,12 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::merge::do_merge; use crate::repository::open_repo; -use crate::util::{ - bytes_to_string, get_branch_by_name, get_current_branch, get_default_remote_for_push_in_repo, -}; +use crate::util::{bytes_to_string, get_branch_by_name, get_current_branch}; +use git2::BranchType; use git2::build::CheckoutBuilder; -use git2::{BranchType, Repository}; use log::info; use std::path::Path; -pub(crate) fn branch_set_upstream_after_push(repo: &Repository, branch_name: &str) -> Result<()> { - let mut branch = repo.find_branch(branch_name, BranchType::Local)?; - - if branch.upstream().is_err() { - let remote = get_default_remote_for_push_in_repo(repo)?; - let upstream_name = format!("{remote}/{branch_name}"); - branch.set_upstream(Some(upstream_name.as_str()))?; - } - - Ok(()) -} - pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result { if branch_name.starts_with("origin/") { return git_checkout_remote_branch(dir, branch_name, force); @@ -43,7 +29,11 @@ pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Ok(branch_name.to_string()) } -pub(crate) fn git_checkout_remote_branch(dir: &Path, branch_name: &str, force: bool) -> Result { +pub(crate) fn git_checkout_remote_branch( + dir: &Path, + branch_name: &str, + force: bool, +) -> Result { let branch_name = branch_name.trim_start_matches("origin/"); let repo = open_repo(dir)?; @@ -55,7 +45,7 @@ pub(crate) fn git_checkout_remote_branch(dir: &Path, branch_name: &str, force: b let upstream_name = format!("origin/{}", branch_name); new_branch.set_upstream(Some(&upstream_name))?; - return git_checkout_branch(dir, branch_name, force) + git_checkout_branch(dir, branch_name, force) } pub(crate) fn git_create_branch(dir: &Path, name: &str) -> Result<()> { diff --git a/src-tauri/yaak-git/src/callbacks.rs b/src-tauri/yaak-git/src/callbacks.rs deleted file mode 100644 index e44244d2..00000000 --- a/src-tauri/yaak-git/src/callbacks.rs +++ /dev/null @@ -1,76 +0,0 @@ -use git2::{Cred, RemoteCallbacks}; -use log::{debug, info}; -use crate::util::find_ssh_key; - -pub(crate) fn default_callbacks<'s>() -> RemoteCallbacks<'s> { - let mut callbacks = RemoteCallbacks::new(); - - let mut fail_next_call = false; - let mut tried_agent = false; - - callbacks.credentials(move |url, username_from_url, allowed_types| { - if fail_next_call { - info!("Failed to get credentials for push"); - return Err(git2::Error::from_str("Bad credentials.")); - } - - debug!("getting credentials {url} {username_from_url:?} {allowed_types:?}"); - match (allowed_types.is_ssh_key(), username_from_url) { - (true, Some(username)) => { - if !tried_agent { - tried_agent = true; - return Cred::ssh_key_from_agent(username); - } - - fail_next_call = true; // This is our last try - - // If the agent failed, try using the default SSH key - if let Some(key) = find_ssh_key() { - Cred::ssh_key(username, None, key.as_path(), None) - } else { - Err(git2::Error::from_str( - "Bad credentials. Ensure your key was added using ssh-add", - )) - } - } - (true, None) => Err(git2::Error::from_str("Couldn't get username from url")), - _ => { - return Err(git2::Error::from_str("https remotes are not (yet) supported")); - } - } - }); - - callbacks.push_transfer_progress(|current, total, bytes| { - debug!("progress: {}/{} ({} B)", current, total, bytes,); - }); - - callbacks.transfer_progress(|p| { - debug!("transfer: {}/{}", p.received_objects(), p.total_objects()); - true - }); - - callbacks.pack_progress(|stage, current, total| { - debug!("packing: {:?} - {}/{}", stage, current, total); - }); - - callbacks.push_update_reference(|reference, msg| { - debug!("push_update_reference: '{}' {:?}", reference, msg); - Ok(()) - }); - - callbacks.update_tips(|name, a, b| { - debug!("update tips: '{}' {} -> {}", name, a, b); - if a != b { - // let mut push_result = push_result.lock().unwrap(); - // *push_result = PushResult::Success - } - true - }); - - callbacks.sideband_progress(|data| { - debug!("sideband transfer: '{}'", String::from_utf8_lossy(data).trim()); - true - }); - - callbacks -} diff --git a/src-tauri/yaak-git/src/commands.rs b/src-tauri/yaak-git/src/commands.rs index f51168cf..f2e1cf5b 100644 --- a/src-tauri/yaak-git/src/commands.rs +++ b/src-tauri/yaak-git/src/commands.rs @@ -1,13 +1,19 @@ +use crate::add::git_add; use crate::branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch}; +use crate::commit::git_commit; +use crate::credential::git_add_credential; use crate::error::Result; use crate::fetch::git_fetch_all; -use crate::git::{ - git_add, git_commit, git_init, git_log, git_status, git_unstage, GitCommit, GitStatusSummary, -}; -use crate::pull::{git_pull, PullResult}; -use crate::push::{git_push, PushResult}; +use crate::init::git_init; +use crate::log::{GitCommit, git_log}; +use crate::pull::{PullResult, git_pull}; +use crate::push::{PushResult, git_push}; +use crate::remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote}; +use crate::status::{GitStatusSummary, git_status}; +use crate::unstage::git_unstage; use std::path::{Path, PathBuf}; use tauri::command; + // NOTE: All of these commands are async to prevent blocking work from locking up the UI #[command] @@ -80,3 +86,28 @@ pub async fn unstage(dir: &Path, rela_paths: Vec) -> Result<()> { } Ok(()) } + +#[command] +pub async fn add_credential( + dir: &Path, + remote_url: &str, + username: &str, + password: &str, +) -> Result<()> { + git_add_credential(dir, remote_url, username, password).await +} + +#[command] +pub async fn remotes(dir: &Path) -> Result> { + git_remotes(dir) +} + +#[command] +pub async fn add_remote(dir: &Path, name: &str, url: &str) -> Result { + git_add_remote(dir, name, url) +} + +#[command] +pub async fn rm_remote(dir: &Path, name: &str) -> Result<()> { + git_rm_remote(dir, name) +} diff --git a/src-tauri/yaak-git/src/commit.rs b/src-tauri/yaak-git/src/commit.rs new file mode 100644 index 00000000..c58aeb6b --- /dev/null +++ b/src-tauri/yaak-git/src/commit.rs @@ -0,0 +1,20 @@ +use crate::binary::new_binary_command; +use crate::error::Error::GenericError; +use log::info; +use std::path::Path; + +pub(crate) fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> { + let out = new_binary_command(dir)?.args(["commit", "--message", message]).output()?; + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = stdout + stderr; + + if !out.status.success() { + return Err(GenericError(format!("Failed to commit: {}", combined))); + } + + info!("Committed to {dir:?}"); + + Ok(()) +} diff --git a/src-tauri/yaak-git/src/credential.rs b/src-tauri/yaak-git/src/credential.rs new file mode 100644 index 00000000..b270383b --- /dev/null +++ b/src-tauri/yaak-git/src/credential.rs @@ -0,0 +1,47 @@ +use crate::binary::new_binary_command; +use crate::error::Error::GenericError; +use crate::error::Result; +use std::io::Write; +use std::path::Path; +use std::process::Stdio; +use tauri::Url; + +pub(crate) async fn git_add_credential( + dir: &Path, + remote_url: &str, + username: &str, + password: &str, +) -> Result<()> { + let url = Url::parse(remote_url) + .map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?; + let protocol = url.scheme(); + let host = url.host_str().unwrap(); + let path = Some(url.path()); + + let mut child = new_binary_command(dir)? + .args(["credential", "approve"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn()?; + + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, "protocol={}", protocol)?; + writeln!(stdin, "host={}", host)?; + if let Some(path) = path { + if !path.is_empty() { + writeln!(stdin, "path={}", path.trim_start_matches('/'))?; + } + } + writeln!(stdin, "username={}", username)?; + writeln!(stdin, "password={}", password)?; + writeln!(stdin)?; // blank line terminator + } + + let status = child.wait()?; + if !status.success() { + return Err(GenericError("Failed to approve git credential".to_string())); + } + + Ok(()) +} diff --git a/src-tauri/yaak-git/src/error.rs b/src-tauri/yaak-git/src/error.rs index b4207c22..2200966c 100644 --- a/src-tauri/yaak-git/src/error.rs +++ b/src-tauri/yaak-git/src/error.rs @@ -33,9 +33,18 @@ pub enum Error { #[error("Git error: {0}")] GenericError(String), + #[error("'git' not found. Please ensure it's installed and available in $PATH")] + GitNotFound, + + #[error("Credentials required: {0}")] + CredentialsRequiredError(String), + #[error("No default remote found")] NoDefaultRemoteFound, + #[error("No remotes found for repo")] + NoRemotesFound, + #[error("Merge failed due to conflicts")] MergeConflicts, diff --git a/src-tauri/yaak-git/src/fetch.rs b/src-tauri/yaak-git/src/fetch.rs index d3b2febc..371d20b8 100644 --- a/src-tauri/yaak-git/src/fetch.rs +++ b/src-tauri/yaak-git/src/fetch.rs @@ -1,37 +1,20 @@ -use crate::callbacks::default_callbacks; +use crate::binary::new_binary_command; +use crate::error::Error::GenericError; use crate::error::Result; -use crate::repository::open_repo; -use git2::{FetchOptions, ProxyOptions, Repository}; use std::path::Path; pub(crate) fn git_fetch_all(dir: &Path) -> Result<()> { - let repo = open_repo(dir)?; - let remotes = repo.remotes()?.iter().flatten().map(String::from).collect::>(); + let out = new_binary_command(dir)? + .args(["fetch", "--all", "--prune", "--tags"]) + .output() + .map_err(|e| GenericError(format!("failed to run git pull: {e}")))?; + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = stdout + stderr; - for (_idx, remote) in remotes.into_iter().enumerate() { - fetch_from_remote(&repo, &remote)?; + if !out.status.success() { + return Err(GenericError(format!("Failed to fetch: {}", combined))); } Ok(()) } - -fn fetch_from_remote(repo: &Repository, remote: &str) -> Result<()> { - let mut remote = repo.find_remote(remote)?; - - let mut options = FetchOptions::new(); - let callbacks = default_callbacks(); - - options.prune(git2::FetchPrune::On); - let mut proxy = ProxyOptions::new(); - proxy.auto(); - - options.proxy_options(proxy); - options.download_tags(git2::AutotagOption::All); - options.remote_callbacks(callbacks); - - remote.fetch(&[] as &[&str], Some(&mut options), None)?; - // fetch tags (also removing remotely deleted ones) - remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut options), None)?; - - Ok(()) -} diff --git a/src-tauri/yaak-git/src/git.rs b/src-tauri/yaak-git/src/git.rs deleted file mode 100644 index 9f678484..00000000 --- a/src-tauri/yaak-git/src/git.rs +++ /dev/null @@ -1,673 +0,0 @@ -use crate::error::Result; -use crate::repository::open_repo; -use crate::util::{local_branch_names, remote_branch_names}; -use chrono::{DateTime, Utc}; -use git2::IndexAddOption; -use log::{info, warn}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::Path; -use ts_rs::TS; -use yaak_sync::models::SyncModel; - -#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "gen_git.ts")] -pub struct GitStatusSummary { - pub path: String, - pub head_ref: Option, - pub head_ref_shorthand: Option, - pub entries: Vec, - pub origins: Vec, - pub local_branches: Vec, - pub remote_branches: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "gen_git.ts")] -pub struct GitStatusEntry { - pub rela_path: String, - pub status: GitStatus, - pub staged: bool, - pub prev: Option, - pub next: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export, export_to = "gen_git.ts")] -pub enum GitStatus { - Untracked, - Conflict, - Current, - Modified, - Removed, - Renamed, - TypeChange, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "gen_git.ts")] -pub struct GitCommit { - author: GitAuthor, - when: DateTime, - message: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "gen_git.ts")] -pub struct GitAuthor { - name: Option, - email: Option, -} - -pub fn git_init(dir: &Path) -> Result<()> { - git2::Repository::init(dir)?; - let repo = open_repo(dir)?; - // Default to main instead of master, to align with - // the official Git and GitHub behavior - repo.set_head("refs/heads/main")?; - info!("Initialized {dir:?}"); - Ok(()) -} - -pub fn git_add(dir: &Path, rela_path: &Path) -> Result<()> { - let repo = open_repo(dir)?; - let mut index = repo.index()?; - - info!("Staging file {rela_path:?} to {dir:?}"); - index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?; - index.write()?; - - Ok(()) -} - -pub fn git_unstage(dir: &Path, rela_path: &Path) -> Result<()> { - let repo = open_repo(dir)?; - - let head = match repo.head() { - Ok(h) => h, - Err(e) if e.code() == git2::ErrorCode::UnbornBranch => { - info!("Unstaging file in empty branch {rela_path:?} to {dir:?}"); - // Repo has no commits, so "unstage" means remove from index - let mut index = repo.index()?; - index.remove_path(rela_path)?; - index.write()?; - return Ok(()); - } - Err(e) => return Err(e.into()), - }; - - // If repo has commits, update the index entry back to HEAD - info!("Unstaging file {rela_path:?} to {dir:?}"); - let commit = head.peel_to_commit()?; - repo.reset_default(Some(commit.as_object()), &[rela_path])?; - - Ok(()) -} - -pub fn git_commit(dir: &Path, message: &str) -> Result<()> { - let repo = open_repo(dir)?; - - // Clear the in-memory index, add the paths, and write the tree for committing - let tree_oid = repo.index()?.write_tree()?; - let tree = repo.find_tree(tree_oid)?; - - // Make the signature - let config = repo.config()?.snapshot()?; - let name = config.get_str("user.name").unwrap_or("Unknown"); - let email = config.get_str("user.email")?; - let sig = git2::Signature::now(name, email)?; - - // Get the current HEAD commit (if it exists) - let parent_commit = match repo.head() { - Ok(head) => Some(head.peel_to_commit()?), - Err(_) => None, // No parent if no HEAD exists (initial commit) - }; - - let parents = parent_commit.as_ref().map(|p| vec![p]).unwrap_or_default(); - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, parents.as_slice())?; - - info!("Committed to {dir:?}"); - - Ok(()) -} - -pub fn git_log(dir: &Path) -> Result> { - let repo = open_repo(dir)?; - - // Return empty if empty repo or no head (new repo) - if repo.is_empty()? || repo.head().is_err() { - return Ok(vec![]); - } - - let mut revwalk = repo.revwalk()?; - revwalk.push_head()?; - revwalk.set_sorting(git2::Sort::TIME)?; - - // Run git log - macro_rules! filter_try { - ($e:expr) => { - match $e { - Ok(t) => t, - Err(_) => return None, - } - }; - } - let log: Vec = revwalk - .filter_map(|oid| { - let oid = filter_try!(oid); - let commit = filter_try!(repo.find_commit(oid)); - let author = commit.author(); - Some(GitCommit { - author: GitAuthor { - name: author.name().map(|s| s.to_string()), - email: author.email().map(|s| s.to_string()), - }, - when: convert_git_time_to_date(author.when()), - message: commit.message().map(|m| m.to_string()), - }) - }) - .collect(); - - Ok(log) -} - -pub fn git_status(dir: &Path) -> Result { - let repo = open_repo(dir)?; - let (head_tree, head_ref, head_ref_shorthand) = match repo.head() { - Ok(head) => { - let tree = head.peel_to_tree().ok(); - let head_ref_shorthand = head.shorthand().map(|s| s.to_string()); - let head_ref = head.name().map(|s| s.to_string()); - - (tree, head_ref, head_ref_shorthand) - } - Err(_) => { - // For "unborn" repos, reading from HEAD is the only way to get the branch name - // See https://github.com/starship/starship/pull/1336 - let head_path = repo.path().join("HEAD"); - let head_ref = fs::read_to_string(&head_path) - .ok() - .unwrap_or_default() - .lines() - .next() - .map(|s| s.trim_start_matches("ref:").trim().to_string()); - let head_ref_shorthand = - head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string()); - (None, head_ref, head_ref_shorthand) - } - }; - - let mut opts = git2::StatusOptions::new(); - opts.include_ignored(false) - .include_untracked(true) // Include untracked - .recurse_untracked_dirs(true) // Show all untracked - .include_unmodified(true); // Include unchanged - - // TODO: Support renames - - let mut entries: Vec = Vec::new(); - for entry in repo.statuses(Some(&mut opts))?.into_iter() { - let rela_path = entry.path().unwrap().to_string(); - let status = entry.status(); - let index_status = match status { - // Note: order matters here, since we're checking a bitmap! - s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict, - s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked, - s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified, - s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed, - s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed, - s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange, - s if s.contains(git2::Status::CURRENT) => GitStatus::Current, - s => { - warn!("Unknown index status {s:?}"); - continue; - } - }; - - let worktree_status = match status { - // Note: order matters here, since we're checking a bitmap! - s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict, - s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked, - s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified, - s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed, - s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed, - s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange, - s if s.contains(git2::Status::CURRENT) => GitStatus::Current, - s => { - warn!("Unknown worktree status {s:?}"); - continue; - } - }; - - let status = if index_status == GitStatus::Current { - worktree_status.clone() - } else { - index_status.clone() - }; - - let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current - { - // No change, so can't be added - false - } else if index_status != GitStatus::Current { - true - } else { - false - }; - - // Get previous content from Git, if it's in there - let prev = match head_tree.clone() { - None => None, - Some(t) => match t.get_path(&Path::new(&rela_path)) { - Ok(entry) => { - let obj = entry.to_object(&repo)?; - let content = obj.as_blob().unwrap().content(); - let name = Path::new(entry.name().unwrap_or_default()); - SyncModel::from_bytes(content.into(), name)?.map(|m| m.0) - } - Err(_) => None, - }, - }; - - let next = { - let full_path = repo.workdir().unwrap().join(rela_path.clone()); - SyncModel::from_file(full_path.as_path())?.map(|m| m.0) - }; - - entries.push(GitStatusEntry { - status, - staged, - rela_path, - prev: prev.clone(), - next: next.clone(), - }) - } - - let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect(); - let local_branches = local_branch_names(&repo)?; - let remote_branches = remote_branch_names(&repo)?; - - Ok(GitStatusSummary { - entries, - origins, - path: dir.to_string_lossy().to_string(), - head_ref, - head_ref_shorthand, - local_branches, - remote_branches, - }) -} - -#[cfg(test)] -fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime { - DateTime::from_timestamp(0, 0).unwrap() -} - -#[cfg(not(test))] -fn convert_git_time_to_date(git_time: git2::Time) -> DateTime { - let timestamp = git_time.seconds(); - DateTime::from_timestamp(timestamp, 0).unwrap() -} - -// // Write a test -// #[cfg(test)] -// mod test { -// use crate::error::Error::GitRepoNotFound; -// use crate::error::Result; -// use crate::git::{ -// git_add, git_commit, git_init, git_log, git_status, git_unstage, open_repo, GitStatus, -// GitStatusEntry, -// }; -// use std::fs::{create_dir_all, remove_file, File}; -// use std::io::Write; -// use std::path::{Path, PathBuf}; -// use tempdir::TempDir; -// -// fn new_dir() -> PathBuf { -// let p = TempDir::new("yaak-git").unwrap().into_path(); -// p -// } -// -// fn new_file(path: &Path, content: &str) { -// let parent = path.parent().unwrap(); -// create_dir_all(parent).unwrap(); -// File::create(path).unwrap().write_all(content.as_bytes()).unwrap(); -// } -// -// #[tokio::test] -// async fn test_status_no_repo() { -// let dir = &new_dir(); -// let result = git_status(dir).await; -// assert!(matches!(result, Err(GitRepoNotFound(_)))); -// } -// -// #[test] -// fn test_open_repo() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// open_repo(dir.as_path())?; -// Ok(()) -// } -// -// #[test] -// fn test_open_repo_from_subdir() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// let sub_dir = dir.join("a").join("b"); -// create_dir_all(sub_dir.as_path())?; // Create sub dir -// -// open_repo(sub_dir.as_path())?; -// Ok(()) -// } -// -// #[tokio::test] -// async fn test_status() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// assert_eq!(git_status(dir).await?.entries, Vec::new()); -// -// new_file(&dir.join("foo.txt"), "foo"); -// new_file(&dir.join("bar.txt"), "bar"); -// new_file(&dir.join("dir/baz.txt"), "baz"); -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "dir/baz.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("baz".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("foo".to_string()), -// }, -// ], -// ); -// Ok(()) -// } -// -// #[tokio::test] -// fn test_add() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// new_file(&dir.join("foo.txt"), "foo"); -// new_file(&dir.join("bar.txt"), "bar"); -// -// git_add(dir, Path::new("foo.txt"))?; -// -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Added, -// staged: true, -// prev: None, -// next: Some("foo".to_string()), -// }, -// ], -// ); -// -// new_file(&dir.join("foo.txt"), "foo foo"); -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Added, -// staged: true, -// prev: None, -// next: Some("foo foo".to_string()), -// }, -// ], -// ); -// Ok(()) -// } -// -// #[tokio::test] -// fn test_unstage() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// new_file(&dir.join("foo.txt"), "foo"); -// new_file(&dir.join("bar.txt"), "bar"); -// -// git_add(dir, Path::new("foo.txt"))?; -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Added, -// staged: true, -// prev: None, -// next: Some("foo".to_string()), -// }, -// ] -// ); -// -// git_unstage(dir, Path::new("foo.txt"))?; -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("foo".to_string()), -// } -// ] -// ); -// -// Ok(()) -// } -// -// #[tokio::test] -// fn test_commit() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// new_file(&dir.join("foo.txt"), "foo"); -// new_file(&dir.join("bar.txt"), "bar"); -// -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("foo".to_string()), -// }, -// ] -// ); -// -// git_add(dir, Path::new("foo.txt"))?; -// git_commit(dir, "This is my message")?; -// -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Current, -// staged: false, -// prev: Some("foo".to_string()), -// next: Some("foo".to_string()), -// }, -// ] -// ); -// -// new_file(&dir.join("foo.txt"), "foo foo"); -// git_add(dir, Path::new("foo.txt"))?; -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Modified, -// staged: true, -// prev: Some("foo".to_string()), -// next: Some("foo foo".to_string()), -// }, -// ] -// ); -// Ok(()) -// } -// -// #[tokio::test] -// async fn test_add_removed_file() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// let foo_path = &dir.join("foo.txt"); -// let bar_path = &dir.join("bar.txt"); -// -// new_file(foo_path, "foo"); -// new_file(bar_path, "bar"); -// -// git_add(dir, Path::new("foo.txt"))?; -// git_commit(dir, "Initial commit")?; -// -// remove_file(foo_path)?; -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Removed, -// staged: false, -// prev: Some("foo".to_string()), -// next: None, -// }, -// ], -// ); -// -// git_add(dir, Path::new("foo.txt"))?; -// assert_eq!( -// git_status(dir).await?.entries, -// vec![ -// GitStatusEntry { -// rela_path: "bar.txt".to_string(), -// status: GitStatus::Added, -// staged: false, -// prev: None, -// next: Some("bar".to_string()), -// }, -// GitStatusEntry { -// rela_path: "foo.txt".to_string(), -// status: GitStatus::Removed, -// staged: true, -// prev: Some("foo".to_string()), -// next: None, -// }, -// ], -// ); -// Ok(()) -// } -// -// #[tokio::test] -// fn test_log_empty() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// let log = git_log(dir)?; -// assert_eq!(log.len(), 0); -// Ok(()) -// } -// -// #[test] -// fn test_log() -> Result<()> { -// let dir = &new_dir(); -// git_init(dir)?; -// -// new_file(&dir.join("foo.txt"), "foo"); -// new_file(&dir.join("bar.txt"), "bar"); -// -// git_add(dir, Path::new("foo.txt"))?; -// git_commit(dir, "This is my message")?; -// -// let log = git_log(dir)?; -// assert_eq!(log.len(), 1); -// assert_eq!(log.get(0).unwrap().message, Some("This is my message".to_string())); -// Ok(()) -// } -// } diff --git a/src-tauri/yaak-git/src/init.rs b/src-tauri/yaak-git/src/init.rs new file mode 100644 index 00000000..28095b91 --- /dev/null +++ b/src-tauri/yaak-git/src/init.rs @@ -0,0 +1,14 @@ +use crate::error::Result; +use crate::repository::open_repo; +use log::info; +use std::path::Path; + +pub(crate) fn git_init(dir: &Path) -> Result<()> { + git2::Repository::init(dir)?; + let repo = open_repo(dir)?; + // Default to main instead of master, to align with + // the official Git and GitHub behavior + repo.set_head("refs/heads/main")?; + info!("Initialized {dir:?}"); + Ok(()) +} diff --git a/src-tauri/yaak-git/src/lib.rs b/src-tauri/yaak-git/src/lib.rs index a56e18ed..05359da2 100644 --- a/src-tauri/yaak-git/src/lib.rs +++ b/src-tauri/yaak-git/src/lib.rs @@ -1,26 +1,34 @@ -use crate::commands::{add, branch, checkout, commit, delete_branch, fetch_all, initialize, log, merge_branch, pull, push, status, unstage}; +use crate::commands::{add, add_credential, add_remote, branch, checkout, commit, delete_branch, fetch_all, initialize, log, merge_branch, pull, push, remotes, rm_remote, status, unstage}; use tauri::{ - generate_handler, + Runtime, generate_handler, plugin::{Builder, TauriPlugin}, - Runtime, }; +mod add; +mod binary; mod branch; -mod callbacks; mod commands; -pub mod error; +mod commit; +mod credential; mod fetch; -mod git; +mod init; +mod log; mod merge; mod pull; mod push; +mod remotes; mod repository; +mod status; +mod unstage; mod util; +pub mod error; pub fn init() -> TauriPlugin { Builder::new("yaak-git") .invoke_handler(generate_handler![ add, + add_credential, + add_remote, branch, checkout, commit, @@ -31,8 +39,10 @@ pub fn init() -> TauriPlugin { merge_branch, pull, push, + remotes, + rm_remote, status, - unstage + unstage, ]) .build() } diff --git a/src-tauri/yaak-git/src/log.rs b/src-tauri/yaak-git/src/log.rs new file mode 100644 index 00000000..704ede94 --- /dev/null +++ b/src-tauri/yaak-git/src/log.rs @@ -0,0 +1,73 @@ +use crate::repository::open_repo; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_git.ts")] +pub(crate) struct GitCommit { + pub author: GitAuthor, + pub when: DateTime, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_git.ts")] +pub(crate) struct GitAuthor { + pub name: Option, + pub email: Option, +} + +pub(crate) fn git_log(dir: &Path) -> crate::error::Result> { + let repo = open_repo(dir)?; + + // Return empty if empty repo or no head (new repo) + if repo.is_empty()? || repo.head().is_err() { + return Ok(vec![]); + } + + let mut revwalk = repo.revwalk()?; + revwalk.push_head()?; + revwalk.set_sorting(git2::Sort::TIME)?; + + // Run git log + macro_rules! filter_try { + ($e:expr) => { + match $e { + Ok(t) => t, + Err(_) => return None, + } + }; + } + let log: Vec = revwalk + .filter_map(|oid| { + let oid = filter_try!(oid); + let commit = filter_try!(repo.find_commit(oid)); + let author = commit.author(); + Some(GitCommit { + author: GitAuthor { + name: author.name().map(|s| s.to_string()), + email: author.email().map(|s| s.to_string()), + }, + when: convert_git_time_to_date(author.when()), + message: commit.message().map(|m| m.to_string()), + }) + }) + .collect(); + + Ok(log) +} + +#[cfg(test)] +fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime { + DateTime::from_timestamp(0, 0).unwrap() +} + +#[cfg(not(test))] +fn convert_git_time_to_date(git_time: git2::Time) -> DateTime { + let timestamp = git_time.seconds(); + DateTime::from_timestamp(timestamp, 0).unwrap() +} diff --git a/src-tauri/yaak-git/src/pull.rs b/src-tauri/yaak-git/src/pull.rs index b675b92e..36fc4943 100644 --- a/src-tauri/yaak-git/src/pull.rs +++ b/src-tauri/yaak-git/src/pull.rs @@ -1,54 +1,100 @@ -use crate::callbacks::default_callbacks; -use crate::error::Error::NoActiveBranch; +use crate::binary::new_binary_command; +use crate::error::Error::GenericError; use crate::error::Result; -use crate::merge::do_merge; use crate::repository::open_repo; -use crate::util::{bytes_to_string, get_current_branch}; -use git2::{FetchOptions, ProxyOptions}; -use log::debug; +use crate::util::{get_current_branch_name, get_default_remote_in_repo}; +use log::info; use serde::{Deserialize, Serialize}; use std::path::Path; use ts_rs::TS; -#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_git.ts")] -pub(crate) struct PullResult { - received_bytes: usize, - received_objects: usize, +pub(crate) enum PullResult { + Success { message: String }, + UpToDate, + NeedsCredentials { url: String, error: Option }, } pub(crate) fn git_pull(dir: &Path) -> Result { let repo = open_repo(dir)?; + let branch_name = get_current_branch_name(&repo)?; + let remote = get_default_remote_in_repo(&repo)?; + let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?; + let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?; - let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?; - let branch_ref = branch.get(); - let branch_ref = bytes_to_string(branch_ref.name_bytes())?; + let out = new_binary_command(dir)? + .args(["pull", &remote_name, &branch_name]) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .map_err(|e| GenericError(format!("failed to run git pull: {e}")))?; - let remote_name = repo.branch_upstream_remote(&branch_ref)?; - let remote_name = bytes_to_string(&remote_name)?; - debug!("Pulling from {remote_name}"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = stdout + stderr; - let mut remote = repo.find_remote(&remote_name)?; + info!("Pulled status={} {combined}", out.status); - let mut options = FetchOptions::new(); - let callbacks = default_callbacks(); - options.remote_callbacks(callbacks); + if combined.to_lowercase().contains("could not read") { + return Ok(PullResult::NeedsCredentials { + url: remote_url.to_string(), + error: None, + }); + } - let mut proxy = ProxyOptions::new(); - proxy.auto(); - options.proxy_options(proxy); + if combined.to_lowercase().contains("unable to access") { + return Ok(PullResult::NeedsCredentials { + url: remote_url.to_string(), + error: Some(combined.to_string()), + }); + } - remote.fetch(&[&branch_ref], Some(&mut options), None)?; + if !out.status.success() { + return Err(GenericError(format!("Failed to pull {combined}"))); + } - let stats = remote.stats(); + if combined.to_lowercase().contains("up to date") { + return Ok(PullResult::UpToDate); + } - let fetch_head = repo.find_reference("FETCH_HEAD")?; - let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; - do_merge(&repo, &branch, &fetch_commit)?; - - Ok(PullResult { - received_bytes: stats.received_bytes(), - received_objects: stats.received_objects(), + Ok(PullResult::Success { + message: format!("Pulled from {}/{}", remote_name, branch_name), }) } + +// pub(crate) fn git_pull_old(dir: &Path) -> Result { +// let repo = open_repo(dir)?; +// +// let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?; +// let branch_ref = branch.get(); +// let branch_ref = bytes_to_string(branch_ref.name_bytes())?; +// +// let remote_name = repo.branch_upstream_remote(&branch_ref)?; +// let remote_name = bytes_to_string(&remote_name)?; +// debug!("Pulling from {remote_name}"); +// +// let mut remote = repo.find_remote(&remote_name)?; +// +// let mut options = FetchOptions::new(); +// let callbacks = default_callbacks(); +// options.remote_callbacks(callbacks); +// +// let mut proxy = ProxyOptions::new(); +// proxy.auto(); +// options.proxy_options(proxy); +// +// remote.fetch(&[&branch_ref], Some(&mut options), None)?; +// +// let stats = remote.stats(); +// +// let fetch_head = repo.find_reference("FETCH_HEAD")?; +// let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; +// do_merge(&repo, &branch, &fetch_commit)?; +// +// Ok(PullResult::Success { +// message: "Hello".to_string(), +// // received_bytes: stats.received_bytes(), +// // received_objects: stats.received_objects(), +// }) +// } diff --git a/src-tauri/yaak-git/src/push.rs b/src-tauri/yaak-git/src/push.rs index 46d3693b..bfd2edc1 100644 --- a/src-tauri/yaak-git/src/push.rs +++ b/src-tauri/yaak-git/src/push.rs @@ -1,74 +1,64 @@ -use crate::branch::branch_set_upstream_after_push; -use crate::callbacks::default_callbacks; +use crate::binary::new_binary_command; +use crate::error::Error::GenericError; use crate::error::Result; use crate::repository::open_repo; -use git2::{ProxyOptions, PushOptions}; +use crate::util::{get_current_branch_name, get_default_remote_for_push_in_repo}; +use log::info; use serde::{Deserialize, Serialize}; use std::path::Path; -use std::sync::Mutex; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export, export_to = "gen_git.ts")] -pub(crate) enum PushType { - Branch, - Tag, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_git.ts")] pub(crate) enum PushResult { - Success, - NothingToPush, + Success { message: String }, + UpToDate, + NeedsCredentials { url: String, error: Option }, } pub(crate) fn git_push(dir: &Path) -> Result { let repo = open_repo(dir)?; - let head = repo.head()?; - let branch = head.shorthand().unwrap(); - let mut remote = repo.find_remote("origin")?; + let branch_name = get_current_branch_name(&repo)?; + let remote = get_default_remote_for_push_in_repo(&repo)?; + let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?; + let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?; - let mut options = PushOptions::new(); - options.packbuilder_parallelism(0); - - let push_result = Mutex::new(PushResult::NothingToPush); - - let mut callbacks = default_callbacks(); - callbacks.push_transfer_progress(|_current, _total, _bytes| { - let mut push_result = push_result.lock().unwrap(); - *push_result = PushResult::Success; - }); - - options.remote_callbacks(default_callbacks()); + let out = new_binary_command(dir)? + .args(["push", &remote_name, &branch_name]) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .map_err(|e| GenericError(format!("failed to run git push: {e}")))?; - let mut proxy = ProxyOptions::new(); - proxy.auto(); - options.proxy_options(proxy); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = stdout + stderr; - // Push the current branch - let force = false; - let delete = false; - let branch_modifier = match (force, delete) { - (true, true) => "+:", - (false, true) => ":", - (true, false) => "+", - (false, false) => "", - }; + info!("Pushed to repo status={} {combined}", out.status); - let ref_type = PushType::Branch; + if combined.to_lowercase().contains("could not read") { + return Ok(PushResult::NeedsCredentials { + url: remote_url.to_string(), + error: None, + }); + } - let ref_type = match ref_type { - PushType::Branch => "heads", - PushType::Tag => "tags", - }; + if combined.to_lowercase().contains("unable to access") { + return Ok(PushResult::NeedsCredentials { + url: remote_url.to_string(), + error: Some(combined.to_string()), + }); + } - let refspec = format!("{branch_modifier}refs/{ref_type}/{branch}"); - remote.push(&[refspec], Some(&mut options))?; + if combined.to_lowercase().contains("up-to-date") { + return Ok(PushResult::UpToDate); + } - branch_set_upstream_after_push(&repo, branch)?; + if !out.status.success() { + return Err(GenericError(format!("Failed to push {combined}"))); + } - let push_result = push_result.lock().unwrap(); - Ok(push_result.clone()) + Ok(PushResult::Success { + message: format!("Pushed to {}/{}", remote_name, branch_name), + }) } diff --git a/src-tauri/yaak-git/src/remotes.rs b/src-tauri/yaak-git/src/remotes.rs new file mode 100644 index 00000000..e08e6f49 --- /dev/null +++ b/src-tauri/yaak-git/src/remotes.rs @@ -0,0 +1,53 @@ +use crate::error::Result; +use crate::repository::open_repo; +use log::warn; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "gen_git.ts")] +pub(crate) struct GitRemote { + name: String, + url: Option, +} + +pub(crate) fn git_remotes(dir: &Path) -> Result> { + let repo = open_repo(dir)?; + let mut remotes = Vec::new(); + + for remote in repo.remotes()?.into_iter() { + let name = match remote { + None => continue, + Some(name) => name, + }; + let r = match repo.find_remote(name) { + Ok(r) => r, + Err(e) => { + warn!("Failed to get remote {name}: {e:?}"); + continue; + } + }; + remotes.push(GitRemote { + name: name.to_string(), + url: r.url().map(|u| u.to_string()), + }); + } + + Ok(remotes) +} + +pub(crate) fn git_add_remote(dir: &Path, name: &str, url: &str) -> Result { + let repo = open_repo(dir)?; + repo.remote(name, url)?; + Ok(GitRemote { + name: name.to_string(), + url: Some(url.to_string()), + }) +} + +pub(crate) fn git_rm_remote(dir: &Path, name: &str) -> Result<()> { + let repo = open_repo(dir)?; + repo.remote_delete(name)?; + Ok(()) +} diff --git a/src-tauri/yaak-git/src/status.rs b/src-tauri/yaak-git/src/status.rs new file mode 100644 index 00000000..41ab0574 --- /dev/null +++ b/src-tauri/yaak-git/src/status.rs @@ -0,0 +1,172 @@ +use crate::repository::open_repo; +use crate::util::{local_branch_names, remote_branch_names}; +use log::warn; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; +use ts_rs::TS; +use yaak_sync::models::SyncModel; + +#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_git.ts")] +pub struct GitStatusSummary { + pub path: String, + pub head_ref: Option, + pub head_ref_shorthand: Option, + pub entries: Vec, + pub origins: Vec, + pub local_branches: Vec, + pub remote_branches: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_git.ts")] +pub struct GitStatusEntry { + pub rela_path: String, + pub status: GitStatus, + pub staged: bool, + pub prev: Option, + pub next: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_git.ts")] +pub enum GitStatus { + Untracked, + Conflict, + Current, + Modified, + Removed, + Renamed, + TypeChange, +} + +pub(crate) fn git_status(dir: &Path) -> crate::error::Result { + let repo = open_repo(dir)?; + let (head_tree, head_ref, head_ref_shorthand) = match repo.head() { + Ok(head) => { + let tree = head.peel_to_tree().ok(); + let head_ref_shorthand = head.shorthand().map(|s| s.to_string()); + let head_ref = head.name().map(|s| s.to_string()); + + (tree, head_ref, head_ref_shorthand) + } + Err(_) => { + // For "unborn" repos, reading from HEAD is the only way to get the branch name + // See https://github.com/starship/starship/pull/1336 + let head_path = repo.path().join("HEAD"); + let head_ref = fs::read_to_string(&head_path) + .ok() + .unwrap_or_default() + .lines() + .next() + .map(|s| s.trim_start_matches("ref:").trim().to_string()); + let head_ref_shorthand = + head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string()); + (None, head_ref, head_ref_shorthand) + } + }; + + let mut opts = git2::StatusOptions::new(); + opts.include_ignored(false) + .include_untracked(true) // Include untracked + .recurse_untracked_dirs(true) // Show all untracked + .include_unmodified(true); // Include unchanged + + // TODO: Support renames + + let mut entries: Vec = Vec::new(); + for entry in repo.statuses(Some(&mut opts))?.into_iter() { + let rela_path = entry.path().unwrap().to_string(); + let status = entry.status(); + let index_status = match status { + // Note: order matters here, since we're checking a bitmap! + s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict, + s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked, + s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified, + s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed, + s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed, + s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange, + s if s.contains(git2::Status::CURRENT) => GitStatus::Current, + s => { + warn!("Unknown index status {s:?}"); + continue; + } + }; + + let worktree_status = match status { + // Note: order matters here, since we're checking a bitmap! + s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict, + s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked, + s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified, + s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed, + s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed, + s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange, + s if s.contains(git2::Status::CURRENT) => GitStatus::Current, + s => { + warn!("Unknown worktree status {s:?}"); + continue; + } + }; + + let status = if index_status == GitStatus::Current { + worktree_status.clone() + } else { + index_status.clone() + }; + + let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current + { + // No change, so can't be added + false + } else if index_status != GitStatus::Current { + true + } else { + false + }; + + // Get previous content from Git, if it's in there + let prev = match head_tree.clone() { + None => None, + Some(t) => match t.get_path(&Path::new(&rela_path)) { + Ok(entry) => { + let obj = entry.to_object(&repo)?; + let content = obj.as_blob().unwrap().content(); + let name = Path::new(entry.name().unwrap_or_default()); + SyncModel::from_bytes(content.into(), name)?.map(|m| m.0) + } + Err(_) => None, + }, + }; + + let next = { + let full_path = repo.workdir().unwrap().join(rela_path.clone()); + SyncModel::from_file(full_path.as_path())?.map(|m| m.0) + }; + + entries.push(GitStatusEntry { + status, + staged, + rela_path, + prev: prev.clone(), + next: next.clone(), + }) + } + + let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect(); + let local_branches = local_branch_names(&repo)?; + let remote_branches = remote_branch_names(&repo)?; + + Ok(GitStatusSummary { + entries, + origins, + path: dir.to_string_lossy().to_string(), + head_ref, + head_ref_shorthand, + local_branches, + remote_branches, + }) +} diff --git a/src-tauri/yaak-git/src/unstage.rs b/src-tauri/yaak-git/src/unstage.rs new file mode 100644 index 00000000..d0c7afdd --- /dev/null +++ b/src-tauri/yaak-git/src/unstage.rs @@ -0,0 +1,28 @@ +use std::path::Path; +use log::info; +use crate::repository::open_repo; + +pub(crate) fn git_unstage(dir: &Path, rela_path: &Path) -> crate::error::Result<()> { + let repo = open_repo(dir)?; + + let head = match repo.head() { + Ok(h) => h, + Err(e) if e.code() == git2::ErrorCode::UnbornBranch => { + info!("Unstaging file in empty branch {rela_path:?} to {dir:?}"); + // Repo has no commits, so "unstage" means remove from index + let mut index = repo.index()?; + index.remove_path(rela_path)?; + index.write()?; + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + + // If repo has commits, update the index entry back to HEAD + info!("Unstaging file {rela_path:?} to {dir:?}"); + let commit = head.peel_to_commit()?; + repo.reset_default(Some(commit.as_object()), &[rela_path])?; + + Ok(()) +} + diff --git a/src-tauri/yaak-git/src/util.rs b/src-tauri/yaak-git/src/util.rs index 63554227..0bee2fb5 100644 --- a/src-tauri/yaak-git/src/util.rs +++ b/src-tauri/yaak-git/src/util.rs @@ -1,29 +1,9 @@ use crate::error::Error::{GenericError, NoDefaultRemoteFound}; use crate::error::Result; -use git2::{Branch, BranchType, Repository}; -use std::env; -use std::path::{Path, PathBuf}; +use git2::{Branch, BranchType, Remote, Repository}; const DEFAULT_REMOTE_NAME: &str = "origin"; -pub(crate) fn find_ssh_key() -> Option { - let home_dir = env::var("HOME").ok()?; - let key_paths = [ - format!("{}/.ssh/id_ed25519", home_dir), - format!("{}/.ssh/id_rsa", home_dir), - format!("{}/.ssh/id_ecdsa", home_dir), - format!("{}/.ssh/id_dsa", home_dir), - ]; - - for key_path in key_paths.iter() { - let path = Path::new(key_path); - if path.exists() { - return Some(path.to_path_buf()); - } - } - None -} - pub(crate) fn get_current_branch(repo: &Repository) -> Result>> { for b in repo.branches(None)? { let branch = b?.0; @@ -34,10 +14,18 @@ pub(crate) fn get_current_branch(repo: &Repository) -> Result> Ok(None) } +pub(crate) fn get_current_branch_name(repo: &Repository) -> Result { + Ok(get_current_branch(&repo)? + .ok_or(GenericError("Failed to get current branch".to_string()))? + .name()? + .ok_or(GenericError("Failed to get current branch name".to_string()))? + .to_string()) +} + pub(crate) fn local_branch_names(repo: &Repository) -> Result> { let mut branches = Vec::new(); for branch in repo.branches(Some(BranchType::Local))? { - let branch = branch?.0; + let (branch, _) = branch?; let name = branch.name_bytes()?; let name = bytes_to_string(name)?; branches.push(name); @@ -48,9 +36,12 @@ pub(crate) fn local_branch_names(repo: &Repository) -> Result> { pub(crate) fn remote_branch_names(repo: &Repository) -> Result> { let mut branches = Vec::new(); for branch in repo.branches(Some(BranchType::Remote))? { - let branch = branch?.0; + let (branch, _) = branch?; let name = branch.name_bytes()?; let name = bytes_to_string(name)?; + if name.ends_with("/HEAD") { + continue; + } branches.push(name); } Ok(branches) @@ -64,7 +55,13 @@ pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result { Ok(String::from_utf8(bytes.to_vec())?) } -pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result { +pub(crate) fn get_default_remote_for_push_in_repo(repo: &'_ Repository) -> Result> { + let name = get_default_remote_name_for_push_in_repo(repo)?; + let remote = repo.find_remote(&name)?; + Ok(remote) +} + +pub(crate) fn get_default_remote_name_for_push_in_repo(repo: &Repository) -> Result { let config = repo.config()?; let branch = get_current_branch(repo)?; @@ -89,12 +86,22 @@ pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result Result { +pub(crate) fn get_default_remote_in_repo(repo: &'_ Repository) -> Result> { + let name = get_default_remote_name_in_repo(repo)?; + let remote = repo.find_remote(&name)?; + Ok(remote) +} + +pub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result { let remotes = repo.remotes()?; + if remotes.is_empty() { + return Err(NoDefaultRemoteFound); + } + // if `origin` exists return that let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME)); if found_origin { diff --git a/src-web/components/CreateWorkspaceDialog.tsx b/src-web/components/CreateWorkspaceDialog.tsx index 2ff66f53..54c1de34 100644 --- a/src-web/components/CreateWorkspaceDialog.tsx +++ b/src-web/components/CreateWorkspaceDialog.tsx @@ -1,4 +1,4 @@ -import { useGitInit } from '@yaakapp-internal/git'; +import { gitMutations } from '@yaakapp-internal/git'; import type { WorkspaceMeta } from '@yaakapp-internal/models'; import { createGlobalModel, updateModel } from '@yaakapp-internal/models'; import { useState } from 'react'; @@ -12,6 +12,7 @@ import { Label } from './core/Label'; import { PlainInput } from './core/PlainInput'; import { VStack } from './core/Stacks'; import { EncryptionHelp } from './EncryptionHelp'; +import { gitCallbacks } from './git/callbacks'; import { SyncToFilesystemSetting } from './SyncToFilesystemSetting'; interface Props { @@ -20,7 +21,6 @@ interface Props { export function CreateWorkspaceDialog({ hide }: Props) { const [name, setName] = useState(''); - const gitInit = useGitInit(); const [syncConfig, setSyncConfig] = useState<{ filePath: string | null; initGit?: boolean; @@ -48,9 +48,11 @@ export function CreateWorkspaceDialog({ hide }: Props) { }); if (syncConfig.initGit && syncConfig.filePath) { - gitInit.mutateAsync({ dir: syncConfig.filePath }).catch((err) => { - showErrorToast('git-init-error', String(err)); - }); + gitMutations(syncConfig.filePath, gitCallbacks(syncConfig.filePath)) + .init.mutateAsync() + .catch((err) => { + showErrorToast('git-init-error', String(err)); + }); } // Navigate to workspace diff --git a/src-web/components/Dialogs.tsx b/src-web/components/Dialogs.tsx index 8c2a99ec..e5a48387 100644 --- a/src-web/components/Dialogs.tsx +++ b/src-web/components/Dialogs.tsx @@ -32,10 +32,10 @@ function DialogInstance({ render: Component, onClose, id, ...props }: DialogInst }, [id, onClose]); return ( - - + + - - + + ); } diff --git a/src-web/components/DynamicForm.tsx b/src-web/components/DynamicForm.tsx index 0bcc7e5d..42b96b5b 100644 --- a/src-web/components/DynamicForm.tsx +++ b/src-web/components/DynamicForm.tsx @@ -23,8 +23,10 @@ import { Checkbox } from './core/Checkbox'; import { DetailsBanner } from './core/DetailsBanner'; import { Editor } from './core/Editor/LazyEditor'; import { IconButton } from './core/IconButton'; +import type { InputProps } from './core/Input'; import { Input } from './core/Input'; import { Label } from './core/Label'; +import { PlainInput } from './core/PlainInput'; import { Select } from './core/Select'; import { VStack } from './core/Stacks'; import { Markdown } from './Markdown'; @@ -269,28 +271,31 @@ function TextArg({ autocompleteVariables: boolean; stateKey: string; }) { - return ( - - ); + const props: InputProps = { + onChange, + name: arg.name, + multiLine: arg.multiLine, + className: arg.multiLine ? 'min-h-[4rem]' : undefined, + defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value, + required: !arg.optional, + disabled: arg.disabled, + help: arg.description, + type: arg.password ? 'password' : 'text', + label: arg.label ?? arg.name, + size: INPUT_SIZE, + hideLabel: arg.hideLabel ?? arg.label == null, + placeholder: arg.placeholder ?? undefined, + forceUpdateKey: stateKey, + autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined, + stateKey, + autocompleteFunctions, + autocompleteVariables, + }; + if (autocompleteVariables || autocompleteFunctions) { + return ; + } else { + return ; + } } function EditorArg({ diff --git a/src-web/components/EnvironmentEditDialog.tsx b/src-web/components/EnvironmentEditDialog.tsx index 2c1af24f..5b13c630 100644 --- a/src-web/components/EnvironmentEditDialog.tsx +++ b/src-web/components/EnvironmentEditDialog.tsx @@ -79,6 +79,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) { ) : ( & +export type PlainInputProps = Omit< + InputProps, + | 'wrapLines' + | 'onKeyDown' + | 'type' + | 'stateKey' + | 'autocompleteVariables' + | 'autocompleteFunctions' + | 'autocomplete' + | 'extraExtensions' + | 'forcedEnvironmentId' +> & Pick, 'onKeyDownCapture'> & { onFocusRaw?: HTMLAttributes['onFocus']; type?: 'text' | 'password' | 'number'; diff --git a/src-web/components/core/Prompt.tsx b/src-web/components/core/Prompt.tsx index 2e81c709..e687d951 100644 --- a/src-web/components/core/Prompt.tsx +++ b/src-web/components/core/Prompt.tsx @@ -1,28 +1,27 @@ -import type { PromptTextRequest } from '@yaakapp-internal/plugins'; -import type { FormEvent, ReactNode } from 'react'; -import { useCallback, useState } from 'react'; +import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins'; +import type { FormEvent } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { generateId } from '../../lib/generateId'; +import { DynamicForm } from '../DynamicForm'; import { Button } from './Button'; -import { PlainInput } from './PlainInput'; import { HStack } from './Stacks'; -export type PromptProps = Omit & { - description?: ReactNode; +export interface PromptProps { + inputs: FormInput[]; onCancel: () => void; - onResult: (value: string | null) => void; -}; + onResult: (value: Record | null) => void; + confirmText?: string; + cancelText?: string; +} export function Prompt({ onCancel, - label, - defaultValue, - placeholder, - password, + inputs, onResult, - required, - confirmText, - cancelText, + confirmText = 'Confirm', + cancelText = 'Cancel', }: PromptProps) { - const [value, setValue] = useState(defaultValue ?? ''); + const [value, setValue] = useState>({}); const handleSubmit = useCallback( (e: FormEvent) => { e.preventDefault(); @@ -31,20 +30,14 @@ export function Prompt({ [onResult, value], ); + const id = 'prompt.form.' + useRef(generateId()).current; + return (
- + @@ -193,7 +207,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { size="sm" disabled={!hasAddedAnything} onClick={handleCreateCommitAndPush} - isLoading={push.isPending || commitAndPush.isPending || commit.isPending} + isLoading={isPushing} > Commit and Push diff --git a/src-web/components/GitDropdown.tsx b/src-web/components/git/GitDropdown.tsx similarity index 84% rename from src-web/components/GitDropdown.tsx rename to src-web/components/git/GitDropdown.tsx index 83440007..561283dd 100644 --- a/src-web/components/GitDropdown.tsx +++ b/src-web/components/git/GitDropdown.tsx @@ -4,22 +4,25 @@ import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import type { HTMLAttributes } from 'react'; import { forwardRef } from 'react'; -import { openWorkspaceSettings } from '../commands/openWorkspaceSettings'; -import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace'; -import { useKeyValue } from '../hooks/useKeyValue'; -import { sync } from '../init/sync'; -import { showConfirm, showConfirmDelete } from '../lib/confirm'; -import { showDialog } from '../lib/dialog'; -import { showPrompt } from '../lib/prompt'; -import { showErrorToast, showToast } from '../lib/toast'; -import { Banner } from './core/Banner'; -import type { DropdownItem } from './core/Dropdown'; -import { Dropdown } from './core/Dropdown'; -import { Icon } from './core/Icon'; -import { InlineCode } from './core/InlineCode'; -import { BranchSelectionDialog } from './git/BranchSelectionDialog'; -import { HistoryDialog } from './git/HistoryDialog'; +import { openWorkspaceSettings } from '../../commands/openWorkspaceSettings'; +import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../../hooks/useActiveWorkspace'; +import { useKeyValue } from '../../hooks/useKeyValue'; +import { sync } from '../../init/sync'; +import { showConfirm, showConfirmDelete } from '../../lib/confirm'; +import { showDialog } from '../../lib/dialog'; +import { showPrompt } from '../../lib/prompt'; +import { showErrorToast, showToast } from '../../lib/toast'; +import { Banner } from '../core/Banner'; +import type { DropdownItem } from '../core/Dropdown'; +import { Dropdown } from '../core/Dropdown'; +import { Icon } from '../core/Icon'; +import { InlineCode } from '../core/InlineCode'; +import { BranchSelectionDialog } from './BranchSelectionDialog'; +import { gitCallbacks } from './callbacks'; +import { handlePullResult } from './git-util'; import { GitCommitDialog } from './GitCommitDialog'; +import { GitRemotesDialog } from './GitRemotesDialog'; +import { HistoryDialog } from './HistoryDialog'; export function GitDropdown() { const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom); @@ -37,7 +40,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { const [ { status, log }, { branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init }, - ] = useGit(syncDir); + ] = useGit(syncDir, gitCallbacks(syncDir)); const localBranches = status.data?.localBranches ?? []; const remoteBranches = status.data?.remoteBranches ?? []; @@ -52,9 +55,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { const noRepo = status.error?.includes('not found'); if (noRepo) { - return ( - init.mutate({ dir: syncDir })} /> - ); + return ; } const tryCheckout = (branch: string, force: boolean) => { @@ -110,6 +111,12 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { }); }, }, + { + label: 'Manage Remotes', + leftSlot: , + onSelect: () => GitRemotesDialog.show(syncDir), + }, + { type: 'separator' }, { label: 'New Branch', leftSlot: , @@ -119,17 +126,17 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { title: 'Create Branch', label: 'Branch Name', }); - if (name) { - await branch.mutateAsync( - { branch: name }, - { - onError: (err) => { - showErrorToast('git-branch-error', String(err)); - }, + if (!name) return; + + await branch.mutateAsync( + { branch: name }, + { + onError: (err) => { + showErrorToast('git-branch-error', String(err)); }, - ); - tryCheckout(name, false); - } + }, + ); + tryCheckout(name, false); }, }, { @@ -214,18 +221,11 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { { type: 'separator' }, { label: 'Push', - hidden: (status.data?.origins ?? []).length === 0, leftSlot: , waitForOnSelect: true, async onSelect() { - push.mutate(undefined, { - onSuccess(message) { - if (message === 'nothing_to_push') { - showToast({ id: 'push-success', message: 'Nothing to push', color: 'info' }); - } else { - showToast({ id: 'push-success', message: 'Push successful', color: 'success' }); - } - }, + await push.mutateAsync(undefined, { + onSuccess: handlePullResult, onError(err) { showErrorToast('git-pull-error', String(err)); }, @@ -238,26 +238,17 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { leftSlot: , waitForOnSelect: true, async onSelect() { - const result = await pull.mutateAsync(undefined, { + await pull.mutateAsync(undefined, { + onSuccess: handlePullResult, onError(err) { showErrorToast('git-pull-error', String(err)); }, }); - if (result.receivedObjects > 0) { - showToast({ - id: 'git-pull-success', - message: `Pulled ${result.receivedObjects} objects`, - color: 'success', - }); - await sync({ force: true }); - } else { - showToast({ id: 'git-pull-success', message: 'Already up to date', color: 'info' }); - } }, }, { label: 'Commit', - leftSlot: , + leftSlot: , onSelect() { showDialog({ id: 'commit', diff --git a/src-web/components/git/GitRemotesDialog.tsx b/src-web/components/git/GitRemotesDialog.tsx new file mode 100644 index 00000000..fd8e0da4 --- /dev/null +++ b/src-web/components/git/GitRemotesDialog.tsx @@ -0,0 +1,67 @@ +import { useGit } from '@yaakapp-internal/git'; +import { showDialog } from '../../lib/dialog'; +import { Button } from '../core/Button'; +import { IconButton } from '../core/IconButton'; +import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table'; +import { gitCallbacks } from './callbacks'; +import { addGitRemote } from './showAddRemoteDialog'; + +interface Props { + dir: string; + onDone: () => void; +} + +export function GitRemotesDialog({ dir }: Props) { + const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir)); + + return ( +
+ + + + Name + URL + + + + + + + {remotes.data?.map((r, i) => ( + + {r.name} + {r.url} + + rmRemote.mutate({ name: r.name })} + /> + + + ))} + +
+
+ ); +} + +GitRemotesDialog.show = function (dir: string) { + showDialog({ + id: 'git-remotes', + title: 'Manage Remotes', + size: 'md', + render: ({ hide }) => , + }); +}; diff --git a/src-web/components/git/callbacks.tsx b/src-web/components/git/callbacks.tsx new file mode 100644 index 00000000..47435f42 --- /dev/null +++ b/src-web/components/git/callbacks.tsx @@ -0,0 +1,48 @@ +import type { GitCallbacks } from '@yaakapp-internal/git'; +import { showPromptForm } from '../../lib/prompt-form'; +import { Banner } from '../core/Banner'; +import { InlineCode } from '../core/InlineCode'; +import { addGitRemote } from './showAddRemoteDialog'; + +export function gitCallbacks(dir: string): GitCallbacks { + return { + addRemote: async () => { + return addGitRemote(dir); + }, + promptCredentials: async ({ url: remoteUrl, error }) => { + const isGitHub = /github\.com/i.test(remoteUrl); + const userLabel = isGitHub ? 'GitHub Username' : 'Username'; + const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token'; + const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined; + const passDescription = isGitHub + ? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.' + : 'Enter your password or access token for this Git server.'; + const r = await showPromptForm({ + id: 'git-credentials', + title: 'Credentials Required', + description: error ? ( + {error} + ) : ( + <> + Enter credentials for {remoteUrl} + + ), + inputs: [ + { type: 'text', name: 'username', label: userLabel, description: userDescription }, + { + type: 'text', + name: 'password', + label: passLabel, + description: passDescription, + password: true, + }, + ], + }); + if (r == null) throw new Error('Cancelled credentials prompt'); + + const username = String(r.username || ''); + const password = String(r.password || ''); + return { username, password }; + }, + }; +} diff --git a/src-web/components/git/git-util.ts b/src-web/components/git/git-util.ts new file mode 100644 index 00000000..a06f821c --- /dev/null +++ b/src-web/components/git/git-util.ts @@ -0,0 +1,30 @@ +import type { PullResult, PushResult } from '@yaakapp-internal/git'; +import { showToast } from '../../lib/toast'; + +export function handlePushResult(r: PushResult) { + switch (r.type) { + case 'needs_credentials': + showToast({ id: 'push-error', message: 'Credentials not found', color: 'danger' }); + break; + case 'success': + showToast({ id: 'push-success', message: r.message, color: 'success' }); + break; + case 'up_to_date': + showToast({ id: 'push-nothing', message: 'Already up-to-date', color: 'info' }); + break; + } +} + +export function handlePullResult(r: PullResult) { + switch (r.type) { + case 'needs_credentials': + showToast({ id: 'pull-error', message: 'Credentials not found', color: 'danger' }); + break; + case 'success': + showToast({ id: 'pull-success', message: r.message, color: 'success' }); + break; + case 'up_to_date': + showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' }); + break; + } +} diff --git a/src-web/components/git/showAddRemoteDialog.tsx b/src-web/components/git/showAddRemoteDialog.tsx new file mode 100644 index 00000000..ea44f57c --- /dev/null +++ b/src-web/components/git/showAddRemoteDialog.tsx @@ -0,0 +1,20 @@ +import type { GitRemote } from '@yaakapp-internal/git'; +import { gitMutations } from '@yaakapp-internal/git'; +import { showPromptForm } from '../../lib/prompt-form'; +import { gitCallbacks } from './callbacks'; + +export async function addGitRemote(dir: string): Promise { + const r = await showPromptForm({ + id: 'add-remote', + title: 'Add Remote', + inputs: [ + { type: 'text', label: 'Name', name: 'name' }, + { type: 'text', label: 'URL', name: 'url' }, + ], + }); + if (r == null) throw new Error('Cancelled remote prompt'); + + const name = String(r.name ?? ''); + const url = String(r.url ?? ''); + return gitMutations(dir, gitCallbacks(dir)).addRemote.mutateAsync({ name, url }); +} diff --git a/src-web/components/graphql/GraphQLEditor.tsx b/src-web/components/graphql/GraphQLEditor.tsx index 84bd7131..3b317e2c 100644 --- a/src-web/components/graphql/GraphQLEditor.tsx +++ b/src-web/components/graphql/GraphQLEditor.tsx @@ -24,7 +24,13 @@ type Props = Pick & request: HttpRequest; }; -export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorProps }: Props) { +export function GraphQLEditor(props: Props) { + // There's some weirdness with stale onChange being called when switching requests, so we'll + // key on the request ID as a workaround for now. + return ; +} + +function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProps }: Props) { const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage< Record >('graphQLAutoIntrospectDisabled', {}); diff --git a/src-web/lib/prompt-form.tsx b/src-web/lib/prompt-form.tsx new file mode 100644 index 00000000..fe0e7b0c --- /dev/null +++ b/src-web/lib/prompt-form.tsx @@ -0,0 +1,39 @@ +import type { DialogProps } from '../components/core/Dialog'; +import type { PromptProps } from '../components/core/Prompt'; +import { Prompt } from '../components/core/Prompt'; +import { showDialog } from './dialog'; + +type FormArgs = Pick & + Omit & { + id: string; + }; + +export async function showPromptForm({ id, title, description, ...props }: FormArgs) { + return new Promise((resolve: PromptProps['onResult']) => { + showDialog({ + id, + title, + description, + hideX: true, + size: 'sm', + disableBackdropClose: true, // Prevent accidental dismisses + onClose: () => { + // Click backdrop, close, or escape + resolve(null); + }, + render: ({ hide }) => + Prompt({ + onCancel: () => { + // Click cancel button within dialog + resolve(null); + hide(); + }, + onResult: (v) => { + resolve(v); + hide(); + }, + ...props, + }), + }); + }); +} diff --git a/src-web/lib/prompt.ts b/src-web/lib/prompt.ts index 89d0fac4..4cc31aa4 100644 --- a/src-web/lib/prompt.ts +++ b/src-web/lib/prompt.ts @@ -1,7 +1,13 @@ +import type { FormInput, PromptTextRequest } from '@yaakapp-internal/plugins'; +import type { ReactNode } from 'react'; import type { DialogProps } from '../components/core/Dialog'; -import type { PromptProps } from '../components/core/Prompt'; -import { Prompt } from '../components/core/Prompt'; -import { showDialog } from './dialog'; +import { showPromptForm } from './prompt-form'; + +type PromptProps = Omit & { + description?: ReactNode; + onCancel: () => void; + onResult: (value: string | null) => void; +}; type PromptArgs = Pick & Omit & { id: string }; @@ -10,35 +16,26 @@ export async function showPrompt({ id, title, description, - required = true, + cancelText, + confirmText, ...props }: PromptArgs) { - return new Promise((resolve: PromptProps['onResult']) => { - showDialog({ - id, - title, - description, - hideX: true, - size: 'sm', - disableBackdropClose: true, // Prevent accidental dismisses - onClose: () => { - // Click backdrop, close, or escape - resolve(null); - }, - render: ({ hide }) => - Prompt({ - required, - onCancel: () => { - // Click cancel button within dialog - resolve(null); - hide(); - }, - onResult: (v) => { - resolve(v); - hide(); - }, - ...props, - }), - }); + const inputs: FormInput[] = [ + { + type: 'text', + name: 'value', + ...props, + }, + ]; + + const result = await showPromptForm({ + id, + title, + description, + inputs, + cancelText, + confirmText, }); + + return result?.value ? String(result.value) : null; }