diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index cc41b031..bfec2ca3 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -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 { let vault = Path::new(vault_path); @@ -504,24 +529,38 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result 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"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 963286a0..c4bc3e91 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -143,6 +143,18 @@ fn git_pull(vault_path: String) -> Result { git::git_pull(&vault_path) } +#[tauri::command] +fn get_conflict_files(vault_path: String) -> Result, 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, diff --git a/src/App.tsx b/src/App.tsx index 2bc5fe27..19d18655 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('get_conflict_files', { vaultPath: resolvedPath }) + : await mockInvoke('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() diff --git a/src/hooks/useAutoSync.test.ts b/src/hooks/useAutoSync.test.ts index 3cb8d6a1..49bfa149 100644 --- a/src/hooks/useAutoSync.test.ts +++ b/src/hooks/useAutoSync.test.ts @@ -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((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']) + }) + }) }) diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index 7c4bcba9..e1e39fe6 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -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 => { + try { + const files = await tauriCall('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(() => { diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index efc783b2..07f46aea 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -173,6 +173,8 @@ export const mockHandlers: Record 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,