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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
src/App.tsx
20
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<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()
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user