mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-30 10:09:18 -05:00
Git Improvements (#382)
This commit is contained in:
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 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, };
|
||||
|
||||
@@ -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<GitRemote | null>;
|
||||
promptCredentials: (
|
||||
result: Extract<PushResult, { type: 'needs_credentials' }>,
|
||||
) => Promise<GitCredentials | null>;
|
||||
promptDiverged: (
|
||||
result: Extract<PullResult, { type: 'diverged' }>,
|
||||
) => Promise<DivergedStrategy>;
|
||||
promptUncommittedChanges: () => Promise<UncommittedChangesStrategy>;
|
||||
forceSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
||||
@@ -69,6 +79,15 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
return invoke<PushResult>('cmd_git_push', { dir });
|
||||
};
|
||||
|
||||
const handleError = (err: unknown) => {
|
||||
showToast({
|
||||
id: `${err}`,
|
||||
message: `${err}`,
|
||||
color: 'danger',
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'init'],
|
||||
@@ -133,10 +152,9 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
},
|
||||
onSuccess,
|
||||
}),
|
||||
fetchAll: createFastMutation<string, string, void>({
|
||||
mutationKey: ['git', 'checkout', dir],
|
||||
fetchAll: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'fetch_all', dir],
|
||||
mutationFn: () => invoke('cmd_git_fetch_all', { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
push: createFastMutation<PushResult, string, void>({
|
||||
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<PullResult>('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<PullResult>('cmd_git_pull', { dir });
|
||||
// Pull again after credentials
|
||||
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,
|
||||
}),
|
||||
@@ -169,6 +218,11 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
mutationFn: (args) => invoke('cmd_git_unstage', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
resetChanges: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'reset-changes', dir],
|
||||
mutationFn: () => invoke('cmd_git_reset_changes', { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
} as const;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,9 +15,23 @@ pub enum PullResult {
|
||||
Success { message: String },
|
||||
UpToDate,
|
||||
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> {
|
||||
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<PullResult> {
|
||||
}
|
||||
|
||||
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<PullResult> {
|
||||
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> {
|
||||
// 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 local_branches: Vec<String>,
|
||||
pub remote_branches: Vec<String>,
|
||||
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<GitStatusSummary> {
|
||||
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<GitStatusSummary> {
|
||||
head_ref_shorthand,
|
||||
local_branches,
|
||||
remote_branches,
|
||||
ahead: ahead as u32,
|
||||
behind: behind as u32,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user