diff --git a/Backend/src/core/mod.rs b/Backend/src/core/mod.rs index 7b901f35..235a3663 100644 --- a/Backend/src/core/mod.rs +++ b/Backend/src/core/mod.rs @@ -13,6 +13,8 @@ pub use self::models::OnEvent; use std::path::{Path, PathBuf}; +use crate::core::models::VcsCaps; + /// Error type returned by VCS backend operations. #[derive(thiserror::Error, Debug)] pub enum VcsError { @@ -33,6 +35,14 @@ pub enum VcsError { /// Backend-provided error message. msg: String, }, + /// The backend does not support the requested merge strategy. + #[error("unsupported merge strategy '{strategy}' for backend {backend}")] + UnsupportedStrategy { + /// Backend identifier. + backend: BackendId, + /// The strategy that was requested. + strategy: String, + }, } impl VcsError { @@ -47,6 +57,17 @@ impl VcsError { VcsError::Unsupported(backend) => format!("unsupported backend: {backend}"), VcsError::Io(e) => e.to_string(), VcsError::Backend { msg, .. } => msg.clone(), + VcsError::UnsupportedStrategy { strategy, .. } => { + format!("unsupported merge strategy '{strategy}'") + } + } + } + + /// Builds an unsupported strategy error for the given backend and strategy. + pub fn unsupported_strategy(backend: &BackendId, strategy: &str) -> Self { + VcsError::UnsupportedStrategy { + backend: backend.clone(), + strategy: strategy.to_string(), } } } @@ -87,6 +108,11 @@ pub trait Vcs: Send + Sync { /// Creates a commit from the provided paths and returns its id. fn commit(&self, message: &str, name: &str, email: &str, paths: &[PathBuf]) -> Result; + /// Returns the capabilities advertised by this backend. + fn caps(&self) -> Result { + Ok(VcsCaps::default()) + } + /// Creates a commit from the current index and returns its id. fn commit_index(&self, message: &str, name: &str, email: &str) -> Result; /// Returns status details for files and ahead/behind counts. @@ -139,8 +165,20 @@ pub trait Vcs: Send + Sync { fn rename_branch(&self, old: &str, new: &str) -> Result<()>; /// Merges a branch into the current branch. fn merge_into_current(&self, name: &str) -> Result<()>; - /// Merges a branch into the current branch with an optional message. - fn merge_into_current_with_message(&self, name: &str, message: Option<&str>) -> Result<()> { + /// Merges a branch into the current branch with an optional message and strategy. + fn merge_into_current_with_message( + &self, + name: &str, + message: Option<&str>, + strategy: Option<&str>, + ) -> Result<()> { + // Reject non-merge strategies by default — backends that support them + // override this method and advertise via caps. + if let Some(s) = strategy + && s != "merge" + { + return Err(VcsError::unsupported_strategy(&self.id(), s)); + } let _ = message; self.merge_into_current(name) } diff --git a/Backend/src/core/models.rs b/Backend/src/core/models.rs index 836bf745..8d66294e 100644 --- a/Backend/src/core/models.rs +++ b/Backend/src/core/models.rs @@ -236,6 +236,15 @@ pub struct HunkSelection { pub partial_hunks: std::collections::HashMap>, } +/// Describes the capabilities advertised by a VCS backend. +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct VcsCaps { + /// Merge strategies the backend supports (values like "merge", "squash", "rebase"). + /// Empty means only default merge is supported. + #[serde(default)] + pub merge_strategies: Vec, +} + /// Callback function type for handling VCS events. pub type OnEvent = Arc; diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index e46c3b6a..5ec69de2 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -419,6 +419,7 @@ fn build_invoke_handler() tauri_commands::vcs_launch_merge_tool, tauri_commands::vcs_delete_branch, tauri_commands::vcs_merge_branch, + tauri_commands::vcs_merge_strategies, tauri_commands::vcs_merge_context, tauri_commands::vcs_merge_abort, tauri_commands::vcs_merge_continue, diff --git a/Backend/src/plugin_runtime/node_instance/vcs.rs b/Backend/src/plugin_runtime/node_instance/vcs.rs index da52a43e..7499964f 100644 --- a/Backend/src/plugin_runtime/node_instance/vcs.rs +++ b/Backend/src/plugin_runtime/node_instance/vcs.rs @@ -280,9 +280,21 @@ impl NodePluginRuntimeInstance { self.rpc_call_unit(Methods::VCS_RENAME_BRANCH, params) } + /// Calls `vcs.get_caps` and returns the raw JSON response. + pub fn vcs_get_caps(&self) -> Result { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_GET_CAPS, params) + } + /// Calls `vcs.merge-into-current`. - pub fn vcs_merge_into_current(&self, name: &str, message: Option<&str>) -> Result<(), String> { - let params = self.session_params(json!({ "name": name, "message": message }))?; + pub fn vcs_merge_into_current( + &self, + name: &str, + message: Option<&str>, + strategy: Option<&str>, + ) -> Result<(), String> { + let params = + self.session_params(json!({ "name": name, "message": message, "strategy": strategy }))?; self.rpc_call_unit(Methods::VCS_MERGE_INTO_CURRENT, params) } diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index d8a70731..3f2a2390 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,6 +1,7 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use crate::core::models::VcsCaps; use crate::core::models::{ BranchItem, CommitItem, ConflictDetails, ConflictSide, DiffFileResult, LogQuery, OnEvent, StashItem, StatusPayload, @@ -285,15 +286,31 @@ impl Vcs for PluginVcsProxy { .map_err(|e| self.map_runtime_error(e)) } + fn caps(&self) -> VcsResult { + let response = self.runtime.vcs_get_caps().map_err(|e| VcsError::Backend { + backend: self.backend_id.clone(), + msg: e, + })?; + serde_json::from_value(response).map_err(|e| VcsError::Backend { + backend: self.backend_id.clone(), + msg: format!("failed to parse caps: {e}"), + }) + } + fn merge_into_current(&self, name: &str) -> VcsResult<()> { self.runtime - .vcs_merge_into_current(name, None) + .vcs_merge_into_current(name, None, None) .map_err(|e| self.map_runtime_error(e)) } - fn merge_into_current_with_message(&self, name: &str, message: Option<&str>) -> VcsResult<()> { + fn merge_into_current_with_message( + &self, + name: &str, + message: Option<&str>, + strategy: Option<&str>, + ) -> VcsResult<()> { self.runtime - .vcs_merge_into_current(name, message) + .vcs_merge_into_current(name, message, strategy) .map_err(|e| self.map_runtime_error(e)) } diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index 0880276f..a10e9e02 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -377,25 +377,71 @@ pub async fn vcs_rename_branch( .await } +#[tauri::command] +/// Returns the merge strategies advertised by the current VCS backend. +/// +/// An empty list means only the default merge strategy is available. +/// +/// # Parameters +/// - `state`: Shared application state. +/// +/// # Returns +/// - `Ok(Vec)` — supported strategy names (e.g. `"squash"`, `"rebase"`). +pub async fn vcs_merge_strategies(state: State<'_, AppState>) -> Result, String> { + let repo = current_repo_or_err(&state)?; + run_repo_task("vcs_merge_strategies", repo, move |repo| { + let caps = repo.inner().caps().map_err(|e| e.to_string())?; + Ok(caps.merge_strategies) + }) + .await +} + #[tauri::command] /// Merges a source branch into the current branch. /// /// # Parameters /// - `state`: Shared application state. /// - `name`: Source branch to merge. +/// - `strategy`: Optional merge strategy (`merge`, `squash`, or `rebase`). /// /// # Returns /// - `Ok(())` when merge succeeds. /// - `Err(String)` when validation or merge fails. -pub async fn vcs_merge_branch(state: State<'_, AppState>, name: String) -> Result<(), String> { +pub async fn vcs_merge_branch( + state: State<'_, AppState>, + name: String, + strategy: Option, +) -> Result<(), String> { let name = name.trim(); if name.is_empty() { return Err("Branch name cannot be empty".to_string()); } + // Validate strategy before dispatching + let validated_strategy: Option = match strategy.as_deref() { + None | Some("merge") => None, + Some(s) if s.trim().is_empty() => { + return Err("Merge strategy cannot be empty".to_string()); + } + Some("squash") | Some("rebase") => strategy, + Some(other) => { + return Err(format!( + "Unknown merge strategy '{other}'. Must be 'merge', 'squash', or 'rebase'." + )); + } + }; + let repo = current_repo_or_err(&state)?; let branch = name.to_string(); let template = backend_merge_message_template(&repo.id()); run_repo_task("vcs_merge_branch", repo, move |repo| { + // Backend-side capability guard: reject strategy if not in supported list + if let Some(ref strat) = validated_strategy { + let caps = repo.inner().caps().map_err(|e| e.to_string())?; + if !caps.merge_strategies.iter().any(|s| s == strat) { + return Err(format!("Backend does not support merge strategy '{strat}'")); + } + } + let vcs = repo.inner(); let target_branch = vcs .current_branch() @@ -437,8 +483,12 @@ pub async fn vcs_merge_branch(state: State<'_, AppState>, name: String) -> Resul )) }; - vcs.merge_into_current_with_message(&branch, message.as_deref()) - .map_err(|e| e.to_string()) + vcs.merge_into_current_with_message( + &branch, + message.as_deref(), + validated_strategy.as_deref(), + ) + .map_err(|e| e.to_string()) }) .await } diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 850d5a1e..7b0b219b 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -630,10 +630,10 @@ pub fn list_plugins_with_settings(state: State<'_, AppState>) -> Result).unwrap(); + rt.vcs_merge_into_current("feature", None::<&str>, None).unwrap(); } #[test] diff --git a/Backend/tests/plugin_runtime/vcs_proxy.rs b/Backend/tests/plugin_runtime/vcs_proxy.rs index 143bfe50..4810864a 100644 --- a/Backend/tests/plugin_runtime/vcs_proxy.rs +++ b/Backend/tests/plugin_runtime/vcs_proxy.rs @@ -212,7 +212,7 @@ fn proxy_merge_into_current_with_message_delegates() { let (proxy, rt) = mock_proxy(); rt.set_session_id(Some("s".into())); set_unit_response(&rt); - assert!(proxy.merge_into_current_with_message("feature", Some("msg")).is_ok()); + assert!(proxy.merge_into_current_with_message("feature", Some("msg"), None).is_ok()); } #[test] diff --git a/Backend/tests/tauri_commands/branches.rs b/Backend/tests/tauri_commands/branches.rs index 430746c8..52b667d9 100644 --- a/Backend/tests/tauri_commands/branches.rs +++ b/Backend/tests/tauri_commands/branches.rs @@ -170,6 +170,7 @@ fn build_vcs_branches_app() -> (tauri::App, Arc().expect("should deserialize"); + assert_eq!(ctx.get("in_progress").and_then(|v| v.as_bool()), Some(false), "should report not in progress"); } #[test] @@ -319,7 +323,7 @@ fn vcs_set_upstream_propagates_error() { let (app, _) = build_vcs_branches_app(); let wv = test_webview(&app); - let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "main", "upstream": "origin/main"})); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"branch": "main", "upstream": "origin/main"})); let res = invoke_cmd(&wv, "vcs_set_upstream", body); assert!(res.is_err(), "set upstream should fail (unsupported)"); } @@ -345,3 +349,43 @@ fn vcs_merge_branch_propagates_error() { let res = invoke_cmd(&wv, "vcs_merge_branch", body); assert!(res.is_err(), "merge should fail (unsupported)"); } + +#[test] +fn vcs_merge_branch_rejects_unsupported_strategy() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "develop", "strategy": "squash"})); + let res = invoke_cmd(&wv, "vcs_merge_branch", body); + assert!(res.is_err(), "squash should be rejected on non-Git backend"); + let err_str = format!("{:?}", res); + assert!(err_str.contains("does not support merge strategy"), "unexpected error: {err_str}"); +} + +#[test] +fn vcs_merge_branch_rejects_unknown_strategy() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "develop", "strategy": "xyz"})); + let res = invoke_cmd(&wv, "vcs_merge_branch", body); + assert!(res.is_err(), "unknown strategy should be rejected"); + let err_str = format!("{:?}", res); + assert!(err_str.contains("Unknown merge strategy"), "unexpected error: {err_str}"); +} + +#[test] +fn vcs_merge_strategies_returns_empty_for_test_backend() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({})); + let res = invoke_cmd(&wv, "vcs_merge_strategies", body); + assert!(res.is_ok(), "caps query should succeed: {:?}", res); + let value = res.unwrap(); + let data = value.deserialize::>().expect("should deserialize string list"); + assert!(data.is_empty(), "test backend should have no merge strategies"); +} diff --git a/Frontend/src/modals/merge-strategy.html b/Frontend/src/modals/merge-strategy.html new file mode 100644 index 00000000..dbea6a12 --- /dev/null +++ b/Frontend/src/modals/merge-strategy.html @@ -0,0 +1,29 @@ + diff --git a/Frontend/src/scripts/features/branches.ts b/Frontend/src/scripts/features/branches.ts index f902d38b..ba7d49e4 100644 --- a/Frontend/src/scripts/features/branches.ts +++ b/Frontend/src/scripts/features/branches.ts @@ -12,6 +12,7 @@ import { openRenameBranch } from './renameBranch'; import { openSetUpstream } from './setUpstream'; import { confirmDeleteBranch } from './deleteBranchConfirm'; import { buildCtxMenu, CtxItem } from '../lib/menu'; +import { promptMergeStrategy } from './mergeStrategy'; import { renderList, hydrateStatus } from './repo'; import { setTab } from '../ui/layout'; import type { ConflictDetails, FileStatus } from '../types'; @@ -212,8 +213,10 @@ export function bindBranchUI() { }}); items.push({ label: 'Merge into current…', action: async () => { if (name === cur) { notify('Cannot merge a branch into itself'); return; } - const ok = await confirmBool(`Merge '${name}' into '${cur}'?`); - if (!ok) return; + const strategies = await TAURI.invoke('vcs_merge_strategies').catch(() => []); + const hasAdvanced = strategies.some(s => s === 'squash' || s === 'rebase'); + const strategy = hasAdvanced ? await promptMergeStrategy(name, cur, strategies) : (await confirmBool(`Merge '${name}' into '${cur}'?`) ? 'merge' : null); + if (!strategy) return; const statusEl = document.getElementById('status'); const setBusy = (msg: string) => { @@ -225,7 +228,7 @@ export function bindBranchUI() { try { setBusy('Merging…'); - await TAURI.invoke('vcs_merge_branch', { name }); + await TAURI.invoke('vcs_merge_branch', { name, strategy }); clearBusy(); notify(`Merged branch '${name}' into '${cur}'`); await Promise.allSettled([renderList(), loadBranches()]); diff --git a/Frontend/src/scripts/features/mergeStrategy.ts b/Frontend/src/scripts/features/mergeStrategy.ts new file mode 100644 index 00000000..f929d570 --- /dev/null +++ b/Frontend/src/scripts/features/mergeStrategy.ts @@ -0,0 +1,72 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +import { closeModal, hydrate, openModal } from '../ui/modals'; + +let wired = false; +let pendingResolve: ((strategy: string | null) => void) | null = null; + +function resolvePending(strategy: string | null) { + if (!pendingResolve) return; + const resolve = pendingResolve; + pendingResolve = null; + resolve(strategy); +} + +function wireMergeStrategyModal() { + if (wired) return; + wired = true; + const modal = document.getElementById('merge-strategy-modal'); + if (!modal) return; + + const options = modal.querySelectorAll('.merge-strategy-option'); + for (const opt of options) { + const handler = () => { + const strategy = opt.getAttribute('data-strategy'); + resolvePending(strategy); + closeModal('merge-strategy-modal'); + }; + opt.addEventListener('click', handler); + opt.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handler(); + } + }); + } + + // Single persistent listener for all dismiss paths — never leaks + modal.addEventListener('modal:closed', () => resolvePending(null)); +} + +export function promptMergeStrategy( + branchName: string, + targetBranch: string, + supported: string[], +): Promise { + hydrate('merge-strategy-modal'); + wireMergeStrategyModal(); + + const modal = document.getElementById('merge-strategy-modal'); + const hintEl = document.getElementById('merge-strategy-hint'); + if (hintEl) { + hintEl.textContent = `Choose how to merge '${branchName}' into '${targetBranch}'.`; + } + + // Show only options for strategies the backend supports + const options = modal?.querySelectorAll('.merge-strategy-option'); + if (options) { + const supportedSet = new Set(supported); + for (const opt of options) { + const strat = opt.getAttribute('data-strategy'); + const visible = strat ? supportedSet.has(strat) : false; + opt.style.display = visible ? '' : 'none'; + } + } + + return new Promise((resolve) => { + const prev = pendingResolve; + pendingResolve = resolve; + if (prev) prev(null); + openModal('merge-strategy-modal'); + }); +} diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index 34066d29..1384376f 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -29,6 +29,7 @@ import { wireUpdate } from "../features/update"; import stashConfirmHtml from "@modals/stash-confirm.html?raw"; import { wireStashConfirm } from "../features/stashConfirm"; import mergeHtml from "@modals/merge.html?raw"; +import mergeStrategyHtml from "@modals/merge-strategy.html?raw"; import conflictsSummaryHtml from "@modals/conflicts-summary.html?raw"; import { wireSshKeys } from "../features/sshKeys"; import repoSwitchDrawerHtml from "@modals/repoSwitchDrawer.html?raw"; @@ -53,6 +54,7 @@ const FRAGMENTS: Record = { "update-modal": updateHtml, "stash-confirm-modal": stashConfirmHtml, "merge-modal": mergeHtml, + "merge-strategy-modal": mergeStrategyHtml, "conflicts-summary-modal": conflictsSummaryHtml, "error-modal": errorHtml, }; diff --git a/Frontend/src/styles/index.css b/Frontend/src/styles/index.css index ddcc187c..ac2975bf 100644 --- a/Frontend/src/styles/index.css +++ b/Frontend/src/styles/index.css @@ -17,6 +17,7 @@ @import "./modal/confirm.css"; @import "./modal/stash-confirm.css"; @import "./modal/merge.css"; +@import "./modal/merge-strategy.css"; @import "./modal/ssh-keys.css"; @import "./modal/delete-branch.css"; @import "./modal/repo-switch-drawer.css"; diff --git a/Frontend/src/styles/modal/merge-strategy.css b/Frontend/src/styles/modal/merge-strategy.css new file mode 100644 index 00000000..dce88fbf --- /dev/null +++ b/Frontend/src/styles/modal/merge-strategy.css @@ -0,0 +1,44 @@ +/* src/styles/modal/merge-strategy.css */ + +#merge-strategy-modal .dialog.sheet { + width: clamp(380px, 88vw, 480px); + max-height: min(80vh, 400px); +} + +#merge-strategy-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; +} + +.merge-strategy-option { + cursor: pointer; + border: 1px solid var(--border, #ddd); + border-radius: 8px; + padding: 12px 16px; + transition: background-color 0.15s, border-color 0.15s; + user-select: none; +} + +.merge-strategy-option:hover { + background-color: var(--hover-bg, rgba(0,0,0,0.04)); + border-color: var(--accent, #4a90d9); +} + +.merge-strategy-option:focus-visible { + outline: 2px solid var(--accent, #4a90d9); + outline-offset: 2px; +} + +.merge-strategy-option strong { + display: block; + font-size: 14px; + margin-bottom: 2px; +} + +.merge-strategy-option .hint { + margin: 0; + font-size: 12px; + opacity: 0.75; +} diff --git a/Frontend/tests/scripts/features/branches.test.ts b/Frontend/tests/scripts/features/branches.test.ts index 2bf6d13d..fbfdb1d3 100644 --- a/Frontend/tests/scripts/features/branches.test.ts +++ b/Frontend/tests/scripts/features/branches.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockInvoke = vi.fn(); const mockConfirmBool = vi.fn(); +const mockPromptMergeStrategy = vi.fn(); const mockNotify = vi.fn(); const mockRefreshOverlayScrollbarsFor = vi.fn(); const mockOpenModal = vi.fn(); @@ -49,6 +50,12 @@ vi.mock('@scripts/state/state', () => ({ vi.mock('@scripts/ui/modals', () => ({ openModal: mockOpenModal, + closeModal: vi.fn(), + hydrate: vi.fn(), +})); + +vi.mock('@scripts/features/mergeStrategy', () => ({ + promptMergeStrategy: mockPromptMergeStrategy, })); vi.mock('@scripts/features/renameBranch', () => ({ @@ -386,88 +393,96 @@ describe('bindBranchUI', () => { expect(items[0].label).toBe('Checkout'); }); - it('merge into current', async () => { - mockConfirmBool.mockResolvedValue(true); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockInvoke.mockResolvedValueOnce(undefined); - const { bindBranchUI } = await import('@scripts/features/branches'); - bindBranchUI(); - await openPopover(1); - const items = await triggerContextMenu(); - await items[1].action(); +it('merge into current', async () => { + mockPromptMergeStrategy.mockResolvedValue('merge'); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(['merge', 'squash', 'rebase']); // vcs_merge_strategies + mockInvoke.mockResolvedValueOnce(undefined); // vcs_merge_branch - expect(mockConfirmBool).toHaveBeenCalledWith("Merge 'feature' into 'main'?"); - expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_branch', { name: 'feature' }); - expect(mockNotify).toHaveBeenCalledWith("Merged branch 'feature' into 'main'"); - }); + const { bindBranchUI } = await import('@scripts/features/branches'); + bindBranchUI(); + await openPopover(1); - it('merge cancelled by user', async () => { - mockConfirmBool.mockResolvedValue(false); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + const items = await triggerContextMenu(); + await items[1].action(); - const { bindBranchUI } = await import('@scripts/features/branches'); - bindBranchUI(); - await openPopover(1); + expect(mockPromptMergeStrategy).toHaveBeenCalledWith('feature', 'main', ['merge', 'squash', 'rebase']); + expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_branch', { name: 'feature', strategy: 'merge' }); + expect(mockNotify).toHaveBeenCalledWith("Merged branch 'feature' into 'main'"); +}); - const items = await triggerContextMenu(); - await items[1].action(); +it('merge cancelled by user', async () => { + mockPromptMergeStrategy.mockResolvedValue(null); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(['merge', 'squash', 'rebase']); // vcs_merge_strategies - expect(mockInvoke).not.toHaveBeenCalledWith('vcs_merge_branch', expect.anything()); - }); + const { bindBranchUI } = await import('@scripts/features/branches'); + bindBranchUI(); + await openPopover(1); - it('merge into self shows notify', async () => { - mockState.branch = 'feature'; - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + const items = await triggerContextMenu(); + await items[1].action(); - const { bindBranchUI } = await import('@scripts/features/branches'); - bindBranchUI(); - await openPopover(1); + expect(mockInvoke).not.toHaveBeenCalledWith('vcs_merge_branch', expect.anything()); +}); - const items = await triggerContextMenu(); - await items[1].action(); +it('merge into self shows notify', async () => { + mockState.branch = 'feature'; + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - expect(mockNotify).toHaveBeenCalledWith('Cannot merge a branch into itself'); - }); + const { bindBranchUI } = await import('@scripts/features/branches'); + bindBranchUI(); + await openPopover(1); - it('merge detects conflicts', async () => { - mockConfirmBool.mockResolvedValue(true); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockInvoke.mockRejectedValueOnce(new Error('Automatic merge failed; fix conflicts and then commit')); - mockState.files = [{ path: 'file.txt' }]; + const items = await triggerContextMenu(); + await items[1].action(); - const { bindBranchUI } = await import('@scripts/features/branches'); - bindBranchUI(); - await openPopover(1); + expect(mockNotify).toHaveBeenCalledWith('Cannot merge a branch into itself'); +}); - const items = await triggerContextMenu(); - await items[1].action(); +it('merge detects conflicts', async () => { + mockPromptMergeStrategy.mockResolvedValue('merge'); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(['merge', 'squash', 'rebase']); // vcs_merge_strategies + mockInvoke.mockRejectedValueOnce(new Error('Automatic merge failed; fix conflicts and then commit')); + mockState.files = [{ path: 'file.txt' }]; - expect(mockNotify).toHaveBeenCalledWith('Merge conflict detected'); - expect(mockSetTab).toHaveBeenCalledWith('changes'); - }); + const { bindBranchUI } = await import('@scripts/features/branches'); + bindBranchUI(); + await openPopover(1); - it('merge shows generic error', async () => { - mockConfirmBool.mockResolvedValue(true); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); - mockInvoke.mockRejectedValueOnce(new Error('some other error')); + const items = await triggerContextMenu(); + await items[1].action(); - const { bindBranchUI } = await import('@scripts/features/branches'); - bindBranchUI(); - await openPopover(1); + expect(mockNotify).toHaveBeenCalledWith('Merge conflict detected'); + expect(mockSetTab).toHaveBeenCalledWith('changes'); +}); - const items = await triggerContextMenu(); - await items[1].action(); +it('merge shows generic error', async () => { + mockPromptMergeStrategy.mockResolvedValue('merge'); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(['merge', 'squash', 'rebase']); // vcs_merge_strategies + mockInvoke.mockRejectedValueOnce(new Error('some other error')); + + const { bindBranchUI } = await import('@scripts/features/branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); - expect(mockNotify).toHaveBeenCalledWith('Merge failed: Error: some other error'); + expect(mockNotify).toHaveBeenCalledWith('Merge failed: Error: some other error'); }); + + it('set upstream for local branch', async () => { mockLoadBranches([ { name: 'feature', kind: { type: 'local' } }, diff --git a/Frontend/tests/scripts/ui/modals.test.ts b/Frontend/tests/scripts/ui/modals.test.ts index a3d165ae..9c43db63 100644 --- a/Frontend/tests/scripts/ui/modals.test.ts +++ b/Frontend/tests/scripts/ui/modals.test.ts @@ -722,6 +722,11 @@ describe('hydrate wiring for specific modals', () => { expect(() => hydrate('merge-modal')).not.toThrow(); }); + it('hydrates merge-strategy-modal without throwing', async () => { + const { hydrate } = await import('@scripts/ui/modals'); + expect(() => hydrate('merge-strategy-modal')).not.toThrow(); + }); + it('hydrates conflicts-summary-modal without throwing', async () => { const { hydrate } = await import('@scripts/ui/modals'); expect(() => hydrate('conflicts-summary-modal')).not.toThrow();