feat(desktop): add PR merge conflict recovery (#2164)

This commit is contained in:
thomaspblock
2026-07-20 13:41:33 +02:00
committed by GitHub
parent 6ecc89468d
commit 4fe3835e76
15 changed files with 973 additions and 153 deletions
@@ -32,6 +32,68 @@ pub struct ProjectRepoMergeResult {
pub status_publication_error: Option<String>,
}
/// Machine-readable recovery metadata for a failed pull-request merge.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestMergeRecovery {
action: String,
target_branch: String,
source_branch: String,
}
/// Structured pull-request merge failure returned across the Tauri boundary.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestMergeError {
code: String,
message: String,
recovery: Option<ProjectPullRequestMergeRecovery>,
}
impl ProjectPullRequestMergeError {
fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_string(),
message: message.into(),
recovery: None,
}
}
fn conflict(target_branch: String, source_branch: String) -> Self {
Self {
code: "merge_conflict".to_string(),
message: "Pull request has merge conflicts.".to_string(),
recovery: Some(ProjectPullRequestMergeRecovery {
action: "open_terminal".to_string(),
target_branch,
source_branch,
}),
}
}
}
impl From<String> for ProjectPullRequestMergeError {
fn from(message: String) -> Self {
Self::new("merge_failed", message)
}
}
fn classify_merge_error(
message: String,
has_conflicts: bool,
target_branch: &str,
source_branch: &str,
) -> ProjectPullRequestMergeError {
if has_conflicts {
ProjectPullRequestMergeError::conflict(target_branch.to_string(), source_branch.to_string())
} else {
ProjectPullRequestMergeError::new(
"merge_failed",
format!("Pull request merge failed: {message}"),
)
}
}
struct ProjectRepoMergeGitResult {
message: String,
merge_commit: String,
@@ -403,7 +465,7 @@ pub async fn merge_project_pull_request(
input: ProjectPullRequestMergeInput,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ProjectRepoMergeResult, String> {
) -> Result<ProjectRepoMergeResult, ProjectPullRequestMergeError> {
let ProjectPullRequestMergeInput {
target_clone_url,
source_clone_url,
@@ -420,10 +482,12 @@ pub async fn merge_project_pull_request(
validate_workspace_clone_url(&source_clone_url, &state)?;
let target_owner = target_owner.trim().to_ascii_lowercase();
if target_owner.len() != 64 || !target_owner.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Invalid target repository owner.".to_string());
return Err("Invalid target repository owner.".to_string().into());
}
if clone_url_owner(&target_clone_url).as_deref() != Some(target_owner.as_str()) {
return Err("Target clone URL does not match the repository owner.".to_string());
return Err("Target clone URL does not match the repository owner."
.to_string()
.into());
}
let owner_identity = project_owner_identity(&app, &state, &target_owner)?;
let merger_pubkey = owner_identity.keys.public_key().to_hex();
@@ -432,7 +496,9 @@ pub async fn merge_project_pull_request(
let source_branch = normalize_branch_option(Some(&source_branch))
.ok_or_else(|| "Invalid source branch.".to_string())?;
if target_branch == source_branch && same_repository(&target_clone_url, &source_clone_url) {
return Err("Source and target branches must be different.".to_string());
return Err("Source and target branches must be different."
.to_string()
.into());
}
let expected_commit = normalize_commit(&expected_commit)
.ok_or_else(|| "Invalid pull request commit.".to_string())?;
@@ -444,87 +510,109 @@ pub async fn merge_project_pull_request(
)?;
let auth = build_git_auth_config_for_keys(&owner_identity.keys)?;
let git_result = tauri::async_runtime::spawn_blocking(move || {
let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
let repo_dir = temp_dir.path().join("repo");
let repo_path = repo_dir
.to_str()
.ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
run_git(
&[
"clone",
"--filter=blob:none",
"--no-tags",
"--branch",
target_branch.as_str(),
"--single-branch",
"--end-of-options",
target_clone_url.as_str(),
repo_path,
],
None,
&auth,
)?;
run_git(
&[
"fetch",
"--quiet",
"--end-of-options",
source_clone_url.as_str(),
source_branch.as_str(),
],
Some(&repo_dir),
&auth,
)?;
let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
.ok()
.and_then(|output| first_output_line(&output))
.ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
if source_head.to_ascii_lowercase() != expected_commit {
return Err(
"The pull request branch changed. Refresh the pull request before merging."
.to_string(),
let git_result = tauri::async_runtime::spawn_blocking(
move || -> Result<ProjectRepoMergeGitResult, ProjectPullRequestMergeError> {
let temp_dir =
tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
let repo_dir = temp_dir.path().join("repo");
let repo_path = repo_dir
.to_str()
.ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
run_git(
&[
"clone",
"--filter=blob:none",
"--no-tags",
"--branch",
target_branch.as_str(),
"--single-branch",
"--end-of-options",
target_clone_url.as_str(),
repo_path,
],
None,
&auth,
)?;
run_git(
&[
"fetch",
"--quiet",
"--end-of-options",
source_clone_url.as_str(),
source_branch.as_str(),
],
Some(&repo_dir),
&auth,
)?;
let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
.ok()
.and_then(|output| first_output_line(&output))
.ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
if source_head.to_ascii_lowercase() != expected_commit {
return Err(ProjectPullRequestMergeError::new(
"branch_changed",
"The pull request branch changed. Refresh the pull request before merging."
.to_string(),
));
}
let merge_email = format!("{merger_pubkey}@users.noreply.buzz");
let merge_result = run_git(
&[
"-c",
"user.name=Buzz User",
"-c",
format!("user.email={merge_email}").as_str(),
"merge",
"--no-edit",
"--end-of-options",
expected_commit.as_str(),
],
Some(&repo_dir),
&auth,
);
}
if let Err(error) = merge_result {
let has_conflicts = run_git(
&["diff", "--name-only", "--diff-filter=U"],
Some(&repo_dir),
&auth,
)
.is_ok_and(|output| !output.trim().is_empty());
return Err(classify_merge_error(
error,
has_conflicts,
&target_branch,
&source_branch,
));
}
let merge_commit = run_git(&["rev-parse", "HEAD"], Some(&repo_dir), &auth)
.ok()
.and_then(|output| first_output_line(&output))
.ok_or_else(|| "Could not resolve the merge commit.".to_string())?;
run_git(
&[
"push",
"--end-of-options",
"origin",
format!("HEAD:{target_branch}").as_str(),
],
Some(&repo_dir),
&auth,
)?;
let merge_email = format!("{merger_pubkey}@users.noreply.buzz");
run_git(
&[
"-c",
"user.name=Buzz User",
"-c",
format!("user.email={merge_email}").as_str(),
"merge",
"--no-edit",
"--end-of-options",
expected_commit.as_str(),
],
Some(&repo_dir),
&auth,
)
.map_err(|error| format!("Pull request cannot be merged cleanly: {error}"))?;
let merge_commit = run_git(&["rev-parse", "HEAD"], Some(&repo_dir), &auth)
.ok()
.and_then(|output| first_output_line(&output))
.ok_or_else(|| "Could not resolve the merge commit.".to_string())?;
run_git(
&[
"push",
"--end-of-options",
"origin",
format!("HEAD:{target_branch}").as_str(),
],
Some(&repo_dir),
&auth,
)?;
Ok(ProjectRepoMergeGitResult {
message: format!("Merged {source_branch} into {target_branch}."),
merge_commit,
})
})
Ok(ProjectRepoMergeGitResult {
message: format!("Merged {source_branch} into {target_branch}."),
merge_commit,
})
},
)
.await
.map_err(|error| format!("pull request merge task failed: {error}"))??;
.map_err(|error| {
ProjectPullRequestMergeError::new(
"merge_task_failed",
format!("pull request merge task failed: {error}"),
)
})??;
let status_event = build_merged_status_event(
&owner_identity.keys,
&repo_address,
@@ -554,8 +642,9 @@ pub async fn merge_project_pull_request(
#[cfg(test)]
mod tests {
use super::{
build_merged_status_event, build_review_request_event, normalize_commit, same_repository,
validate_merge_status_metadata,
build_merged_status_event, build_review_request_event, classify_merge_error,
normalize_commit, same_repository, validate_merge_status_metadata,
ProjectPullRequestMergeError,
};
use nostr::{Event, JsonUtil, Keys, Timestamp};
@@ -583,6 +672,51 @@ mod tests {
));
}
#[test]
fn merge_conflict_error_has_stable_recovery_metadata() {
let error =
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
assert_eq!(error.code, "merge_conflict");
assert_eq!(error.message, "Pull request has merge conflicts.");
let recovery = error.recovery.expect("conflict recovery");
assert_eq!(recovery.action, "open_terminal");
assert_eq!(recovery.target_branch, "main");
assert_eq!(recovery.source_branch, "feature/demo");
}
#[test]
fn merge_conflict_error_serializes_for_tauri_clients() {
let error =
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
let value = serde_json::to_value(error).expect("serialize merge conflict");
assert_eq!(value["code"], "merge_conflict");
assert_eq!(value["recovery"]["targetBranch"], "main");
assert_eq!(value["recovery"]["sourceBranch"], "feature/demo");
}
#[test]
fn merge_error_classification_only_recovers_conflicts() {
let conflict = classify_merge_error(
"CONFLICT (content): Merge conflict in src/main.rs".to_string(),
true,
"main",
"feature/demo",
);
assert_eq!(conflict.code, "merge_conflict");
assert!(conflict.recovery.is_some());
let other = classify_merge_error(
"fatal: refusing to merge unrelated histories".to_string(),
false,
"main",
"feature/demo",
);
assert_eq!(other.code, "merge_failed");
assert!(other.recovery.is_none());
}
#[test]
fn merged_status_is_signed_by_repository_owner() {
let keys = Keys::generate();
@@ -1,13 +1,15 @@
//! Opens an OS terminal window at a project's local git checkout, cloning
//! the repository from the relay first when no local checkout exists.
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::process::Command;
use tauri::State;
use crate::app_state::AppState;
use super::project_git_exec::{build_git_auth_config, validate_workspace_clone_url};
use super::project_git::{first_output_line, normalize_branch_option};
use super::project_git_diff::clean_commit;
use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url};
use super::project_git_workflow::clone_project_repository_blocking;
use super::project_repo_paths::find_local_repo_dir;
@@ -19,6 +21,37 @@ pub struct ProjectTerminalResult {
pub cloned: bool,
}
/// Inputs for preparing an authenticated local merge-conflict recovery.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMergeRecoveryTerminalInput {
repos_dir: Option<String>,
project_dtag: String,
target_clone_url: String,
source_clone_url: String,
target_branch: String,
source_branch: String,
expected_commit: String,
}
/// Local checkout and ref prepared for terminal-based conflict resolution.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMergeRecoveryTerminalResult {
path: String,
cloned: bool,
recovery_ref: String,
target_ref: String,
}
fn merge_recovery_ref(expected_commit: &str) -> String {
format!("refs/buzz/merge-recovery/{expected_commit}")
}
fn merge_recovery_target_ref(target_commit: &str) -> String {
format!("refs/buzz/merge-recovery-target/{target_commit}")
}
#[cfg(target_os = "macos")]
fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> {
let status = Command::new("open")
@@ -122,3 +155,123 @@ pub async fn open_project_terminal(
.await
.map_err(|error| format!("open terminal task failed: {error}"))?
}
/// Authenticates and fetches the exact pull-request commit before opening a
/// terminal. The user's worktree is not switched or modified.
#[tauri::command]
pub async fn open_project_merge_recovery_terminal(
input: ProjectMergeRecoveryTerminalInput,
state: State<'_, AppState>,
) -> Result<ProjectMergeRecoveryTerminalResult, String> {
validate_workspace_clone_url(&input.target_clone_url, &state)?;
validate_workspace_clone_url(&input.source_clone_url, &state)?;
let target_branch = normalize_branch_option(Some(&input.target_branch))
.ok_or_else(|| "Invalid target branch.".to_string())?;
let source_branch = normalize_branch_option(Some(&input.source_branch))
.ok_or_else(|| "Invalid source branch.".to_string())?;
let expected_commit = clean_commit(Some(input.expected_commit.trim().to_ascii_lowercase()))
.ok_or_else(|| "Invalid pull request commit.".to_string())?;
let auth = build_git_auth_config(&state)?;
tauri::async_runtime::spawn_blocking(move || {
let existing_dir = find_local_repo_dir(
input.repos_dir.as_deref(),
&input.project_dtag,
Some(&input.target_clone_url),
)
.ok()
.flatten();
let (repo_dir, cloned) = if let Some(repo_dir) = existing_dir {
(repo_dir, false)
} else {
let clone_result = clone_project_repository_blocking(
input.repos_dir.as_deref(),
&input.project_dtag,
&input.target_clone_url,
Some(&target_branch),
&auth,
)?;
(std::path::PathBuf::from(clone_result.path), true)
};
run_git(
&[
"fetch",
"--quiet",
"--no-tags",
"--end-of-options",
input.target_clone_url.as_str(),
target_branch.as_str(),
],
Some(&repo_dir),
&auth,
)?;
let target_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
.ok()
.and_then(|output| first_output_line(&output))
.ok_or_else(|| "Could not resolve the target branch.".to_string())?;
let target_ref = merge_recovery_target_ref(&target_head);
run_git(
&["update-ref", target_ref.as_str(), target_head.as_str()],
Some(&repo_dir),
&auth,
)?;
run_git(
&[
"fetch",
"--quiet",
"--no-tags",
"--end-of-options",
input.source_clone_url.as_str(),
source_branch.as_str(),
],
Some(&repo_dir),
&auth,
)?;
let source_head = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
.ok()
.and_then(|output| first_output_line(&output))
.ok_or_else(|| "Could not resolve the pull request branch.".to_string())?;
if source_head.to_ascii_lowercase() != expected_commit {
return Err(
"The pull request branch changed. Refresh the pull request before resolving conflicts."
.to_string(),
);
}
let recovery_ref = merge_recovery_ref(&expected_commit);
run_git(
&["update-ref", recovery_ref.as_str(), expected_commit.as_str()],
Some(&repo_dir),
&auth,
)?;
launch_terminal_at(&repo_dir)?;
Ok(ProjectMergeRecoveryTerminalResult {
path: repo_dir.display().to_string(),
cloned,
recovery_ref,
target_ref,
})
})
.await
.map_err(|error| format!("open merge recovery terminal task failed: {error}"))?
}
#[cfg(test)]
mod tests {
use super::{merge_recovery_ref, merge_recovery_target_ref};
#[test]
fn recovery_ref_is_namespaced_by_verified_commit() {
let commit = "a".repeat(40);
assert_eq!(
merge_recovery_ref(&commit),
format!("refs/buzz/merge-recovery/{commit}"),
);
assert_eq!(
merge_recovery_target_ref(&commit),
format!("refs/buzz/merge-recovery-target/{commit}"),
);
}
}
+1
View File
@@ -679,6 +679,7 @@ pub fn run() {
publish_project_pull_request_merged_status,
merge_project_pull_request,
open_project_terminal,
open_project_merge_recovery_terminal,
search_users,
get_presence,
get_os_idle_seconds,
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { projectPullRequestConflictCommands } from "./projectPullRequestConflictRecovery.ts";
test("builds copyable commands without executing merge recovery", () => {
assert.deepEqual(
projectPullRequestConflictCommands({
recoveryRef: `refs/buzz/merge-recovery/${"a".repeat(40)}`,
targetBranch: "main",
targetRef: `refs/buzz/merge-recovery-target/${"b".repeat(40)}`,
}),
[
'test -z "$(git status --porcelain=v1)" &&',
`(git switch 'main' || git switch --create 'main' 'refs/buzz/merge-recovery-target/${"b".repeat(40)}') &&`,
`git merge-base --is-ancestor HEAD 'refs/buzz/merge-recovery-target/${"b".repeat(40)}' &&`,
`git merge --ff-only 'refs/buzz/merge-recovery-target/${"b".repeat(40)}' &&`,
`git merge 'refs/buzz/merge-recovery/${"a".repeat(40)}'`,
],
);
});
test("quotes recovery values as inert shell arguments", () => {
const commands = projectPullRequestConflictCommands({
recoveryRef: `refs/buzz/merge-recovery/${"b".repeat(40)}`,
targetBranch: "release candidate",
targetRef: `refs/buzz/merge-recovery-target/${"c".repeat(40)}`,
});
assert.equal(
commands[1],
`(git switch 'release candidate' || git switch --create 'release candidate' 'refs/buzz/merge-recovery-target/${"c".repeat(40)}') &&`,
);
assert.equal(
commands[4],
`git merge 'refs/buzz/merge-recovery/${"b".repeat(40)}'`,
);
});
@@ -0,0 +1,24 @@
function quoteShellArgument(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
/** Build user-visible recovery commands; callers decide whether to copy them. */
export function projectPullRequestConflictCommands({
recoveryRef,
targetBranch,
targetRef,
}: {
recoveryRef: string;
targetBranch: string;
targetRef: string;
}): string[] {
const quotedTargetBranch = quoteShellArgument(targetBranch);
const quotedTargetRef = quoteShellArgument(targetRef);
return [
'test -z "$(git status --porcelain=v1)" &&',
`(git switch ${quotedTargetBranch} || git switch --create ${quotedTargetBranch} ${quotedTargetRef}) &&`,
`git merge-base --is-ancestor HEAD ${quotedTargetRef} &&`,
`git merge --ff-only ${quotedTargetRef} &&`,
`git merge ${quoteShellArgument(recoveryRef)}`,
];
}
@@ -1,12 +1,18 @@
import { GitMerge } from "lucide-react";
import { AlertTriangle, Copy, GitMerge, SquareTerminal } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import type { Project, ProjectPullRequest } from "@/features/projects/hooks";
import { projectPullRequestConflictCommands } from "@/features/projects/projectPullRequestConflictRecovery";
import {
useMergeProjectPullRequestMutation,
usePublishProjectPullRequestMergedMutation,
} from "@/features/projects/pullRequestMutations";
import {
ProjectPullRequestMergeError,
type ProjectPullRequestMergeRecovery,
} from "@/shared/api/projectGit";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
AlertDialog,
AlertDialogAction,
@@ -19,36 +25,85 @@ import {
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
export type OpenMergeRecoveryTerminal = (input: {
expectedCommit: string;
sourceBranch: string;
sourceCloneUrl: string;
targetBranch: string;
}) => Promise<{ recoveryRef: string; targetRef: string }>;
export function MergePullRequestButton({
onOpenTerminal,
project,
pullRequest,
}: {
onOpenTerminal?: OpenMergeRecoveryTerminal;
project: Project;
pullRequest: ProjectPullRequest;
}) {
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [unpublishedStatusEvent, setUnpublishedStatusEvent] = React.useState<
string | null
>(null);
const [isPreparingRecovery, setIsPreparingRecovery] = React.useState(false);
const [conflictRecoveryState, setConflictRecoveryState] = React.useState<{
pullRequestId: string;
recovery: ProjectPullRequestMergeRecovery;
} | null>(null);
const [unpublishedStatusState, setUnpublishedStatusState] = React.useState<{
event: string;
pullRequestId: string;
} | null>(null);
const [preparedRecoveryState, setPreparedRecoveryState] = React.useState<{
pullRequestId: string;
recoveryRef: string;
targetRef: string;
} | null>(null);
const mergeMutation = useMergeProjectPullRequestMutation(project);
const publishMergedMutation =
usePublishProjectPullRequestMergedMutation(project);
const targetBranch = pullRequest.targetBranch ?? project.defaultBranch;
const conflictRecovery =
conflictRecoveryState?.pullRequestId === pullRequest.id
? conflictRecoveryState.recovery
: null;
const unpublishedStatusEvent =
unpublishedStatusState?.pullRequestId === pullRequest.id
? unpublishedStatusState.event
: null;
const preparedRecovery =
preparedRecoveryState?.pullRequestId === pullRequest.id
? preparedRecoveryState
: null;
const handleMerge = React.useCallback(async () => {
try {
const result = await mergeMutation.mutateAsync({ pullRequest });
if (result.statusPublicationError) {
setUnpublishedStatusEvent(result.statusEvent);
setUnpublishedStatusState({
event: result.statusEvent,
pullRequestId: pullRequest.id,
});
toast.warning(result.message, {
description: result.statusPublicationError,
});
} else {
setUnpublishedStatusEvent(null);
setUnpublishedStatusState(null);
toast.success(result.message);
}
setConflictRecoveryState(null);
setPreparedRecoveryState(null);
setConfirmOpen(false);
} catch (error) {
if (
error instanceof ProjectPullRequestMergeError &&
error.code === "merge_conflict" &&
error.recovery
) {
setConflictRecoveryState({
pullRequestId: pullRequest.id,
recovery: error.recovery,
});
setPreparedRecoveryState(null);
setConfirmOpen(false);
}
toast.error(
error instanceof Error
? error.message
@@ -57,13 +112,58 @@ export function MergePullRequestButton({
}
}, [mergeMutation, pullRequest]);
const recoveryCommands =
conflictRecovery && preparedRecovery
? projectPullRequestConflictCommands({
recoveryRef: preparedRecovery.recoveryRef,
targetBranch: conflictRecovery.targetBranch,
targetRef: preparedRecovery.targetRef,
})
: [];
const handleOpenRecoveryTerminal = React.useCallback(async () => {
const sourceCloneUrl = pullRequest.cloneUrls[0] ?? project.cloneUrls[0];
if (!conflictRecovery || !pullRequest.commit || !sourceCloneUrl) return;
setIsPreparingRecovery(true);
try {
const result = await onOpenTerminal?.({
expectedCommit: pullRequest.commit,
sourceBranch: conflictRecovery.sourceBranch,
sourceCloneUrl,
targetBranch: conflictRecovery.targetBranch,
});
if (!result) return;
setPreparedRecoveryState({
pullRequestId: pullRequest.id,
recoveryRef: result.recoveryRef,
targetRef: result.targetRef,
});
toast.success("Recovery commit fetched and terminal opened.");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to prepare merge recovery.",
);
} finally {
setIsPreparingRecovery(false);
}
}, [
conflictRecovery,
onOpenTerminal,
project.cloneUrls,
pullRequest.cloneUrls,
pullRequest.commit,
pullRequest.id,
]);
const handlePublishMergedStatus = React.useCallback(async () => {
if (!unpublishedStatusEvent) return;
try {
await publishMergedMutation.mutateAsync({
statusEvent: unpublishedStatusEvent,
});
setUnpublishedStatusEvent(null);
setUnpublishedStatusState(null);
toast.success("Published merged pull request status.");
} catch (error) {
toast.error(
@@ -75,55 +175,115 @@ export function MergePullRequestButton({
}, [publishMergedMutation, unpublishedStatusEvent]);
return (
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
<Button
className="h-8 gap-1.5 bg-purple-600 px-3.5 text-white shadow-sm hover:bg-purple-700"
disabled={mergeMutation.isPending || publishMergedMutation.isPending}
onClick={() => {
if (unpublishedStatusEvent) {
void handlePublishMergedStatus();
} else {
setConfirmOpen(true);
}
}}
size="xs"
type="button"
>
<GitMerge className="h-3.5 w-3.5" />
{publishMergedMutation.isPending
? "Publishing…"
: unpublishedStatusEvent
? "Publish merged status"
: "Merge"}
</Button>
<AlertDialogContent data-testid="merge-pull-request-confirm">
<AlertDialogHeader>
<AlertDialogTitle>Merge pull request?</AlertDialogTitle>
<AlertDialogDescription>
Merge {pullRequest.branchName} into {targetBranch} and push the
result to the repository. The remote will reject the operation if
the branch changed or conflicts.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={mergeMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction asChild>
<div className="contents">
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
<Button
className="h-8 gap-1.5 bg-purple-600 px-3.5 text-white shadow-sm hover:bg-purple-700"
disabled={mergeMutation.isPending || publishMergedMutation.isPending}
onClick={() => {
if (unpublishedStatusEvent) {
void handlePublishMergedStatus();
} else {
setConfirmOpen(true);
}
}}
size="xs"
type="button"
>
<GitMerge className="h-3.5 w-3.5" />
{publishMergedMutation.isPending
? "Publishing…"
: unpublishedStatusEvent
? "Publish merged status"
: "Merge"}
</Button>
<AlertDialogContent data-testid="merge-pull-request-confirm">
<AlertDialogHeader>
<AlertDialogTitle>Merge pull request?</AlertDialogTitle>
<AlertDialogDescription>
Merge {pullRequest.branchName} into {targetBranch} and push the
result to the repository. The remote will reject the operation if
the branch changed or conflicts.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={mergeMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="merge-pull-request-confirm-button"
disabled={mergeMutation.isPending}
onClick={(event) => {
event.preventDefault();
void handleMerge();
}}
type="button"
>
{mergeMutation.isPending ? "Merging…" : "Merge pull request"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{conflictRecovery ? (
<div
className="w-full basis-full space-y-2 rounded-lg border border-amber-500/35 bg-amber-500/10 p-3"
data-testid="merge-conflict-recovery"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
Resolve conflicts in your local checkout
</p>
<p className="text-xs text-muted-foreground">
Prepare the local checkout, then switch to{" "}
{conflictRecovery.targetBranch} with the commands shown. After
resolving and committing, push the target branch and retry the
merge.
</p>
</div>
</div>
{preparedRecovery ? (
<pre className="overflow-x-auto rounded-md bg-background/80 p-2 font-mono text-xs text-foreground">
{recoveryCommands.join("\n")}
</pre>
) : (
<p className="rounded-md bg-background/80 p-2 text-xs text-muted-foreground">
Resolve in Terminal securely fetches the target and pull request
commits before showing copyable commands.
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
data-testid="merge-pull-request-confirm-button"
disabled={mergeMutation.isPending}
onClick={(event) => {
event.preventDefault();
void handleMerge();
}}
disabled={!onOpenTerminal || isPreparingRecovery}
onClick={() => void handleOpenRecoveryTerminal()}
size="xs"
type="button"
variant="outline"
>
{mergeMutation.isPending ? "Merging…" : "Merge pull request"}
<SquareTerminal className="h-3.5 w-3.5" />
{isPreparingRecovery ? "Preparing…" : "Resolve in Terminal"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
disabled={!preparedRecovery}
onClick={() =>
copyTextToClipboard(
recoveryCommands.join("\n"),
"Recovery commands copied",
)
}
size="xs"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
Copy commands
</Button>
</div>
</div>
) : null}
</div>
);
}
@@ -42,6 +42,7 @@ import {
profilePanelViewFromSearch,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useIdentityQuery } from "@/shared/api/hooks";
import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import {
channelChrome,
@@ -576,6 +577,26 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
hasLocalCheckout,
});
}, [activeBranch, hasLocalCheckout, openTerminal, project]);
const handleOpenMergeRecoveryTerminal = React.useCallback(
async (input: {
expectedCommit: string;
sourceBranch: string;
sourceCloneUrl: string;
targetBranch: string;
}) => {
const targetCloneUrl = project?.cloneUrls[0];
if (!project || !targetCloneUrl) {
throw new Error("No project selected.");
}
return openProjectMergeRecoveryTerminal({
...input,
projectDtag: project.dtag,
reposDir: activeCommunity?.reposDir,
targetCloneUrl,
});
},
[activeCommunity?.reposDir, project],
);
if (projectQuery.isLoading) {
return null;
@@ -850,6 +871,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
localSnapshotError={localRepoSnapshotQuery.error}
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
onBranchChange={setSelectedBranch}
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
onOpenTerminal={() => {
void handleOpenTerminal();
}}
@@ -32,6 +32,7 @@ import {
ProjectFeedRowMonoCell,
} from "./ProjectFeedRow";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
import { OverviewRailSection } from "./ProjectOverviewPanel";
import {
ProfileAuthorName,
@@ -448,12 +449,14 @@ export function PullRequestMetaRail({
function PullRequestDetail({
mode,
onOpenCommit,
onOpenTerminal,
profiles,
project,
pullRequest,
}: {
mode: PullRequestPanelMode;
onOpenCommit?: (commitHash: string) => void;
onOpenTerminal?: OpenMergeRecoveryTerminal;
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest;
@@ -663,7 +666,11 @@ function PullRequestDetail({
})}
</div>
) : null}
<PullRequestReviewCard project={project} pullRequest={pullRequest} />
<PullRequestReviewCard
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
/>
<h4 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<MessageSquare className="h-3.5 w-3.5" />
Add Your Comment
@@ -687,6 +694,7 @@ export function PullRequestsPanel({
isLoading,
mode = "conversation",
onOpenCommit,
onOpenTerminal,
onSelectedPullRequestIdChange,
profiles,
project,
@@ -697,6 +705,7 @@ export function PullRequestsPanel({
isLoading: boolean;
mode?: PullRequestPanelMode;
onOpenCommit?: (commitHash: string) => void;
onOpenTerminal?: OpenMergeRecoveryTerminal;
onSelectedPullRequestIdChange: (id: string | null) => void;
profiles?: UserProfileLookup;
project: Project;
@@ -738,6 +747,7 @@ export function PullRequestsPanel({
<PullRequestDetail
mode={mode}
onOpenCommit={onOpenCommit}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
@@ -29,6 +29,7 @@ import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
import { ProjectIssuesPanel } from "./ProjectIssuesPanel";
import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
import { ProjectOverviewPanel } from "./ProjectOverviewPanel";
import {
PullRequestDetailHeader,
@@ -124,6 +125,7 @@ export function WorkspaceTabs({
onSelectedPullRequestIdChange,
onSelectedTabChange,
onBranchChange,
onOpenMergeRecoveryTerminal,
onOpenTerminal,
snapshot,
snapshotError,
@@ -160,6 +162,7 @@ export function WorkspaceTabs({
/** Reports the active tab so the screen breadcrumb can mirror it. */
onSelectedTabChange?: (tab: string) => void;
onBranchChange: (branch: string | null) => void;
onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal;
onOpenTerminal?: () => void;
snapshot: ProjectRepoSnapshot | null | undefined;
snapshotError: unknown;
@@ -260,7 +263,7 @@ export function WorkspaceTabs({
<Button
aria-label="Open terminal"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={onOpenTerminal}
onClick={() => onOpenTerminal()}
size="icon"
title={terminalTitle ?? "Open terminal"}
variant="ghost"
@@ -305,6 +308,7 @@ export function WorkspaceTabs({
isLoading={pullRequestsLoading}
mode={mode}
onOpenCommit={onSelectedCommitHashChange}
onOpenTerminal={onOpenMergeRecoveryTerminal}
onSelectedPullRequestIdChange={
onSelectedPullRequestIdChange
}
@@ -399,6 +403,7 @@ export function WorkspaceTabs({
error={pullRequestsError}
isLoading={pullRequestsLoading}
onOpenCommit={onSelectedCommitHashChange}
onOpenTerminal={onOpenMergeRecoveryTerminal}
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
profiles={profiles}
project={project}
@@ -16,13 +16,18 @@ import {
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { MergePullRequestButton } from "./MergePullRequestButton";
import {
MergePullRequestButton,
type OpenMergeRecoveryTerminal,
} from "./MergePullRequestButton";
/** GitHub-style review state and actions rendered in the conversation flow. */
export function PullRequestReviewCard({
onOpenTerminal,
project,
pullRequest,
}: {
onOpenTerminal?: OpenMergeRecoveryTerminal;
project: Project;
pullRequest: ProjectPullRequest;
}) {
@@ -236,6 +241,7 @@ export function PullRequestReviewCard({
) : null}
{canMerge ? (
<MergePullRequestButton
onOpenTerminal={onOpenTerminal}
project={project}
pullRequest={pullRequest}
/>
+118 -7
View File
@@ -9,7 +9,7 @@ import type {
ProjectRepoSnapshot,
ProjectRepoSyncStatus,
} from "@/shared/api/types";
import { invokeTauri } from "@/shared/api/tauri";
import { invokeTauri, TauriInvokeError } from "@/shared/api/tauri";
type RawProjectRepoCommit = {
hash: string;
@@ -337,6 +337,34 @@ export async function openProjectTerminal(input: {
};
}
export async function openProjectMergeRecoveryTerminal(input: {
reposDir?: string | null;
projectDtag: string;
targetCloneUrl: string;
sourceCloneUrl: string;
targetBranch: string;
sourceBranch: string;
expectedCommit: string;
}): Promise<{
path: string;
cloned: boolean;
recoveryRef: string;
targetRef: string;
}> {
const result = await invokeTauri<{
path: string;
cloned: boolean;
recoveryRef: string;
targetRef: string;
}>("open_project_merge_recovery_terminal", {
input: {
...input,
reposDir: input.reposDir ?? null,
},
});
return result;
}
export async function pushProjectLocalRepository(input: {
reposDir?: string | null;
projectDtag: string;
@@ -405,6 +433,84 @@ type RawProjectRepoMergeResult = {
status_publication_error: string | null;
};
export type ProjectPullRequestMergeRecovery = {
action: "open_terminal";
targetBranch: string;
sourceBranch: string;
};
/** Machine-readable pull-request merge failure returned by the desktop shell. */
export class ProjectPullRequestMergeError extends Error {
readonly code: string;
readonly recovery: ProjectPullRequestMergeRecovery | null;
constructor(
code: string,
message: string,
recovery: ProjectPullRequestMergeRecovery | null,
) {
super(message);
this.name = "ProjectPullRequestMergeError";
this.code = code;
this.recovery = recovery;
}
}
function mergeErrorPayload(error: unknown): unknown {
const payload = error instanceof TauriInvokeError ? error.payload : error;
if (typeof payload !== "string") return payload;
try {
return JSON.parse(payload);
} catch {
return null;
}
}
/** Parse a structured native merge error without classifying generic failures. */
export function parseProjectPullRequestMergeError(
error: unknown,
): ProjectPullRequestMergeError | null {
const payload = mergeErrorPayload(error);
if (!payload || typeof payload !== "object") return null;
const candidate = payload as {
code?: unknown;
message?: unknown;
recovery?: unknown;
};
if (
typeof candidate.code !== "string" ||
typeof candidate.message !== "string"
) {
return null;
}
let recovery: ProjectPullRequestMergeRecovery | null = null;
if (candidate.recovery !== null && candidate.recovery !== undefined) {
if (typeof candidate.recovery !== "object") return null;
const value = candidate.recovery as {
action?: unknown;
sourceBranch?: unknown;
targetBranch?: unknown;
};
if (
value.action !== "open_terminal" ||
typeof value.targetBranch !== "string" ||
typeof value.sourceBranch !== "string"
) {
return null;
}
recovery = {
action: value.action,
sourceBranch: value.sourceBranch,
targetBranch: value.targetBranch,
};
}
return new ProjectPullRequestMergeError(
candidate.code,
candidate.message,
recovery,
);
}
export async function mergeProjectPullRequest(input: {
targetCloneUrl: string;
sourceCloneUrl: string;
@@ -417,12 +523,17 @@ export async function mergeProjectPullRequest(input: {
sourceBranch: string;
expectedCommit: string;
}): Promise<ProjectRepoMergeResult> {
const result = await invokeTauri<RawProjectRepoMergeResult>(
"merge_project_pull_request",
{
input,
},
);
let result: RawProjectRepoMergeResult;
try {
result = await invokeTauri<RawProjectRepoMergeResult>(
"merge_project_pull_request",
{
input,
},
);
} catch (error) {
throw parseProjectPullRequestMergeError(error) ?? error;
}
return {
message: result.message,
mergeCommit: result.merge_commit,
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseProjectPullRequestMergeError,
ProjectPullRequestMergeError,
} from "./projectGit.ts";
const CONFLICT = {
code: "merge_conflict",
message: "Pull request has merge conflicts.",
recovery: {
action: "open_terminal",
targetBranch: "main",
sourceBranch: "feature/demo",
},
};
test("parses structured merge conflict recovery metadata", () => {
const error = parseProjectPullRequestMergeError(CONFLICT);
assert.ok(error instanceof ProjectPullRequestMergeError);
assert.equal(error.code, "merge_conflict");
assert.deepEqual(error.recovery, CONFLICT.recovery);
});
test("parses JSON-serialized Tauri merge errors", () => {
const error = parseProjectPullRequestMergeError(JSON.stringify(CONFLICT));
assert.ok(error instanceof ProjectPullRequestMergeError);
assert.equal(error.message, "Pull request has merge conflicts.");
});
test("rejects malformed recovery metadata", () => {
assert.equal(
parseProjectPullRequestMergeError({
...CONFLICT,
recovery: { ...CONFLICT.recovery, targetBranch: null },
}),
null,
);
assert.equal(parseProjectPullRequestMergeError(new Error("offline")), null);
});
+15 -4
View File
@@ -241,13 +241,24 @@ type RawSetCanvasResult = {
event_id: string;
};
/** Error normalized from a rejected Tauri invocation with its wire payload. */
export class TauriInvokeError extends Error {
readonly payload: unknown;
constructor(message: string, payload: unknown) {
super(message);
this.name = "TauriInvokeError";
this.payload = payload;
}
}
function toTauriError(error: unknown): Error {
if (error instanceof Error) {
return error;
}
if (typeof error === "string") {
return new Error(error);
return new TauriInvokeError(error, error);
}
if (
@@ -256,13 +267,13 @@ function toTauriError(error: unknown): Error {
"message" in error &&
typeof error.message === "string"
) {
return new Error(error.message);
return new TauriInvokeError(error.message, error);
}
try {
return new Error(JSON.stringify(error));
return new TauriInvokeError(JSON.stringify(error), error);
} catch {
return new Error("Unknown Tauri error");
return new TauriInvokeError("Unknown Tauri error", error);
}
}
+29
View File
@@ -940,6 +940,16 @@ declare global {
}>;
/** Project event kinds rejected once, in order, to exercise retry flows. */
__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?: number[];
/** Structured merge error returned by the mock native merge command. */
__BUZZ_E2E_PROJECT_MERGE_ERROR__?: {
code: string;
message: string;
recovery: {
action: "open_terminal";
sourceBranch: string;
targetBranch: string;
} | null;
};
/** Overrides the first mock repository owner for delegated-owner tests. */
__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__?: string;
/** Project history kinds rejected with CLOSED for aggregate-query tests. */
@@ -9390,6 +9400,9 @@ export function maybeInstallE2eTauriMocks() {
"Only the repository owner or the owner of its managed agent can merge pull requests.",
);
}
if (window.__BUZZ_E2E_PROJECT_MERGE_ERROR__) {
throw window.__BUZZ_E2E_PROJECT_MERGE_ERROR__;
}
const mergeCommit = "abcdef0123456789abcdef0123456789abcdef01";
const statusEvent = createMockEvent(
KIND_GIT_STATUS_MERGED,
@@ -9431,6 +9444,22 @@ export function maybeInstallE2eTauriMocks() {
status_publication_error: statusPublicationError,
};
}
case "open_project_merge_recovery_terminal": {
const { input } = payload as {
input: { expectedCommit: string };
};
return {
path: "/tmp/buzz/REPOS/buzz",
cloned: false,
recoveryRef: `refs/buzz/merge-recovery/${input.expectedCommit}`,
targetRef: `refs/buzz/merge-recovery-target/${"f".repeat(40)}`,
};
}
case "open_project_terminal":
return {
path: "/tmp/buzz/REPOS/buzz",
cloned: false,
};
case "get_relay_ws_url":
return getRelayWsUrl(activeConfig);
case "get_default_relay_url":
@@ -4,6 +4,7 @@ import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/project-pr-review";
const RECOVERY_SHOTS = "test-results/project-pr-conflict-recovery";
const REVIEWER_AGENT_PUBKEY = "a".repeat(64);
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
@@ -267,6 +268,78 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
});
});
test("merge conflicts offer persistent terminal recovery", async ({ page }) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await openBuzzProject(page);
await page.evaluate(() => {
window.__BUZZ_E2E_PROJECT_MERGE_ERROR__ = {
code: "merge_conflict",
message: "Pull request has merge conflicts.",
recovery: {
action: "open_terminal",
sourceBranch: "feature",
targetBranch: "main",
},
};
});
await page.getByRole("tab", { name: "Pull Request" }).click();
const aliceRow = page
.getByTestId("project-pull-request-row")
.filter({ hasText: "alice" })
.first();
await aliceRow.getByRole("button", { name: /^#/ }).click();
await page.getByRole("button", { name: "Merge", exact: true }).click();
await page.getByTestId("merge-pull-request-confirm-button").click();
const recovery = page.getByTestId("merge-conflict-recovery");
await expect(recovery).toBeVisible();
await expect(
recovery.getByRole("button", { name: "Copy commands" }),
).toBeDisabled();
await waitForAnimations(page);
await recovery.screenshot({
path: `${RECOVERY_SHOTS}/01-merge-conflict.png`,
});
await recovery.getByRole("button", { name: "Resolve in Terminal" }).click();
await expect(
page.getByText("Recovery commit fetched and terminal opened."),
).toBeVisible();
await expect(
page.getByText("Recovery commit fetched and terminal opened."),
).toBeHidden({ timeout: 10_000 });
await expect(recovery).toContainText("git switch 'main'");
await expect(recovery).toContainText("git merge 'refs/buzz/merge-recovery/");
await expect(
recovery.getByRole("button", { name: "Copy commands" }),
).toBeEnabled();
await waitForAnimations(page);
await recovery.screenshot({
path: `${RECOVERY_SHOTS}/02-merge-conflict-prepared.png`,
});
await expect
.poll(() =>
page.evaluate(
() =>
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.find(
(entry) => entry.command === "open_project_merge_recovery_terminal",
) ?? null,
),
)
.toMatchObject({
command: "open_project_merge_recovery_terminal",
payload: {
input: {
expectedCommit: expect.any(String),
sourceBranch: "feature",
targetBranch: "main",
},
},
});
});
test("reviewer can leave a commit-scoped inline diff comment", async ({
page,
}) => {