支持git冲突时弹出窗口合并代码解决冲突
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["welcome", "folder-*", "commit-*", "settings"],
|
||||
"windows": ["welcome", "folder-*", "commit-*", "merge-*", "settings"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
|
||||
@@ -33,9 +33,17 @@ pub struct GitBranchList {
|
||||
pub worktree_branches: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GitConflictInfo {
|
||||
pub has_conflicts: bool,
|
||||
pub conflicted_files: Vec<String>,
|
||||
pub operation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GitPullResult {
|
||||
pub updated_files: usize,
|
||||
pub conflict: Option<GitConflictInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -47,6 +55,21 @@ pub struct GitPushResult {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GitMergeResult {
|
||||
pub merged_commits: usize,
|
||||
pub conflict: Option<GitConflictInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GitRebaseResult {
|
||||
pub message: String,
|
||||
pub conflict: Option<GitConflictInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GitConflictFileVersions {
|
||||
pub base: String,
|
||||
pub ours: String,
|
||||
pub theirs: String,
|
||||
pub merged: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -160,6 +183,25 @@ fn git_command_error(operation: &str, stderr: &[u8]) -> AppCommandError {
|
||||
AppCommandError::external_command(format!("git {operation} failed"), stderr)
|
||||
}
|
||||
|
||||
async fn detect_conflicts(path: &str) -> Result<Vec<String>, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["diff", "--name-only", "--diff-filter=U"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_head_hash(path: &str) -> Result<Option<String>, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
@@ -484,15 +526,110 @@ pub async fn git_init(path: String) -> Result<(), AppCommandError> {
|
||||
pub async fn git_pull(path: String) -> Result<GitPullResult, AppCommandError> {
|
||||
let head_before = get_head_hash(&path).await?;
|
||||
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["pull"])
|
||||
// Step 1: fetch from remote
|
||||
let fetch_output = crate::process::tokio_command("git")
|
||||
.args(["fetch"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("pull", &output.stderr));
|
||||
if !fetch_output.status.success() {
|
||||
return Err(git_command_error("fetch", &fetch_output.stderr));
|
||||
}
|
||||
|
||||
// Step 2: check if upstream exists
|
||||
let upstream_check = crate::process::tokio_command("git")
|
||||
.args(["rev-parse", "@{u}"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !upstream_check.status.success() {
|
||||
// No upstream configured, nothing to merge
|
||||
return Ok(GitPullResult {
|
||||
updated_files: 0,
|
||||
conflict: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: check if we can fast-forward
|
||||
let merge_base = crate::process::tokio_command("git")
|
||||
.args(["merge-base", "HEAD", "@{u}"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
let head_hash = crate::process::tokio_command("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
let base_hash = String::from_utf8_lossy(&merge_base.stdout).trim().to_string();
|
||||
let current_head = String::from_utf8_lossy(&head_hash.stdout).trim().to_string();
|
||||
|
||||
if base_hash == current_head {
|
||||
// Can fast-forward — just do it
|
||||
let ff_output = crate::process::tokio_command("git")
|
||||
.args(["merge", "--ff-only", "@{u}"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !ff_output.status.success() {
|
||||
return Err(git_command_error("merge --ff-only", &ff_output.stderr));
|
||||
}
|
||||
} else {
|
||||
// Non-fast-forward: try merge with --no-commit to detect conflicts
|
||||
let merge_output = crate::process::tokio_command("git")
|
||||
.args(["merge", "--no-commit", "@{u}"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !merge_output.status.success() {
|
||||
// Check for conflicts
|
||||
let conflicted_files = detect_conflicts(&path).await?;
|
||||
if !conflicted_files.is_empty() {
|
||||
// Abort merge to restore working tree
|
||||
let _ = crate::process::tokio_command("git")
|
||||
.args(["merge", "--abort"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
return Ok(GitPullResult {
|
||||
updated_files: 0,
|
||||
conflict: Some(GitConflictInfo {
|
||||
has_conflicts: true,
|
||||
conflicted_files,
|
||||
operation: "pull".to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Err(git_command_error("merge", &merge_output.stderr));
|
||||
}
|
||||
|
||||
// Merge succeeded without conflicts — commit
|
||||
let commit_output = crate::process::tokio_command("git")
|
||||
.args(["commit", "--no-edit"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !commit_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&commit_output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&commit_output.stdout);
|
||||
if !stderr.contains("nothing to commit") && !stdout.contains("nothing to commit") {
|
||||
return Err(git_command_error("commit", &commit_output.stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let head_after = get_head_hash(&path).await?;
|
||||
@@ -504,7 +641,34 @@ pub async fn git_pull(path: String) -> Result<GitPullResult, AppCommandError> {
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
Ok(GitPullResult { updated_files })
|
||||
Ok(GitPullResult {
|
||||
updated_files,
|
||||
conflict: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start a merge with the upstream branch (used by merge workspace after pull conflict detection).
|
||||
/// This recreates the conflict state so that :1:, :2:, :3: stage entries are available.
|
||||
#[tauri::command]
|
||||
pub async fn git_start_pull_merge(path: String) -> Result<(), AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["merge", "--no-commit", "@{u}"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
// It's expected to fail with conflicts — that's the point.
|
||||
// We just need the merge state to be active so stage entries exist.
|
||||
if !output.status.success() {
|
||||
let conflicted_files = detect_conflicts(&path).await?;
|
||||
if !conflicted_files.is_empty() {
|
||||
return Ok(()); // Conflict state is now active — merge workspace can proceed
|
||||
}
|
||||
return Err(git_command_error("merge", &output.stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1241,13 +1405,30 @@ pub async fn git_merge(
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let conflicted_files = detect_conflicts(&path).await?;
|
||||
if !conflicted_files.is_empty() {
|
||||
return Ok(GitMergeResult {
|
||||
merged_commits,
|
||||
conflict: Some(GitConflictInfo {
|
||||
has_conflicts: true,
|
||||
conflicted_files,
|
||||
operation: "merge".to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Err(git_command_error("merge", &output.stderr));
|
||||
}
|
||||
Ok(GitMergeResult { merged_commits })
|
||||
Ok(GitMergeResult {
|
||||
merged_commits,
|
||||
conflict: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_rebase(path: String, branch_name: String) -> Result<String, AppCommandError> {
|
||||
pub async fn git_rebase(
|
||||
path: String,
|
||||
branch_name: String,
|
||||
) -> Result<GitRebaseResult, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["rebase", &branch_name])
|
||||
.current_dir(&path)
|
||||
@@ -1256,9 +1437,23 @@ pub async fn git_rebase(path: String, branch_name: String) -> Result<String, App
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let conflicted_files = detect_conflicts(&path).await?;
|
||||
if !conflicted_files.is_empty() {
|
||||
return Ok(GitRebaseResult {
|
||||
message: String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
||||
conflict: Some(GitConflictInfo {
|
||||
has_conflicts: true,
|
||||
conflicted_files,
|
||||
operation: "rebase".to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Err(git_command_error("rebase", &output.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
Ok(GitRebaseResult {
|
||||
message: String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
||||
conflict: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1281,6 +1476,145 @@ pub async fn git_delete_branch(
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_list_conflicts(path: String) -> Result<Vec<String>, AppCommandError> {
|
||||
detect_conflicts(&path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_conflict_file_versions(
|
||||
path: String,
|
||||
file: String,
|
||||
) -> Result<GitConflictFileVersions, AppCommandError> {
|
||||
// :1: = base (common ancestor), :2: = ours (HEAD), :3: = theirs (incoming)
|
||||
let mut versions = Vec::with_capacity(3);
|
||||
for stage in ["1", "2", "3"] {
|
||||
let file_spec = format!(":{}:{}", stage, file);
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["show", &file_spec])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
// File may not exist at this stage (e.g. newly added on one side)
|
||||
versions.push(String::new());
|
||||
} else {
|
||||
let bytes = &output.stdout;
|
||||
if bytes.iter().take(2048).any(|b| *b == 0) {
|
||||
return Err(
|
||||
AppCommandError::invalid_input("Binary files are not supported")
|
||||
.with_detail(file_spec),
|
||||
);
|
||||
}
|
||||
versions.push(String::from_utf8_lossy(bytes).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Read the working tree file (contains conflict markers)
|
||||
let file_path = Path::new(&path).join(&file);
|
||||
let merged = std::fs::read_to_string(&file_path).unwrap_or_default();
|
||||
|
||||
Ok(GitConflictFileVersions {
|
||||
base: versions.remove(0),
|
||||
ours: versions.remove(0),
|
||||
theirs: versions.remove(0),
|
||||
merged,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_resolve_conflict(
|
||||
path: String,
|
||||
file: String,
|
||||
content: String,
|
||||
) -> Result<(), AppCommandError> {
|
||||
let file_path = Path::new(&path).join(&file);
|
||||
|
||||
// Write resolved content
|
||||
std::fs::write(&file_path, content).map_err(|e| {
|
||||
AppCommandError::io_error(format!("Failed to write resolved file: {}", e))
|
||||
})?;
|
||||
|
||||
// Stage the resolved file
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["add", &file])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("add", &output.stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_abort_operation(
|
||||
path: String,
|
||||
operation: String,
|
||||
) -> Result<(), AppCommandError> {
|
||||
let args = match operation.as_str() {
|
||||
"merge" | "pull" => vec!["merge", "--abort"],
|
||||
"rebase" => vec!["rebase", "--abort"],
|
||||
_ => {
|
||||
return Err(AppCommandError::invalid_input(format!(
|
||||
"Unknown operation: {operation}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(&args)
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error(
|
||||
&format!("{} --abort", operation),
|
||||
&output.stderr,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_continue_operation(
|
||||
path: String,
|
||||
operation: String,
|
||||
) -> Result<(), AppCommandError> {
|
||||
let (program, args): (&str, Vec<&str>) = match operation.as_str() {
|
||||
"merge" | "pull" => ("git", vec!["commit", "--no-edit"]),
|
||||
"rebase" => ("git", vec!["rebase", "--continue"]),
|
||||
_ => {
|
||||
return Err(AppCommandError::invalid_input(format!(
|
||||
"Unknown operation: {operation}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let output = crate::process::tokio_command(program)
|
||||
.args(&args)
|
||||
.current_dir(&path)
|
||||
.env("GIT_EDITOR", "true")
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error(
|
||||
&format!("{} --continue", operation),
|
||||
&output.stderr,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const WATCH_IGNORED_DIRS: &[&str] = &["__pycache__"];
|
||||
const FILE_TREE_IGNORED_DIRS: &[&str] = &[".git", "__pycache__"];
|
||||
|
||||
|
||||
@@ -391,6 +391,108 @@ pub fn restore_window_after_commit(
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MergeWindowState {
|
||||
owner_by_merge_label: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl MergeWindowState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
owner_by_merge_label: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_owner(&self, merge_label: String, owner_label: String) {
|
||||
if let Ok(mut owners) = self.owner_by_merge_label.lock() {
|
||||
owners.insert(merge_label, owner_label);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_owner(&self, merge_label: &str) -> Option<String> {
|
||||
self.owner_by_merge_label
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut owners| owners.remove(merge_label))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_merge_window(
|
||||
app: AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
state: tauri::State<'_, MergeWindowState>,
|
||||
folder_id: i32,
|
||||
operation: String,
|
||||
) -> Result<(), AppCommandError> {
|
||||
let owner_label = window.label().to_string();
|
||||
let label = format!("merge-{folder_id}");
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||
owner_window.set_enabled(false).map_err(|e| {
|
||||
AppCommandError::window("Failed to disable owner window", e.to_string())
|
||||
})?;
|
||||
}
|
||||
state.set_owner(label.clone(), owner_label);
|
||||
let _ = existing.unminimize();
|
||||
existing
|
||||
.set_focus()
|
||||
.map_err(|e| AppCommandError::window("Failed to focus merge window", e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?
|
||||
.ok_or_else(|| {
|
||||
AppCommandError::not_found(format!("Folder {folder_id} not found"))
|
||||
.with_detail(format!("folder_id={folder_id}"))
|
||||
})?;
|
||||
|
||||
let url = WebviewUrl::App(
|
||||
format!("merge?folderId={folder_id}&operation={operation}").into(),
|
||||
);
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||
.title(format!("解决冲突 - {}", folder.name))
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(1100.0, 650.0)
|
||||
.always_on_top(true)
|
||||
.center();
|
||||
let merge_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| AppCommandError::window("Failed to open merge window", e.to_string()))?;
|
||||
ensure_windows_undecorated(&merge_window);
|
||||
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||
if let Err(err) = owner_window.set_enabled(false) {
|
||||
let _ = merge_window.close();
|
||||
return Err(AppCommandError::window(
|
||||
"Failed to disable owner window",
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
state.set_owner(label, owner_label);
|
||||
merge_window
|
||||
.set_focus()
|
||||
.map_err(|e| AppCommandError::window("Failed to focus merge window", e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restore_window_after_merge(
|
||||
app: &AppHandle,
|
||||
state: &MergeWindowState,
|
||||
merge_window_label: &str,
|
||||
) {
|
||||
if let Some(owner_label) = state.take_owner(merge_window_label) {
|
||||
if let Some(window) = app.get_webview_window(&owner_label) {
|
||||
let _ = window.set_enabled(true);
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> {
|
||||
if let Some(existing) = app.get_webview_window("welcome") {
|
||||
ensure_windows_undecorated(&existing);
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn run() {
|
||||
.manage(TerminalManager::new())
|
||||
.manage(windows::SettingsWindowState::new())
|
||||
.manage(windows::CommitWindowState::new())
|
||||
.manage(windows::MergeWindowState::new())
|
||||
.setup(|app| {
|
||||
let app_data_dir = app.path().app_data_dir()?;
|
||||
let app_version = env!("CARGO_PKG_VERSION");
|
||||
@@ -113,6 +114,18 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
if label.starts_with("merge-")
|
||||
&& matches!(
|
||||
event,
|
||||
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
|
||||
)
|
||||
{
|
||||
let app = window.app_handle();
|
||||
if let Some(state) = app.try_state::<windows::MergeWindowState>() {
|
||||
windows::restore_window_after_merge(app, &state, &label);
|
||||
}
|
||||
}
|
||||
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if label.starts_with("folder-") {
|
||||
let app = window.app_handle();
|
||||
@@ -181,6 +194,7 @@ pub fn run() {
|
||||
folders::get_git_branch,
|
||||
folders::git_init,
|
||||
folders::git_pull,
|
||||
folders::git_start_pull_merge,
|
||||
folders::git_fetch,
|
||||
folders::git_push,
|
||||
folders::git_new_branch,
|
||||
@@ -207,6 +221,11 @@ pub fn run() {
|
||||
folders::git_merge,
|
||||
folders::git_rebase,
|
||||
folders::git_delete_branch,
|
||||
folders::git_list_conflicts,
|
||||
folders::git_conflict_file_versions,
|
||||
folders::git_resolve_conflict,
|
||||
folders::git_abort_operation,
|
||||
folders::git_continue_operation,
|
||||
folders::save_folder_opened_conversations,
|
||||
folders::start_file_tree_watch,
|
||||
folders::stop_file_tree_watch,
|
||||
@@ -226,6 +245,7 @@ pub fn run() {
|
||||
windows::open_settings_window,
|
||||
windows::list_open_folders,
|
||||
windows::focus_folder_window,
|
||||
windows::open_merge_window,
|
||||
system_settings::get_system_proxy_settings,
|
||||
system_settings::update_system_proxy_settings,
|
||||
system_settings::get_system_language_settings,
|
||||
|
||||
Reference in New Issue
Block a user