fix: detect and resolve rebase conflicts in sync conflict resolution

The previous conflict detection only worked for merge-based pulls
(--no-rebase) but failed to detect pre-existing conflicts from
interrupted rebases or prior sessions. This fixes three root causes:

1. Rust: add is_rebase_in_progress/is_merge_in_progress/get_conflict_mode
   helpers, and dispatch git_commit_conflict_resolution between
   `git commit` (merge) and `git rebase --continue` (rebase)
2. Frontend: add startup conflict check via get_conflict_files before
   pulling, so pre-existing conflicts are detected on app launch
3. App.tsx: handleOpenConflictResolver now fetches conflicts directly
   when the cached list is empty, preventing the silent early-return

Also exposes get_conflict_files and get_conflict_mode as Tauri commands
so the frontend can independently check conflict state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-05 10:34:25 +01:00
parent 7ced48d001
commit 5775cb0c96
6 changed files with 278 additions and 23 deletions

View File

@@ -491,7 +491,32 @@ pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Res
Ok(())
}
/// Commit after all merge conflicts have been resolved.
/// Check whether a rebase is currently in progress.
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
let vault = Path::new(vault_path);
let git_dir = vault.join(".git");
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
}
/// Check whether a merge is currently in progress.
pub fn is_merge_in_progress(vault_path: &str) -> bool {
Path::new(vault_path).join(".git").join("MERGE_HEAD").exists()
}
/// Returns the current conflict mode: "rebase", "merge", or "none".
pub fn get_conflict_mode(vault_path: &str) -> String {
if is_rebase_in_progress(vault_path) {
"rebase".to_string()
} else if is_merge_in_progress(vault_path) {
"merge".to_string()
} else {
"none".to_string()
}
}
/// Commit after all conflicts have been resolved.
/// Detects whether the repo is in a merge or rebase state and uses the
/// appropriate command (`git commit` vs `git rebase --continue`).
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
@@ -504,24 +529,38 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String
));
}
let commit = Command::new("git")
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?;
let mode = get_conflict_mode(vault_path);
let output = match mode.as_str() {
"rebase" => Command::new("git")
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
_ => Command::new("git")
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?,
};
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
let stdout = String::from_utf8_lossy(&commit.stdout);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
return Err(format!("git commit failed: {}", detail.trim()));
let cmd_name = if mode == "rebase" {
"git rebase --continue"
} else {
"git commit"
};
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
}
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Push to remote.
@@ -1394,4 +1433,128 @@ mod tests {
None
);
}
#[test]
fn test_conflict_mode_none_for_clean_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
assert_eq!(get_conflict_mode(vp), "none");
assert!(!is_rebase_in_progress(vp));
assert!(!is_merge_in_progress(vp));
}
#[test]
fn test_conflict_mode_merge_during_merge_conflict() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
// setup_conflict_pair creates a merge conflict via git_pull (--no-rebase)
assert_eq!(get_conflict_mode(vp_b), "merge");
assert!(is_merge_in_progress(vp_b));
assert!(!is_rebase_in_progress(vp_b));
}
#[test]
fn test_commit_conflict_resolution_merge_mode() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
// After resolution, mode should be none
assert_eq!(get_conflict_mode(vp_b), "none");
}
/// Set up a rebase conflict: clone_b has diverged from origin and
/// `git pull --rebase` causes a conflict.
fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
// A creates the file and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
// B pulls to get the file
git_pull(vp_b).unwrap();
// A modifies and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
// B modifies the same file locally and commits
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
// B pulls with --rebase to cause a rebase conflict
let output = Command::new("git")
.args(["pull", "--rebase"])
.current_dir(clone_b_dir.path())
.output()
.unwrap();
// Pull should fail due to conflict
assert!(
!output.status.success(),
"Expected rebase conflict, but pull succeeded"
);
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_conflict_mode_rebase_during_rebase_conflict() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
assert!(is_rebase_in_progress(vp_b));
assert!(!is_merge_in_progress(vp_b));
}
#[test]
fn test_get_conflict_files_during_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(
conflicts.contains(&"conflict.md".to_string()),
"Should detect conflict.md during rebase, got: {:?}",
conflicts
);
}
#[test]
fn test_resolve_and_continue_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
// Resolve the conflict
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
// Commit resolution should use rebase --continue
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
// After resolution, mode should be none
assert_eq!(get_conflict_mode(vp_b), "none");
}
}

View File

@@ -143,6 +143,18 @@ fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
git::git_pull(&vault_path)
}
#[tauri::command]
fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_files(&vault_path)
}
#[tauri::command]
fn get_conflict_mode(vault_path: String) -> String {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_mode(&vault_path)
}
#[tauri::command]
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
@@ -645,6 +657,8 @@ pub fn run() {
get_last_commit_info,
git_pull,
git_push,
get_conflict_files,
get_conflict_mode,
git_resolve_conflict,
git_commit_conflict_resolution,
ai_chat,

View File

@@ -135,12 +135,24 @@ function App() {
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
})
const handleOpenConflictResolver = useCallback(() => {
if (autoSync.conflictFiles.length === 0) return
const handleOpenConflictResolver = useCallback(async () => {
let files = autoSync.conflictFiles
// If no cached conflicts, check directly — there may be pre-existing
// conflicts from a prior session that the pull flow didn't detect.
if (files.length === 0) {
try {
files = isTauri()
? await invoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
: await mockInvoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
} catch {
return
}
if (files.length === 0) return
}
autoSync.pausePull()
conflictResolver.initFiles(autoSync.conflictFiles)
conflictResolver.initFiles(files)
dialogs.openConflictResolver()
}, [autoSync, conflictResolver, dialogs])
}, [autoSync, conflictResolver, dialogs, resolvedPath])
const handleCloseConflictResolver = useCallback(() => {
autoSync.resumePull()

View File

@@ -35,6 +35,7 @@ describe('useAutoSync', () => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
if (cmd === 'get_conflict_files') return Promise.resolve([])
return Promise.resolve(upToDate())
})
})
@@ -164,14 +165,17 @@ describe('useAutoSync', () => {
let resolveFirst: ((v: GitPullResult) => void) | null = null
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
if (cmd === 'get_conflict_files') return Promise.resolve([])
return new Promise<GitPullResult>((r) => { resolveFirst = r })
})
const { result } = renderSync()
// First pull is in flight (git_pull called once)
const pullCalls = () => mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull').length
expect(pullCalls()).toBe(1)
// Wait for startup conflict check to complete and pull to start
await waitFor(() => {
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull').length
expect(pullCalls).toBe(1)
})
// Trigger a manual sync while first is still running
act(() => {
@@ -179,6 +183,7 @@ describe('useAutoSync', () => {
})
// Should NOT have fired a second git_pull call
const pullCalls = () => mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull').length
expect(pullCalls()).toBe(1)
// Resolve the first
@@ -219,6 +224,7 @@ describe('useAutoSync', () => {
it('handles error status from git_pull result', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(null)
if (cmd === 'get_conflict_files') return Promise.resolve([])
return Promise.resolve({
status: 'error', message: 'remote: Not Found', updatedFiles: [], conflictFiles: [],
})
@@ -229,4 +235,40 @@ describe('useAutoSync', () => {
expect(result.current.syncStatus).toBe('error')
})
})
it('detects pre-existing conflicts on startup before pulling', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve(['note.md', 'plan.md'])
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
return Promise.resolve(upToDate())
})
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('conflict')
expect(result.current.conflictFiles).toEqual(['note.md', 'plan.md'])
expect(onConflict).toHaveBeenCalledWith(['note.md', 'plan.md'])
})
// Should NOT have called git_pull since conflicts were found on startup
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull')
expect(pullCalls).toHaveLength(0)
})
it('detects conflicts when git_pull returns error with unresolved conflicts', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve(['conflict.md'])
if (cmd === 'get_last_commit_info') return Promise.resolve(null)
return Promise.resolve({
status: 'error', message: 'Pull failed', updatedFiles: [], conflictFiles: [],
})
})
const { result } = renderSync()
// Startup check finds conflicts, so pull is skipped
await waitFor(() => {
expect(result.current.syncStatus).toBe('conflict')
expect(result.current.conflictFiles).toEqual(['conflict.md'])
})
})
})

View File

@@ -45,6 +45,22 @@ export function useAutoSync({
const callbacksRef = useRef({ onVaultUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onConflict, onToast }
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
try {
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
if (files.length > 0) {
setSyncStatus('conflict')
setConflictFiles(files)
callbacksRef.current.onConflict(files)
return true
}
} catch {
// If the command doesn't exist (e.g. browser mock), ignore
}
return false
}, [vaultPath])
const performPull = useCallback(async () => {
if (syncingRef.current || pauseRef.current) return
syncingRef.current = true
@@ -67,7 +83,11 @@ export function useAutoSync({
setConflictFiles(result.conflictFiles)
callbacksRef.current.onConflict(result.conflictFiles)
} else if (result.status === 'error') {
setSyncStatus('error')
// Pull failed — check if there are pre-existing conflicts that caused it
const hasConflicts = await checkExistingConflicts()
if (!hasConflicts) {
setSyncStatus('error')
}
} else {
// up_to_date or no_remote
setSyncStatus('idle')
@@ -79,12 +99,14 @@ export function useAutoSync({
} finally {
syncingRef.current = false
}
}, [vaultPath])
}, [vaultPath, checkExistingConflicts])
// Pull on mount (app launch)
// Check for pre-existing conflicts on mount, then pull
useEffect(() => {
performPull()
}, [performPull])
checkExistingConflicts().then(hasConflicts => {
if (!hasConflicts) performPull()
})
}, [checkExistingConflicts, performPull])
// Pull on window focus (app foreground)
useEffect(() => {

View File

@@ -173,6 +173,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: () => 'Everything up-to-date',
get_conflict_files: (): string[] => [],
get_conflict_mode: () => 'none',
check_claude_cli: () => ({ installed: false, version: null }),
stream_claude_chat: () => 'mock-session',
stream_claude_agent: () => null,