Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions Backend/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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(),
}
}
}
Expand Down Expand Up @@ -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<String>;
/// Returns the capabilities advertised by this backend.
fn caps(&self) -> Result<VcsCaps> {
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<String>;
/// Returns status details for files and ahead/behind counts.
Expand Down Expand Up @@ -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)
}
Expand Down
9 changes: 9 additions & 0 deletions Backend/src/core/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@ pub struct HunkSelection {
pub partial_hunks: std::collections::HashMap<usize, Vec<usize>>,
}

/// 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<String>,
}

/// Callback function type for handling VCS events.
pub type OnEvent = Arc<dyn Fn(VcsEvent) + Send + Sync + 'static>;

Expand Down
1 change: 1 addition & 0 deletions Backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ fn build_invoke_handler<R: tauri::Runtime>()
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,
Expand Down
16 changes: 14 additions & 2 deletions Backend/src/plugin_runtime/node_instance/vcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, String> {
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)
}

Expand Down
23 changes: 20 additions & 3 deletions Backend/src/plugin_runtime/vcs_proxy.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -285,15 +286,31 @@ impl Vcs for PluginVcsProxy {
.map_err(|e| self.map_runtime_error(e))
}

fn caps(&self) -> VcsResult<VcsCaps> {
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))
}

Expand Down
56 changes: 53 additions & 3 deletions Backend/src/tauri_commands/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)` — supported strategy names (e.g. `"squash"`, `"rebase"`).
pub async fn vcs_merge_strategies(state: State<'_, AppState>) -> Result<Vec<String>, 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<String>,
) -> 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<String> = 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()
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 4 additions & 4 deletions Backend/src/tauri_commands/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,10 +630,10 @@ pub fn list_plugins_with_settings(state: State<'_, AppState>) -> Result<Vec<Stri
continue;
}

if let Ok((defaults, _)) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id) {
if !defaults.is_empty() {
out.push(plugin_id);
}
if let Ok((defaults, _)) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)
&& !defaults.is_empty()
{
out.push(plugin_id);
}
}

Expand Down
6 changes: 3 additions & 3 deletions Backend/tests/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ fn vcs_revert_commit_returns_unsupported_by_default() {
fn vcs_merge_into_current_with_message_delegates_to_merge_into_current() {
let vcs = DummyVcs::new("test");
// Should not error even with a message
assert!(vcs.merge_into_current_with_message("feature", Some("auto-merge")).is_ok());
// Should work with None message too
assert!(vcs.merge_into_current_with_message("feature", None).is_ok());
assert!(vcs.merge_into_current_with_message("feature", Some("auto-merge"), None).is_ok());
// Should work with None message and strategy too
assert!(vcs.merge_into_current_with_message("feature", None, None).is_ok());
}
4 changes: 2 additions & 2 deletions Backend/tests/plugin_runtime/node_instance/vcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,15 @@ fn vcs_merge_into_current_works() {
let rt = test_runtime();
*rt.vcs_session_id.lock() = Some("s".into());
mock_response(&rt, Value::Null);
rt.vcs_merge_into_current("feature", Some("merge msg")).unwrap();
rt.vcs_merge_into_current("feature", Some("merge msg"), None).unwrap();
}

#[test]
fn vcs_merge_into_current_without_message_works() {
let rt = test_runtime();
*rt.vcs_session_id.lock() = Some("s".into());
mock_response(&rt, Value::Null);
rt.vcs_merge_into_current("feature", None::<&str>).unwrap();
rt.vcs_merge_into_current("feature", None::<&str>, None).unwrap();
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion Backend/tests/plugin_runtime/vcs_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
50 changes: 47 additions & 3 deletions Backend/tests/tauri_commands/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ fn build_vcs_branches_app() -> (tauri::App<tauri::test::MockRuntime>, Arc<TestVc
super::vcs_merge_continue,
super::vcs_set_upstream,
super::vcs_merge_branch,
super::vcs_merge_strategies,
])
.build(mock_context(noop_assets()))
.expect("build branches test app");
Expand Down Expand Up @@ -278,7 +279,7 @@ fn vcs_create_branch_propagates_error() {
let (app, _) = build_vcs_branches_app();
let wv = test_webview(&app);

let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "new-branch", "base": "main"}));
let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "new-branch", "from": "main"}));
let res = invoke_cmd(&wv, "vcs_create_branch", body);
assert!(res.is_err(), "create should fail (unsupported)");
}
Expand Down Expand Up @@ -310,7 +311,10 @@ fn vcs_merge_context_fails_silently_when_not_in_progress() {
let wv = test_webview(&app);

let res = invoke_cmd(&wv, "vcs_merge_context", tauri::ipc::InvokeBody::default());
let _ = res;
assert!(res.is_ok(), "merge context should succeed even when not in progress");
let value = res.unwrap();
let ctx = value.deserialize::<serde_json::Value>().expect("should deserialize");
assert_eq!(ctx.get("in_progress").and_then(|v| v.as_bool()), Some(false), "should report not in progress");
}

#[test]
Expand All @@ -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)");
}
Expand All @@ -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::<Vec<String>>().expect("should deserialize string list");
assert!(data.is_empty(), "test backend should have no merge strategies");
}
Loading
Loading