diff --git a/crates-tauri/yaak-app/src/git_ext.rs b/crates-tauri/yaak-app/src/git_ext.rs index dbbb4999..c4ae297a 100644 --- a/crates-tauri/yaak-app/src/git_ext.rs +++ b/crates-tauri/yaak-app/src/git_ext.rs @@ -9,8 +9,8 @@ use yaak_git::{ BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone, git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all, - git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch, - git_rm_remote, git_status, git_unstage, + git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push, + git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage, }; // NOTE: All of these commands are async to prevent blocking work from locking up the UI @@ -89,6 +89,20 @@ pub async fn cmd_git_pull(dir: &Path) -> Result { Ok(git_pull(dir).await?) } +#[command] +pub async fn cmd_git_pull_force_reset( + dir: &Path, + remote: &str, + branch: &str, +) -> Result { + Ok(git_pull_force_reset(dir, remote, branch).await?) +} + +#[command] +pub async fn cmd_git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result { + Ok(git_pull_merge(dir, remote, branch).await?) +} + #[command] pub async fn cmd_git_add(dir: &Path, rela_paths: Vec) -> Result<()> { for path in rela_paths { @@ -105,6 +119,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec) -> Result<()> Ok(()) } +#[command] +pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> { + Ok(git_reset_changes(dir).await?) +} + #[command] pub async fn cmd_git_add_credential( remote_url: &str, diff --git a/crates-tauri/yaak-app/src/lib.rs b/crates-tauri/yaak-app/src/lib.rs index 24a3fde3..2b1710cf 100644 --- a/crates-tauri/yaak-app/src/lib.rs +++ b/crates-tauri/yaak-app/src/lib.rs @@ -37,8 +37,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message}; use yaak_mac_window::AppHandleMacWindowExt; use yaak_models::models::{ AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, - GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, - Plugin, Workspace, WorkspaceMeta, + GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin, + Workspace, WorkspaceMeta, }; use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources}; use yaak_plugins::events::{ @@ -1709,8 +1709,11 @@ pub fn run() { git_ext::cmd_git_fetch_all, git_ext::cmd_git_push, git_ext::cmd_git_pull, + git_ext::cmd_git_pull_force_reset, + git_ext::cmd_git_pull_merge, git_ext::cmd_git_add, git_ext::cmd_git_unstage, + git_ext::cmd_git_reset_changes, git_ext::cmd_git_add_credential, git_ext::cmd_git_remotes, git_ext::cmd_git_add_remote, diff --git a/crates/yaak-git/bindings/gen_git.ts b/crates/yaak-git/bindings/gen_git.ts index 5f6bff76..1ec69d86 100644 --- a/crates/yaak-git/bindings/gen_git.ts +++ b/crates/yaak-git/bindings/gen_git.ts @@ -15,8 +15,8 @@ export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "rem 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 GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array, origins: Array, localBranches: Array, remoteBranches: Array, ahead: number, behind: number, }; -export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, }; +export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" }; export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, }; diff --git a/crates/yaak-git/index.ts b/crates/yaak-git/index.ts index cd9744f1..b90fa99b 100644 --- a/crates/yaak-git/index.ts +++ b/crates/yaak-git/index.ts @@ -4,6 +4,7 @@ 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'; @@ -13,11 +14,20 @@ export interface GitCredentials { 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'] }); @@ -69,6 +79,15 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => { 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'], @@ -133,10 +152,9 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => { }, onSuccess, }), - fetchAll: createFastMutation({ - mutationKey: ['git', 'checkout', dir], + fetchAll: createFastMutation({ + mutationKey: ['git', 'fetch_all', dir], mutationFn: () => invoke('cmd_git_fetch_all', { dir }), - onSuccess, }), push: createFastMutation({ mutationKey: ['git', 'push', dir], @@ -147,20 +165,51 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => { mutationKey: ['git', 'pull', dir], async mutationFn() { const result = await invoke('cmd_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'); + 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, - }); + await invoke('cmd_git_add_credential', { + remoteUrl: result.url, + username: creds.username, + password: creds.password, + }); - // Pull again - return invoke('cmd_git_pull', { dir }); + // 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, }), @@ -169,6 +218,11 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => { 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; }; diff --git a/crates/yaak-git/src/lib.rs b/crates/yaak-git/src/lib.rs index f399c24b..2a0b6406 100644 --- a/crates/yaak-git/src/lib.rs +++ b/crates/yaak-git/src/lib.rs @@ -13,6 +13,7 @@ mod pull; mod push; mod remotes; mod repository; +mod reset; mod status; mod unstage; mod util; @@ -29,8 +30,9 @@ pub use credential::git_add_credential; pub use fetch::git_fetch_all; pub use init::git_init; pub use log::{GitCommit, git_log}; -pub use pull::{PullResult, git_pull}; +pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge}; pub use push::{PushResult, git_push}; pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote}; +pub use reset::git_reset_changes; pub use status::{GitStatusSummary, git_status}; pub use unstage::git_unstage; diff --git a/crates/yaak-git/src/pull.rs b/crates/yaak-git/src/pull.rs index 05f514ca..0bf75474 100644 --- a/crates/yaak-git/src/pull.rs +++ b/crates/yaak-git/src/pull.rs @@ -15,9 +15,23 @@ pub enum PullResult { Success { message: String }, UpToDate, NeedsCredentials { url: String, error: Option }, + Diverged { remote: String, branch: String }, + UncommittedChanges, +} + +fn has_uncommitted_changes(dir: &Path) -> Result { + let repo = open_repo(dir)?; + let mut opts = git2::StatusOptions::new(); + opts.include_ignored(false).include_untracked(false); + let statuses = repo.statuses(Some(&mut opts))?; + Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT)) } pub async fn git_pull(dir: &Path) -> Result { + if has_uncommitted_changes(dir)? { + return Ok(PullResult::UncommittedChanges); + } + // Extract all git2 data before any await points (git2 types are not Send) let (branch_name, remote_name, remote_url) = { let repo = open_repo(dir)?; @@ -56,6 +70,13 @@ pub async fn git_pull(dir: &Path) -> Result { } if !out.status.success() { + let combined_lower = combined.to_lowercase(); + if combined_lower.contains("cannot fast-forward") + || combined_lower.contains("not possible to fast-forward") + || combined_lower.contains("diverged") + { + return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name }); + } return Err(GenericError(format!("Failed to pull {combined}"))); } @@ -66,6 +87,65 @@ pub async fn git_pull(dir: &Path) -> Result { Ok(PullResult::Success { message: format!("Pulled from {}/{}", remote_name, branch_name) }) } +pub async fn git_pull_force_reset(dir: &Path, remote: &str, branch: &str) -> Result { + // Step 1: fetch the remote + let fetch_out = new_binary_command(dir) + .await? + .args(["fetch", remote]) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .await + .map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?; + + if !fetch_out.status.success() { + let stderr = String::from_utf8_lossy(&fetch_out.stderr); + return Err(GenericError(format!("Failed to fetch: {stderr}"))); + } + + // Step 2: reset --hard to remote/branch + let ref_name = format!("{}/{}", remote, branch); + let reset_out = new_binary_command(dir) + .await? + .args(["reset", "--hard", &ref_name]) + .output() + .await + .map_err(|e| GenericError(format!("failed to run git reset: {e}")))?; + + if !reset_out.status.success() { + let stderr = String::from_utf8_lossy(&reset_out.stderr); + return Err(GenericError(format!("Failed to reset: {}", stderr.trim()))); + } + + Ok(PullResult::Success { message: format!("Reset to {}/{}", remote, branch) }) +} + +pub async fn git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result { + let out = new_binary_command(dir) + .await? + .args(["pull", "--no-rebase", remote, branch]) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .await + .map_err(|e| GenericError(format!("failed to run git pull --no-rebase: {e}")))?; + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = format!("{}{}", stdout, stderr); + + info!("Pull merge status={} {combined}", out.status); + + if !out.status.success() { + if combined.to_lowercase().contains("conflict") { + return Err(GenericError( + "Merge conflicts detected. Please resolve them manually.".to_string(), + )); + } + return Err(GenericError(format!("Failed to merge pull: {}", combined.trim()))); + } + + Ok(PullResult::Success { message: format!("Merged from {}/{}", remote, branch) }) +} + // pub(crate) fn git_pull_old(dir: &Path) -> Result { // let repo = open_repo(dir)?; // diff --git a/crates/yaak-git/src/reset.rs b/crates/yaak-git/src/reset.rs new file mode 100644 index 00000000..5e61ce36 --- /dev/null +++ b/crates/yaak-git/src/reset.rs @@ -0,0 +1,20 @@ +use crate::binary::new_binary_command; +use crate::error::Error::GenericError; +use crate::error::Result; +use std::path::Path; + +pub async fn git_reset_changes(dir: &Path) -> Result<()> { + let out = new_binary_command(dir) + .await? + .args(["reset", "--hard", "HEAD"]) + .output() + .await + .map_err(|e| GenericError(format!("failed to run git reset: {e}")))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(GenericError(format!("Failed to reset: {}", stderr.trim()))); + } + + Ok(()) +} diff --git a/crates/yaak-git/src/status.rs b/crates/yaak-git/src/status.rs index 7625360f..b2dad85b 100644 --- a/crates/yaak-git/src/status.rs +++ b/crates/yaak-git/src/status.rs @@ -18,6 +18,8 @@ pub struct GitStatusSummary { pub origins: Vec, pub local_branches: Vec, pub remote_branches: Vec, + pub ahead: u32, + pub behind: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] @@ -160,6 +162,18 @@ pub fn git_status(dir: &Path) -> crate::error::Result { let local_branches = local_branch_names(&repo)?; let remote_branches = remote_branch_names(&repo)?; + // Compute ahead/behind relative to remote tracking branch + let (ahead, behind) = (|| -> Option<(usize, usize)> { + let head = repo.head().ok()?; + let local_oid = head.target()?; + let branch_name = head.shorthand()?; + let upstream_ref = + repo.find_branch(&format!("origin/{branch_name}"), git2::BranchType::Remote).ok()?; + let upstream_oid = upstream_ref.get().target()?; + repo.graph_ahead_behind(local_oid, upstream_oid).ok() + })() + .unwrap_or((0, 0)); + Ok(GitStatusSummary { entries, origins, @@ -168,5 +182,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result { head_ref_shorthand, local_branches, remote_branches, + ahead: ahead as u32, + behind: behind as u32, }) } diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index 42815d6d..a3ebe6ee 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -788,12 +788,12 @@ export class PluginInstance { const { folders } = await this.#sendForReply(context, payload); return folders.find((f) => f.id === args.id) ?? null; }, - create: async (args) => { + create: async ({ name, ...args }) => { const payload = { type: 'upsert_model_request', model: { - name: '', ...args, + name: name ?? '', id: '', model: 'folder', }, diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index df68385c..61c1ca32 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -111,6 +111,7 @@ import { RefreshCcwIcon, RefreshCwIcon, RocketIcon, + RotateCcwIcon, Rows2Icon, SaveIcon, SearchIcon, @@ -249,6 +250,7 @@ const icons = { puzzle: PuzzleIcon, refresh: RefreshCwIcon, rocket: RocketIcon, + rotate_ccw: RotateCcwIcon, rows_2: Rows2Icon, save: SaveIcon, search: SearchIcon, diff --git a/src-web/components/core/RadioCards.tsx b/src-web/components/core/RadioCards.tsx new file mode 100644 index 00000000..dbf48650 --- /dev/null +++ b/src-web/components/core/RadioCards.tsx @@ -0,0 +1,66 @@ +import classNames from 'classnames'; +import type { ReactNode } from 'react'; + +export interface RadioCardOption { + value: T; + label: ReactNode; + description?: ReactNode; +} + +export interface RadioCardsProps { + value: T | null; + onChange: (value: T) => void; + options: RadioCardOption[]; + name: string; +} + +export function RadioCards({ + value, + onChange, + options, + name, +}: RadioCardsProps) { + return ( +
+ {options.map((option) => { + const selected = value === option.value; + return ( +