mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 10:18:31 +02:00
Git Improvements (#382)
This commit is contained in:
@@ -9,8 +9,8 @@ use yaak_git::{
|
|||||||
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||||
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
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_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_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
|
||||||
git_rm_remote, git_status, git_unstage,
|
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
|
// 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<PullResult> {
|
|||||||
Ok(git_pull(dir).await?)
|
Ok(git_pull(dir).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_pull_force_reset(
|
||||||
|
dir: &Path,
|
||||||
|
remote: &str,
|
||||||
|
branch: &str,
|
||||||
|
) -> Result<PullResult> {
|
||||||
|
Ok(git_pull_force_reset(dir, remote, branch).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||||
|
Ok(git_pull_merge(dir, remote, branch).await?)
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
pub async fn cmd_git_add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||||
for path in rela_paths {
|
for path in rela_paths {
|
||||||
@@ -105,6 +119,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
|
||||||
|
Ok(git_reset_changes(dir).await?)
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_add_credential(
|
pub async fn cmd_git_add_credential(
|
||||||
remote_url: &str,
|
remote_url: &str,
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
|||||||
use yaak_mac_window::AppHandleMacWindowExt;
|
use yaak_mac_window::AppHandleMacWindowExt;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState,
|
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin,
|
||||||
Plugin, Workspace, WorkspaceMeta,
|
Workspace, WorkspaceMeta,
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
@@ -1709,8 +1709,11 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_fetch_all,
|
git_ext::cmd_git_fetch_all,
|
||||||
git_ext::cmd_git_push,
|
git_ext::cmd_git_push,
|
||||||
git_ext::cmd_git_pull,
|
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_add,
|
||||||
git_ext::cmd_git_unstage,
|
git_ext::cmd_git_unstage,
|
||||||
|
git_ext::cmd_git_reset_changes,
|
||||||
git_ext::cmd_git_add_credential,
|
git_ext::cmd_git_add_credential,
|
||||||
git_ext::cmd_git_remotes,
|
git_ext::cmd_git_remotes,
|
||||||
git_ext::cmd_git_add_remote,
|
git_ext::cmd_git_add_remote,
|
||||||
|
|||||||
4
crates/yaak-git/bindings/gen_git.ts
generated
4
crates/yaak-git/bindings/gen_git.ts
generated
@@ -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 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<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, };
|
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, 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, };
|
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
|
|||||||
import { queryClient } from '@yaakapp/app/lib/queryClient';
|
import { queryClient } from '@yaakapp/app/lib/queryClient';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
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_git';
|
||||||
export * from './bindings/gen_models';
|
export * from './bindings/gen_models';
|
||||||
@@ -13,11 +14,20 @@ export interface GitCredentials {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DivergedStrategy = 'force_reset' | 'merge' | 'cancel';
|
||||||
|
|
||||||
|
export type UncommittedChangesStrategy = 'reset' | 'cancel';
|
||||||
|
|
||||||
export interface GitCallbacks {
|
export interface GitCallbacks {
|
||||||
addRemote: () => Promise<GitRemote | null>;
|
addRemote: () => Promise<GitRemote | null>;
|
||||||
promptCredentials: (
|
promptCredentials: (
|
||||||
result: Extract<PushResult, { type: 'needs_credentials' }>,
|
result: Extract<PushResult, { type: 'needs_credentials' }>,
|
||||||
) => Promise<GitCredentials | null>;
|
) => Promise<GitCredentials | null>;
|
||||||
|
promptDiverged: (
|
||||||
|
result: Extract<PullResult, { type: 'diverged' }>,
|
||||||
|
) => Promise<DivergedStrategy>;
|
||||||
|
promptUncommittedChanges: () => Promise<UncommittedChangesStrategy>;
|
||||||
|
forceSync: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
||||||
@@ -69,6 +79,15 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
return invoke<PushResult>('cmd_git_push', { dir });
|
return invoke<PushResult>('cmd_git_push', { dir });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleError = (err: unknown) => {
|
||||||
|
showToast({
|
||||||
|
id: `${err}`,
|
||||||
|
message: `${err}`,
|
||||||
|
color: 'danger',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init: createFastMutation<void, string, void>({
|
init: createFastMutation<void, string, void>({
|
||||||
mutationKey: ['git', 'init'],
|
mutationKey: ['git', 'init'],
|
||||||
@@ -133,10 +152,9 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
},
|
},
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
fetchAll: createFastMutation<string, string, void>({
|
fetchAll: createFastMutation<void, string, void>({
|
||||||
mutationKey: ['git', 'checkout', dir],
|
mutationKey: ['git', 'fetch_all', dir],
|
||||||
mutationFn: () => invoke('cmd_git_fetch_all', { dir }),
|
mutationFn: () => invoke('cmd_git_fetch_all', { dir }),
|
||||||
onSuccess,
|
|
||||||
}),
|
}),
|
||||||
push: createFastMutation<PushResult, string, void>({
|
push: createFastMutation<PushResult, string, void>({
|
||||||
mutationKey: ['git', 'push', dir],
|
mutationKey: ['git', 'push', dir],
|
||||||
@@ -147,20 +165,51 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
mutationKey: ['git', 'pull', dir],
|
mutationKey: ['git', 'pull', dir],
|
||||||
async mutationFn() {
|
async mutationFn() {
|
||||||
const result = await invoke<PullResult>('cmd_git_pull', { dir });
|
const result = await invoke<PullResult>('cmd_git_pull', { dir });
|
||||||
if (result.type !== 'needs_credentials') return result;
|
|
||||||
|
|
||||||
// Needs credentials, prompt for them
|
if (result.type === 'needs_credentials') {
|
||||||
const creds = await callbacks.promptCredentials(result);
|
const creds = await callbacks.promptCredentials(result);
|
||||||
if (creds == null) throw new Error('Canceled');
|
if (creds == null) throw new Error('Canceled');
|
||||||
|
|
||||||
await invoke('cmd_git_add_credential', {
|
await invoke('cmd_git_add_credential', {
|
||||||
remoteUrl: result.url,
|
remoteUrl: result.url,
|
||||||
username: creds.username,
|
username: creds.username,
|
||||||
password: creds.password,
|
password: creds.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pull again
|
// Pull again after credentials
|
||||||
return invoke<PullResult>('cmd_git_pull', { dir });
|
return invoke<PullResult>('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<PullResult>('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<PullResult>('cmd_git_pull_force_reset', {
|
||||||
|
dir,
|
||||||
|
remote: result.remote,
|
||||||
|
branch: result.branch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return invoke<PullResult>('cmd_git_pull_merge', {
|
||||||
|
dir,
|
||||||
|
remote: result.remote,
|
||||||
|
branch: result.branch,
|
||||||
|
});
|
||||||
|
}).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
@@ -169,6 +218,11 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
mutationFn: (args) => invoke('cmd_git_unstage', { dir, ...args }),
|
mutationFn: (args) => invoke('cmd_git_unstage', { dir, ...args }),
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
|
resetChanges: createFastMutation<void, string, void>({
|
||||||
|
mutationKey: ['git', 'reset-changes', dir],
|
||||||
|
mutationFn: () => invoke('cmd_git_reset_changes', { dir }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod pull;
|
|||||||
mod push;
|
mod push;
|
||||||
mod remotes;
|
mod remotes;
|
||||||
mod repository;
|
mod repository;
|
||||||
|
mod reset;
|
||||||
mod status;
|
mod status;
|
||||||
mod unstage;
|
mod unstage;
|
||||||
mod util;
|
mod util;
|
||||||
@@ -29,8 +30,9 @@ pub use credential::git_add_credential;
|
|||||||
pub use fetch::git_fetch_all;
|
pub use fetch::git_fetch_all;
|
||||||
pub use init::git_init;
|
pub use init::git_init;
|
||||||
pub use log::{GitCommit, git_log};
|
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 push::{PushResult, git_push};
|
||||||
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
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 status::{GitStatusSummary, git_status};
|
||||||
pub use unstage::git_unstage;
|
pub use unstage::git_unstage;
|
||||||
|
|||||||
@@ -15,9 +15,23 @@ pub enum PullResult {
|
|||||||
Success { message: String },
|
Success { message: String },
|
||||||
UpToDate,
|
UpToDate,
|
||||||
NeedsCredentials { url: String, error: Option<String> },
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
|
Diverged { remote: String, branch: String },
|
||||||
|
UncommittedChanges,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
|
||||||
|
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<PullResult> {
|
pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||||
|
if has_uncommitted_changes(dir)? {
|
||||||
|
return Ok(PullResult::UncommittedChanges);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract all git2 data before any await points (git2 types are not Send)
|
// Extract all git2 data before any await points (git2 types are not Send)
|
||||||
let (branch_name, remote_name, remote_url) = {
|
let (branch_name, remote_name, remote_url) = {
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
@@ -56,6 +70,13 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !out.status.success() {
|
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}")));
|
return Err(GenericError(format!("Failed to pull {combined}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +87,65 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
|||||||
Ok(PullResult::Success { message: format!("Pulled from {}/{}", remote_name, branch_name) })
|
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<PullResult> {
|
||||||
|
// 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<PullResult> {
|
||||||
|
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<PullResult> {
|
// pub(crate) fn git_pull_old(dir: &Path) -> Result<PullResult> {
|
||||||
// let repo = open_repo(dir)?;
|
// let repo = open_repo(dir)?;
|
||||||
//
|
//
|
||||||
|
|||||||
20
crates/yaak-git/src/reset.rs
Normal file
20
crates/yaak-git/src/reset.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ pub struct GitStatusSummary {
|
|||||||
pub origins: Vec<String>,
|
pub origins: Vec<String>,
|
||||||
pub local_branches: Vec<String>,
|
pub local_branches: Vec<String>,
|
||||||
pub remote_branches: Vec<String>,
|
pub remote_branches: Vec<String>,
|
||||||
|
pub ahead: u32,
|
||||||
|
pub behind: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
@@ -160,6 +162,18 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
|||||||
let local_branches = local_branch_names(&repo)?;
|
let local_branches = local_branch_names(&repo)?;
|
||||||
let remote_branches = remote_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 {
|
Ok(GitStatusSummary {
|
||||||
entries,
|
entries,
|
||||||
origins,
|
origins,
|
||||||
@@ -168,5 +182,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
|||||||
head_ref_shorthand,
|
head_ref_shorthand,
|
||||||
local_branches,
|
local_branches,
|
||||||
remote_branches,
|
remote_branches,
|
||||||
|
ahead: ahead as u32,
|
||||||
|
behind: behind as u32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -788,12 +788,12 @@ export class PluginInstance {
|
|||||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||||
return folders.find((f) => f.id === args.id) ?? null;
|
return folders.find((f) => f.id === args.id) ?? null;
|
||||||
},
|
},
|
||||||
create: async (args) => {
|
create: async ({ name, ...args }) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
type: 'upsert_model_request',
|
type: 'upsert_model_request',
|
||||||
model: {
|
model: {
|
||||||
name: '',
|
|
||||||
...args,
|
...args,
|
||||||
|
name: name ?? '',
|
||||||
id: '',
|
id: '',
|
||||||
model: 'folder',
|
model: 'folder',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ import {
|
|||||||
RefreshCcwIcon,
|
RefreshCcwIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
|
RotateCcwIcon,
|
||||||
Rows2Icon,
|
Rows2Icon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -249,6 +250,7 @@ const icons = {
|
|||||||
puzzle: PuzzleIcon,
|
puzzle: PuzzleIcon,
|
||||||
refresh: RefreshCwIcon,
|
refresh: RefreshCwIcon,
|
||||||
rocket: RocketIcon,
|
rocket: RocketIcon,
|
||||||
|
rotate_ccw: RotateCcwIcon,
|
||||||
rows_2: Rows2Icon,
|
rows_2: Rows2Icon,
|
||||||
save: SaveIcon,
|
save: SaveIcon,
|
||||||
search: SearchIcon,
|
search: SearchIcon,
|
||||||
|
|||||||
66
src-web/components/core/RadioCards.tsx
Normal file
66
src-web/components/core/RadioCards.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface RadioCardOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioCardsProps<T extends string> {
|
||||||
|
value: T | null;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: RadioCardOption<T>[];
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioCards<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
name,
|
||||||
|
}: RadioCardsProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = value === option.value;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={classNames(
|
||||||
|
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer',
|
||||||
|
'transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-border-focus'
|
||||||
|
: 'border-border-subtle hocus:border-text-subtlest',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
value={option.value}
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => onChange(option.value)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'mt-1 w-4 h-4 flex-shrink-0 rounded-full border',
|
||||||
|
'flex items-center justify-center',
|
||||||
|
selected ? 'border-focus' : 'border-border',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && <div className="w-2 h-2 rounded-full bg-text" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-semibold text-text">{option.label}</span>
|
||||||
|
{option.description && (
|
||||||
|
<span className="text-sm text-text-subtle">{option.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import { InlineCode } from '../core/InlineCode';
|
|||||||
import { gitCallbacks } from './callbacks';
|
import { gitCallbacks } from './callbacks';
|
||||||
import { GitCommitDialog } from './GitCommitDialog';
|
import { GitCommitDialog } from './GitCommitDialog';
|
||||||
import { GitRemotesDialog } from './GitRemotesDialog';
|
import { GitRemotesDialog } from './GitRemotesDialog';
|
||||||
import { handlePullResult } from './git-util';
|
import { handlePullResult, handlePushResult } from './git-util';
|
||||||
import { HistoryDialog } from './HistoryDialog';
|
import { HistoryDialog } from './HistoryDialog';
|
||||||
|
|
||||||
export function GitDropdown() {
|
export function GitDropdown() {
|
||||||
@@ -48,6 +48,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
push,
|
push,
|
||||||
pull,
|
pull,
|
||||||
checkout,
|
checkout,
|
||||||
|
resetChanges,
|
||||||
init,
|
init,
|
||||||
},
|
},
|
||||||
] = useGit(syncDir, gitCallbacks(syncDir));
|
] = useGit(syncDir, gitCallbacks(syncDir));
|
||||||
@@ -72,6 +73,9 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentBranch = status.data.headRefShorthand;
|
const currentBranch = status.data.headRefShorthand;
|
||||||
|
const hasChanges = status.data.entries.some((e) => e.status !== 'current');
|
||||||
|
const hasRemotes = (status.data.origins ?? []).length > 0;
|
||||||
|
const { ahead, behind } = status.data;
|
||||||
|
|
||||||
const tryCheckout = (branch: string, force: boolean) => {
|
const tryCheckout = (branch: string, force: boolean) => {
|
||||||
checkout.mutate(
|
checkout.mutate(
|
||||||
@@ -168,12 +172,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Push',
|
label: 'Push',
|
||||||
|
disabled: !hasRemotes || ahead === 0,
|
||||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
leftSlot: <Icon icon="arrow_up_from_line" />,
|
||||||
waitForOnSelect: true,
|
waitForOnSelect: true,
|
||||||
async onSelect() {
|
async onSelect() {
|
||||||
await push.mutateAsync(undefined, {
|
await push.mutateAsync(undefined, {
|
||||||
disableToastError: true,
|
disableToastError: true,
|
||||||
onSuccess: handlePullResult,
|
onSuccess: handlePushResult,
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showErrorToast({
|
showErrorToast({
|
||||||
id: 'git-push-error',
|
id: 'git-push-error',
|
||||||
@@ -186,7 +191,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pull',
|
label: 'Pull',
|
||||||
hidden: (status.data?.origins ?? []).length === 0,
|
disabled: !hasRemotes || behind === 0,
|
||||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
leftSlot: <Icon icon="arrow_down_to_line" />,
|
||||||
waitForOnSelect: true,
|
waitForOnSelect: true,
|
||||||
async onSelect() {
|
async onSelect() {
|
||||||
@@ -205,6 +210,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Commit...',
|
label: 'Commit...',
|
||||||
|
disabled: !hasChanges,
|
||||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
leftSlot: <Icon icon="git_commit_vertical" />,
|
||||||
onSelect() {
|
onSelect() {
|
||||||
showDialog({
|
showDialog({
|
||||||
@@ -218,6 +224,41 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Reset Changes',
|
||||||
|
hidden: !hasChanges,
|
||||||
|
leftSlot: <Icon icon="rotate_ccw" />,
|
||||||
|
color: 'danger',
|
||||||
|
async onSelect() {
|
||||||
|
const confirmed = await showConfirm({
|
||||||
|
id: 'git-reset-changes',
|
||||||
|
title: 'Reset Changes',
|
||||||
|
description: 'This will discard all uncommitted changes. This cannot be undone.',
|
||||||
|
confirmText: 'Reset',
|
||||||
|
color: 'danger',
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await resetChanges.mutateAsync(undefined, {
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: 'git-reset-success',
|
||||||
|
message: 'Changes have been reset',
|
||||||
|
color: 'success',
|
||||||
|
});
|
||||||
|
sync({ force: true });
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-reset-error',
|
||||||
|
title: 'Error resetting changes',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 'separator', label: 'Branches', hidden: localBranches.length < 1 },
|
{ type: 'separator', label: 'Branches', hidden: localBranches.length < 1 },
|
||||||
...localBranches.map((branch) => {
|
...localBranches.map((branch) => {
|
||||||
const isCurrent = currentBranch === branch;
|
const isCurrent = currentBranch === branch;
|
||||||
@@ -463,8 +504,14 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
return (
|
return (
|
||||||
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
|
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
|
||||||
<GitMenuButton>
|
<GitMenuButton>
|
||||||
<InlineCode>{currentBranch}</InlineCode>
|
<InlineCode className="flex items-center gap-1">
|
||||||
<Icon icon="git_branch" size="sm" />
|
<Icon icon="git_branch" size="xs" className="opacity-50" />
|
||||||
|
{currentBranch}
|
||||||
|
</InlineCode>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{ahead > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-primary">↗</span>{ahead}</span>}
|
||||||
|
{behind > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-info">↙</span>{behind}</span>}
|
||||||
|
</div>
|
||||||
</GitMenuButton>
|
</GitMenuButton>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { GitCallbacks } from '@yaakapp-internal/git';
|
import type { GitCallbacks } from '@yaakapp-internal/git';
|
||||||
|
import { sync } from '../../init/sync';
|
||||||
import { promptCredentials } from './credentials';
|
import { promptCredentials } from './credentials';
|
||||||
|
import { promptDivergedStrategy } from './diverged';
|
||||||
|
import { promptUncommittedChangesStrategy } from './uncommitted';
|
||||||
import { addGitRemote } from './showAddRemoteDialog';
|
import { addGitRemote } from './showAddRemoteDialog';
|
||||||
|
|
||||||
export function gitCallbacks(dir: string): GitCallbacks {
|
export function gitCallbacks(dir: string): GitCallbacks {
|
||||||
@@ -12,5 +15,12 @@ export function gitCallbacks(dir: string): GitCallbacks {
|
|||||||
if (creds == null) throw new Error('Cancelled credentials prompt');
|
if (creds == null) throw new Error('Cancelled credentials prompt');
|
||||||
return creds;
|
return creds;
|
||||||
},
|
},
|
||||||
|
promptDiverged: async ({ remote, branch }) => {
|
||||||
|
return promptDivergedStrategy({ remote, branch });
|
||||||
|
},
|
||||||
|
promptUncommittedChanges: async () => {
|
||||||
|
return promptUncommittedChangesStrategy();
|
||||||
|
},
|
||||||
|
forceSync: () => sync({ force: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
102
src-web/components/git/diverged.tsx
Normal file
102
src-web/components/git/diverged.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { DivergedStrategy } from '@yaakapp-internal/git';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { showDialog } from '../../lib/dialog';
|
||||||
|
import { Button } from '../core/Button';
|
||||||
|
import { InlineCode } from '../core/InlineCode';
|
||||||
|
import { RadioCards } from '../core/RadioCards';
|
||||||
|
import { HStack } from '../core/Stacks';
|
||||||
|
|
||||||
|
type Resolution = 'force_reset' | 'merge';
|
||||||
|
|
||||||
|
const resolutionLabel: Record<Resolution, string> = {
|
||||||
|
force_reset: 'Force Pull',
|
||||||
|
merge: 'Merge',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DivergedDialogProps {
|
||||||
|
remote: string;
|
||||||
|
branch: string;
|
||||||
|
onResult: (strategy: DivergedStrategy) => void;
|
||||||
|
onHide: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProps) {
|
||||||
|
const [selected, setSelected] = useState<Resolution | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (selected == null) return;
|
||||||
|
onResult(selected);
|
||||||
|
onHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
onResult('cancel');
|
||||||
|
onHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mb-4">
|
||||||
|
<p className="text-text-subtle">
|
||||||
|
Your local branch has diverged from{' '}
|
||||||
|
<InlineCode>
|
||||||
|
{remote}/{branch}
|
||||||
|
</InlineCode>. How would you like to resolve this?
|
||||||
|
</p>
|
||||||
|
<RadioCards
|
||||||
|
name="diverged-strategy"
|
||||||
|
value={selected}
|
||||||
|
onChange={setSelected}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'merge',
|
||||||
|
label: 'Merge Commit',
|
||||||
|
description: 'Combining local and remote changes into a single merge commit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'force_reset',
|
||||||
|
label: 'Force Pull',
|
||||||
|
description: 'Discard local commits and reset to match the remote branch',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
||||||
|
<Button
|
||||||
|
color={selected === 'force_reset' ? 'danger' : 'primary'}
|
||||||
|
disabled={selected == null}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{selected != null ? resolutionLabel[selected] : 'Select an option'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="border" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptDivergedStrategy({
|
||||||
|
remote,
|
||||||
|
branch,
|
||||||
|
}: {
|
||||||
|
remote: string;
|
||||||
|
branch: string;
|
||||||
|
}): Promise<DivergedStrategy> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
showDialog({
|
||||||
|
id: 'git-diverged',
|
||||||
|
title: 'Branches Diverged',
|
||||||
|
hideX: true,
|
||||||
|
size: 'sm',
|
||||||
|
disableBackdropClose: true,
|
||||||
|
onClose: () => resolve('cancel'),
|
||||||
|
render: ({ hide }) =>
|
||||||
|
DivergedDialog({
|
||||||
|
remote,
|
||||||
|
branch,
|
||||||
|
onHide: hide,
|
||||||
|
onResult: resolve,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,5 +26,11 @@ export function handlePullResult(r: PullResult) {
|
|||||||
case 'up_to_date':
|
case 'up_to_date':
|
||||||
showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' });
|
showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' });
|
||||||
break;
|
break;
|
||||||
|
case 'diverged':
|
||||||
|
// Handled by mutation callback before reaching here
|
||||||
|
break;
|
||||||
|
case 'uncommitted_changes':
|
||||||
|
// Handled by mutation callback before reaching here
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src-web/components/git/uncommitted.tsx
Normal file
13
src-web/components/git/uncommitted.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { UncommittedChangesStrategy } from '@yaakapp-internal/git';
|
||||||
|
import { showConfirm } from '../../lib/confirm';
|
||||||
|
|
||||||
|
export async function promptUncommittedChangesStrategy(): Promise<UncommittedChangesStrategy> {
|
||||||
|
const confirmed = await showConfirm({
|
||||||
|
id: 'git-uncommitted-changes',
|
||||||
|
title: 'Uncommitted Changes',
|
||||||
|
description: 'You have uncommitted changes. Commit or reset your changes before pulling.',
|
||||||
|
confirmText: 'Reset and Pull',
|
||||||
|
color: 'danger',
|
||||||
|
});
|
||||||
|
return confirmed ? 'reset' : 'cancel';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user