diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8148ca8..c4632e7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(cargo build *)", "Bash(npm run *)", - "Bash(kill %1)" + "Bash(kill %1)", + "Bash(perl -0pi -e 's/\\\\{line \\\\|\\\\| \" \"\\\\}/{displayLine\\(line\\) || \" \"}/g' src/App.svelte)" ] } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index fd1d776..aeced7c 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -82,6 +82,15 @@ pub struct GitCommitComparison { pub patch: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConflictFile { + pub path: String, + pub content: String, + pub ours: Option, + pub theirs: Option, + pub base: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitRepositoryFile { pub path: String, @@ -260,8 +269,39 @@ pub fn merge_branch(path: String, branch: String) -> Result { return Err("Branch-Name darf nicht leer sein.".to_string()); } - run_git(&repo, ["merge", "--no-edit", branch])?; - status_for_repo(&repo) + let output = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["merge", "--no-edit", branch]) + .output() + .map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; + + if output.status.success() { + return status_for_repo(&repo); + } + + // A merge that stops on conflicts leaves unmerged paths in the work tree. + // Surface those through the status so the UI can offer conflict resolution + // instead of treating the conflict as a hard error. + let status = status_for_repo(&repo)?; + if status.files.iter().any(|file| { + matches!(file.staged, Some(FileStatusKind::Conflicted)) + || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) + }) { + return Ok(status); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = if !stderr.trim().is_empty() { + stderr.trim() + } else if !stdout.trim().is_empty() { + stdout.trim() + } else { + "unbekannter Fehler" + }; + + Err(format!("Merge fehlgeschlagen: {details}")) } #[tauri::command] @@ -435,6 +475,56 @@ pub fn diff_file_against_working_tree( }) } +#[tauri::command] +pub fn read_conflict(path: String, file: String) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + + let content = std::fs::read_to_string(repo.join(&file)) + .map_err(|err| format!("Konfliktdatei konnte nicht gelesen werden: {err}"))?; + + Ok(ConflictFile { + base: read_index_stage(&repo, 1, &file), + ours: read_index_stage(&repo, 2, &file), + theirs: read_index_stage(&repo, 3, &file), + path: file, + content, + }) +} + +#[tauri::command] +pub fn resolve_conflict(path: String, file: String, content: String) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + + let target = repo.join(&file); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("Verzeichnis konnte nicht erstellt werden: {err}"))?; + } + std::fs::write(&target, content) + .map_err(|err| format!("Konfliktdatei konnte nicht geschrieben werden: {err}"))?; + + run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?; + status_for_repo(&repo) +} + +fn read_index_stage(repo: &Path, stage: u8, file: &str) -> Option { + let spec = format!(":{stage}:{file}"); + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["show", spec.as_str()]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + None + } +} + fn short_hash(hash: &str) -> String { hash.chars().take(7).collect() } @@ -1505,6 +1595,57 @@ mod tests { assert!(comparison.patch.contains("working tree change")); } + #[test] + fn read_and_resolve_conflict_round_trip() { + let repo = init_temp_repo("resolve_conflict"); + fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); + run_git_test(&repo.path, ["add", "file.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); + let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + + run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); + fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change"); + run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]); + + run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); + fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change"); + run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]); + + // The merge is expected to fail with a conflict, so run git directly. + let _ = Command::new("git") + .arg("-C") + .arg(&repo.path) + .args(["merge", "--no-edit", "feature"]) + .output() + .expect("git merge should start"); + + let conflict = + read_conflict(repo.path.to_string_lossy().to_string(), "file.txt".to_string()).unwrap(); + assert_eq!( + conflict.ours.unwrap().replace("\r\n", "\n"), + "ours change\n" + ); + assert_eq!( + conflict.theirs.unwrap().replace("\r\n", "\n"), + "theirs change\n" + ); + assert!(conflict.content.contains("<<<<<<<")); + + let status = resolve_conflict( + repo.path.to_string_lossy().to_string(), + "file.txt".to_string(), + "resolved\n".to_string(), + ) + .unwrap(); + + assert!(!status.files.iter().any(|file| { + matches!(file.staged, Some(FileStatusKind::Conflicted)) + || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) + })); + let contents = fs::read_to_string(repo.path.join("file.txt")).unwrap(); + assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); + } + #[test] fn restore_staged_added_file_removes_it_from_index_and_worktree() { let repo = init_temp_repo("restore_staged_added_file"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a83f5da..96638f0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,8 +5,8 @@ mod git; use git::{ checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status, list_branches, list_commits, list_file_history, list_repository_files, merge_branch, - open_repository, pull, push, restore_file_from_commit, restore_files, restore_to_commit, - stage_files, unstage_files, + open_repository, pull, push, read_conflict, resolve_conflict, restore_file_from_commit, + restore_files, restore_to_commit, stage_files, unstage_files, }; fn main() { @@ -29,7 +29,9 @@ fn main() { list_repository_files, list_file_history, compare_commits, - diff_file_against_working_tree + diff_file_against_working_tree, + read_conflict, + resolve_conflict ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.svelte b/src/App.svelte index 632babf..5efa024 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -35,6 +35,8 @@ openRepository, pull, push, + readConflict, + resolveConflict, restoreFileFromCommit, restoreFiles, restoreToCommit, @@ -42,6 +44,7 @@ unstageFiles, } from "./lib/git"; import type { + ConflictFile, FileStatusKind, GitBranch as GitBranchInfo, GitCommit, @@ -65,6 +68,12 @@ children: ExplorerNode[]; } + type ConflictChoice = "ours" | "theirs" | "both-ot" | "both-to"; + + type ConflictPart = + | { kind: "text"; lines: string[] } + | { kind: "conflict"; index: number; oursLines: string[]; theirsLines: string[] }; + let repoPath = ""; let activeRepoPath = ""; let status: GitStatus | null = null; @@ -83,6 +92,13 @@ let comparison: GitCommitComparison | null = null; let compareDialogOpen = false; let selectedDiffPath = ""; + let resolveDialogOpen = false; + let conflictTarget = ""; + let conflict: ConflictFile | null = null; + let resolveContent = ""; + let conflictParts: ConflictPart[] = []; + let conflictChoices: (ConflictChoice | null)[] = []; + let manualMode = false; $: isBusy = operation.length > 0; $: hasRepository = activeRepoPath.length > 0 && status !== null; @@ -96,6 +112,18 @@ compareTo.length > 0 && compareFrom !== compareTo && !isBusy; + $: conflictedFiles = changedFiles.filter( + (file) => file.staged === "conflicted" || file.unstaged === "conflicted", + ); + $: hasConflicts = conflictedFiles.length > 0; + $: conflictRegionCount = conflictParts.filter((part) => part.kind === "conflict").length; + $: unresolvedCount = manualMode + ? 0 + : conflictChoices.filter((choice) => choice == null).length; + $: resolvedContent = manualMode + ? resolveContent + : buildResolution(conflictParts, conflictChoices); + $: resolveHasMarkers = /^<{7}/m.test(resolvedContent) || /^>{7}/m.test(resolvedContent); $: diffByPath = comparison ? buildDiffByPath(comparison.patch) : new Map(); $: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null; $: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : ""; @@ -353,6 +381,13 @@ comparison = null; compareDialogOpen = false; selectedDiffPath = ""; + resolveDialogOpen = false; + conflictTarget = ""; + conflict = null; + resolveContent = ""; + conflictParts = []; + conflictChoices = []; + manualMode = false; await refreshBranchList(activeRepoPath); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); @@ -601,6 +636,215 @@ } } + async function loadConflict(file: string) { + conflictTarget = file; + conflict = await readConflict(activeRepoPath, file); + conflictParts = parseConflicts(conflict.content); + conflictChoices = conflictParts.filter((part) => part.kind === "conflict").map(() => null); + manualMode = false; + resolveContent = conflict.content; + } + + function parseConflicts(content: string): ConflictPart[] { + const lines = content.split("\n"); + const parts: ConflictPart[] = []; + let textLines: string[] = []; + let regionIndex = 0; + let index = 0; + + const flushText = () => { + if (textLines.length > 0) { + parts.push({ kind: "text", lines: textLines }); + textLines = []; + } + }; + + while (index < lines.length) { + const line = lines[index]; + + if (line.startsWith("<<<<<<<")) { + flushText(); + index += 1; + + const oursLines: string[] = []; + while ( + index < lines.length && + !lines[index].startsWith("=======") && + !lines[index].startsWith("|||||||") + ) { + oursLines.push(lines[index]); + index += 1; + } + + // Skip the diff3 base section (||||||| ... =======) if present. + if (index < lines.length && lines[index].startsWith("|||||||")) { + index += 1; + while (index < lines.length && !lines[index].startsWith("=======")) { + index += 1; + } + } + + if (index < lines.length && lines[index].startsWith("=======")) { + index += 1; + } + + const theirsLines: string[] = []; + while (index < lines.length && !lines[index].startsWith(">>>>>>>")) { + theirsLines.push(lines[index]); + index += 1; + } + + if (index < lines.length && lines[index].startsWith(">>>>>>>")) { + index += 1; + } + + parts.push({ kind: "conflict", index: regionIndex, oursLines, theirsLines }); + regionIndex += 1; + } else { + textLines.push(line); + index += 1; + } + } + + flushText(); + return parts; + } + + function buildResolution(parts: ConflictPart[], choices: (ConflictChoice | null)[]): string { + const out: string[] = []; + + for (const part of parts) { + if (part.kind === "text") { + out.push(...part.lines); + continue; + } + + const choice = choices[part.index]; + + if (choice === "ours") { + out.push(...part.oursLines); + } else if (choice === "theirs") { + out.push(...part.theirsLines); + } else if (choice === "both-ot") { + out.push(...part.oursLines, ...part.theirsLines); + } else if (choice === "both-to") { + out.push(...part.theirsLines, ...part.oursLines); + } else { + // Unresolved: keep the conflict markers so it stays clearly flagged. + out.push( + "<<<<<<< current", + ...part.oursLines, + "=======", + ...part.theirsLines, + ">>>>>>> incoming", + ); + } + } + + return out.join("\n"); + } + + function setConflictChoice(index: number, choice: ConflictChoice) { + const next = [...conflictChoices]; + next[index] = choice; + conflictChoices = next; + } + + function setAllConflicts(choice: ConflictChoice) { + conflictChoices = conflictParts + .filter((part) => part.kind === "conflict") + .map(() => choice); + } + + function enableManualEdit() { + resolveContent = buildResolution(conflictParts, conflictChoices); + manualMode = true; + } + + function disableManualEdit() { + manualMode = false; + } + + function displayLine(line: string): string { + // Strip a trailing CR for display only; the stored line keeps it so the + // rebuilt file preserves its original line endings. + return line.replace(/\r$/, ""); + } + + function oursActive(choice: ConflictChoice | null): boolean { + return choice === "ours" || choice === "both-ot" || choice === "both-to"; + } + + function theirsActive(choice: ConflictChoice | null): boolean { + return choice === "theirs" || choice === "both-ot" || choice === "both-to"; + } + + async function openResolveDialog() { + if (!hasConflicts || isBusy) { + return; + } + + const first = conflictedFiles[0].path; + await runOperation("Loading conflicts", async () => { + resolveDialogOpen = true; + await loadConflict(first); + }); + } + + async function selectConflictFile(file: string) { + if (file === conflictTarget || isBusy) { + return; + } + + await runOperation(`Loading ${file}`, async () => { + await loadConflict(file); + }); + } + + async function saveResolution() { + if (!activeRepoPath || !conflictTarget || isBusy) { + return; + } + + if ( + resolveHasMarkers && + !window.confirm( + "Conflict markers (<<<<<<< / >>>>>>>) are still present. Mark this file as resolved anyway?", + ) + ) { + return; + } + + const resolvedPath = conflictTarget; + const content = resolvedContent; + await runOperation(`Resolving ${resolvedPath}`, async () => { + const nextStatus = await resolveConflict(activeRepoPath, resolvedPath, content); + applyStatus(nextStatus); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + + const remaining = nextStatus.files.filter( + (file) => file.staged === "conflicted" || file.unstaged === "conflicted", + ); + + if (remaining.length === 0) { + resolveDialogOpen = false; + conflict = null; + conflictTarget = ""; + resolveContent = ""; + conflictParts = []; + conflictChoices = []; + manualMode = false; + } else { + await loadConflict(remaining[0].path); + } + }); + } + + function closeResolveDialog() { + resolveDialogOpen = false; + } + function closeCompareDialog() { compareDialogOpen = false; } @@ -835,6 +1079,19 @@ {/if} + {#if hasConflicts} + + {/if} +