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
3 changes: 3 additions & 0 deletions Backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ fn build_invoke_handler<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -
tauri_commands::git_launch_merge_tool,
tauri_commands::git_delete_branch,
tauri_commands::git_merge_branch,
tauri_commands::git_merge_context,
tauri_commands::git_merge_abort,
tauri_commands::git_merge_continue,
tauri_commands::git_set_upstream,
tauri_commands::git_diff_commit,
tauri_commands::commit_changes,
Expand Down
6 changes: 6 additions & 0 deletions Backend/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ pub struct Git {
#[serde(default)] pub fetch_on_focus: bool,
#[serde(default)] pub allow_hooks: HookPolicy,
#[serde(default)] pub respect_core_autocrlf: bool,
/// Commit message template used for automatic merge commits.
///
/// If empty, Git's default merge message is used.
/// Supported placeholders: {branch:source}, {branch:target}, {repo:name}, {repo:username}
#[serde(default)] pub merge_commit_message_template: String,
}
impl Default for Git {
fn default() -> Self {
Expand All @@ -87,6 +92,7 @@ impl Default for Git {
fetch_on_focus: true,
allow_hooks: HookPolicy::Ask,
respect_core_autocrlf: true,
merge_commit_message_template: "Merged branch '{branch:source}' into '{branch:target}'".into(),
}
}
}
Expand Down
141 changes: 139 additions & 2 deletions Backend/src/tauri_commands/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,66 @@ use crate::state::AppState;

use super::{current_repo_or_err, run_repo_task};

fn repo_username_from_origin(url: &str) -> Option<String> {
let u = url.trim();
if u.is_empty() {
return None;
}

// https://host/owner/repo(.git)
if let Some(rest) = u.strip_prefix("https://").or_else(|| u.strip_prefix("http://")) {
let path = rest.splitn(2, '/').nth(1).unwrap_or("");
let mut seg = path.split('/').filter(|s| !s.is_empty());
let owner = seg.next()?;
return Some(owner.to_string());
}

// git@host:owner/repo(.git)
if let Some(rest) = u.splitn(2, ':').nth(1) {
let mut seg = rest.split('/').filter(|s| !s.is_empty());
let owner = seg.next()?;
return Some(owner.to_string());
}

None
}

fn repo_name_from_origin(url: &str) -> Option<String> {
let u = url.trim();
if u.is_empty() {
return None;
}

// https://host/owner/repo(.git)
if let Some(rest) = u.strip_prefix("https://").or_else(|| u.strip_prefix("http://")) {
let path = rest.splitn(2, '/').nth(1).unwrap_or("");
let last = path.split('/').filter(|s| !s.is_empty()).last()?;
return Some(last.strip_suffix(".git").unwrap_or(last).to_string());
}

// git@host:owner/repo(.git)
if let Some(rest) = u.splitn(2, ':').nth(1) {
let last = rest.split('/').filter(|s| !s.is_empty()).last()?;
return Some(last.strip_suffix(".git").unwrap_or(last).to_string());
}

None
}

fn apply_merge_template(
template: &str,
source_branch: &str,
target_branch: &str,
repo_name: &str,
repo_username: &str,
) -> String {
template
.replace("{branch:source}", source_branch)
.replace("{branch:target}", target_branch)
.replace("{repo:name}", repo_name)
.replace("{repo:username}", repo_username)
}

#[tauri::command]
pub async fn git_list_branches(state: State<'_, AppState>) -> Result<Vec<BranchItem>, String> {
let repo = current_repo_or_err(&state)?;
Expand Down Expand Up @@ -212,14 +272,91 @@ pub async fn git_merge_branch(state: State<'_, AppState>, name: String) -> Resul
}
let repo = current_repo_or_err(&state)?;
let branch = name.to_string();
let template = state.with_config(|cfg| cfg.git.merge_commit_message_template.clone());
run_repo_task("git_merge_branch", repo, move |repo| {
repo.inner()
.merge_into_current(&branch)
let vcs = repo.inner();
let target_branch = vcs
.current_branch()
.ok()
.flatten()
.unwrap_or_else(|| "HEAD".to_string());

let workdir_name = vcs
.workdir()
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("repo")
.to_string();

let (repo_username, repo_name) = match vcs.list_remotes() {
Ok(remotes) => {
let origin = remotes
.iter()
.find(|(n, _)| n == "origin")
.map(|(_, url)| url.as_str())
.unwrap_or("");
let username = repo_username_from_origin(origin).unwrap_or_default();
let name = repo_name_from_origin(origin).unwrap_or_else(|| workdir_name.clone());
(username, name)
}
Err(_) => (String::new(), workdir_name.clone()),
};

let msg = template.trim();
let message = if msg.is_empty() {
None
} else {
Some(apply_merge_template(
msg,
&branch,
&target_branch,
&repo_name,
&repo_username,
))
};

vcs.merge_into_current_with_message(&branch, message.as_deref())
.map_err(|e| e.to_string())
})
.await
}

#[derive(serde::Serialize)]
pub struct MergeContext {
pub in_progress: bool,
}

#[tauri::command]
pub async fn git_merge_context(state: State<'_, AppState>) -> Result<MergeContext, String> {
let repo = current_repo_or_err(&state)?;
run_repo_task("git_merge_context", repo, move |repo| {
let in_progress = repo
.inner()
.merge_in_progress()
.unwrap_or(false);
Ok(MergeContext { in_progress })
})
.await
}

#[tauri::command]
pub async fn git_merge_abort(state: State<'_, AppState>) -> Result<(), String> {
let repo = current_repo_or_err(&state)?;
run_repo_task("git_merge_abort", repo, move |repo| {
repo.inner().merge_abort().map_err(|e| e.to_string())
})
.await
}

#[tauri::command]
pub async fn git_merge_continue(state: State<'_, AppState>) -> Result<(), String> {
let repo = current_repo_or_err(&state)?;
run_repo_task("git_merge_continue", repo, move |repo| {
repo.inner().merge_continue().map_err(|e| e.to_string())
})
.await
}

#[tauri::command]
pub async fn git_set_upstream(
state: State<'_, AppState>,
Expand Down
29 changes: 29 additions & 0 deletions Frontend/src/modals/conflicts-summary.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="modal" id="conflicts-summary-modal" aria-hidden="true">
<div class="backdrop" data-close="true"></div>
<div class="dialog sheet" role="dialog" aria-modal="true" aria-labelledby="conflicts-summary-title">
<div class="sheet-head">
<h3 id="conflicts-summary-title" style="margin:0">Resolve conflicts</h3>
<button class="icon close" data-close aria-label="Close">✕</button>
</div>

<section class="sheet-body" style="display:block">
<div class="group" style="margin-bottom:14px">
<div id="conflicts-summary-subtitle" style="opacity:.85"></div>
</div>

<div class="group">
<div id="conflicts-summary-count" style="font-weight:600; margin-bottom:10px"></div>
<div id="conflicts-summary-list" class="list"></div>
</div>

<div class="group" style="margin-top:14px; opacity:.8">
<div>Resolve each file, then commit to finish.</div>
</div>
</section>

<div class="sheet-foot" style="display:flex; gap:10px; justify-content:flex-end">
<button class="btn" id="conflicts-abort" hidden>Abort merge</button>
<button class="btn primary" id="conflicts-continue" hidden>Commit merge</button>
</div>
</div>
</div>
7 changes: 7 additions & 0 deletions Frontend/src/modals/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ <h3 id="settings-title" style="margin:0">Settings</h3>
</select>
</div>

<div class="group">
<label for="set-merge-message-template">Merge commit message template
<span class="help-tip" title="Leave blank to use Git's default merge message. Placeholders: {branch:source}, {branch:target}, {repo:name}, {repo:username}.">?</span>
</label>
<input id="set-merge-message-template" type="text" placeholder="Merged branch '{branch:source}' into '{branch:target}'" />
</div>



<div class="group">
Expand Down
26 changes: 24 additions & 2 deletions Frontend/src/scripts/features/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { openRenameBranch } from './renameBranch';
import { openSetUpstream } from './setUpstream';
import { buildCtxMenu, CtxItem } from '../lib/menu';
import { renderList, hydrateCommits, hydrateStatus } from './repo';
import { setTab } from '../ui/layout';
import type { ConflictDetails, FileStatus } from '../types';
import { openConflictsSummary } from './conflicts';

type Branch = { name: string; current?: boolean; kind?: { type?: string; remote?: string } };

Expand Down Expand Up @@ -142,8 +145,27 @@ export function bindBranchUI() {
if (name === cur) { notify('Cannot merge a branch into itself'); return; }
const ok = window.confirm(`Merge '${name}' into '${cur}'?`);
if (!ok) return;
try { if (TAURI.has) await TAURI.invoke('git_merge_branch', { name }); notify(`Merged '${name}' into '${cur}'`); await Promise.allSettled([renderList(), loadBranches()]); }
catch { notify('Merge failed'); }
try {
if (TAURI.has) await TAURI.invoke('git_merge_branch', { name });
notify(`Merged branch '${name}' into '${cur}'`);
await Promise.allSettled([renderList(), loadBranches()]);
} catch (e) {
const msg = String(e || '');
const looksLikeConflict =
/CONFLICT/i.test(msg) ||
/Automatic merge failed/i.test(msg) ||
/fix conflicts and then commit/i.test(msg);

if (looksLikeConflict) {
notify('Merge conflict detected');
await hydrateStatus();
setTab('changes');
await openConflictsSummary((state.files || []) as FileStatus[]);
return;
}

notify(`Merge failed${msg ? `: ${msg}` : ''}`);
}
}});
if (kind !== 'remote') {
items.push({ label: '---' });
Expand Down
Loading
Loading