- git.rs (1907 lines) → 7 files: mod, history, status, commit, remote, conflict, pulse - theme.rs (1075 lines) → 4 files: mod, defaults, seed, create - github.rs (886 lines) → 4 files: mod, api, auth, clone - frontmatter.rs (827 lines) → 3 files: mod, yaml, ops - lib.rs (772 lines) → lib.rs (160) + commands.rs (650) 484 tests pass, clippy clean, rustfmt clean, coverage 85.42%
367 lines
12 KiB
Rust
367 lines
12 KiB
Rust
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
use super::run_git;
|
|
|
|
/// List files with merge conflicts (unmerged paths).
|
|
///
|
|
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
|
|
/// ls-files reliably detects unmerged index entries even when the merge state is
|
|
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
|
|
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
|
|
let vault = Path::new(vault_path);
|
|
let output = Command::new("git")
|
|
.args(["ls-files", "--unmerged"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
|
|
// Format: "<mode> <hash> <stage>\t<path>"
|
|
let mut files: Vec<String> = stdout
|
|
.lines()
|
|
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
|
|
.collect();
|
|
files.sort();
|
|
files.dedup();
|
|
Ok(files)
|
|
}
|
|
|
|
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
|
|
/// then stage the result.
|
|
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
|
|
let vault = Path::new(vault_path);
|
|
|
|
let checkout_flag = match strategy {
|
|
"ours" => "--ours",
|
|
"theirs" => "--theirs",
|
|
_ => {
|
|
return Err(format!(
|
|
"Invalid strategy '{}': must be 'ours' or 'theirs'",
|
|
strategy
|
|
))
|
|
}
|
|
};
|
|
|
|
run_git(vault, &["checkout", checkout_flag, "--", file])?;
|
|
run_git(vault, &["add", "--", file])?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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);
|
|
|
|
// Verify no remaining conflicts
|
|
let remaining = get_conflict_files(vault_path)?;
|
|
if !remaining.is_empty() {
|
|
return Err(format!(
|
|
"Cannot commit: {} file(s) still have unresolved conflicts",
|
|
remaining.len()
|
|
));
|
|
}
|
|
|
|
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 !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
|
|
};
|
|
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(&output.stdout).to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
|
use crate::git::{git_commit, git_pull, git_push};
|
|
use std::fs;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_get_conflict_files_empty_when_clean() {
|
|
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();
|
|
|
|
let conflicts = get_conflict_files(vp).unwrap();
|
|
assert!(conflicts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_conflict_invalid_strategy() {
|
|
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
|
let vp_b = clone_b.path().to_str().unwrap();
|
|
|
|
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Invalid strategy"));
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
|
|
/// Set up a pair of clones that have a merge conflict on the same file.
|
|
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
|
|
fn setup_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 — this causes a merge conflict
|
|
let result = git_pull(vp_b).unwrap();
|
|
assert_eq!(result.status, "conflict");
|
|
|
|
(bare_dir, clone_a_dir, clone_b_dir)
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_conflict_ours() {
|
|
let (_bare, _clone_a, clone_b) = setup_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()));
|
|
|
|
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
|
|
|
let remaining = get_conflict_files(vp_b).unwrap();
|
|
assert!(remaining.is_empty());
|
|
|
|
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
|
assert_eq!(content, "# Version B\n");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_conflict_theirs() {
|
|
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
|
let vp_b = clone_b.path().to_str().unwrap();
|
|
|
|
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
|
|
|
let remaining = get_conflict_files(vp_b).unwrap();
|
|
assert!(remaining.is_empty());
|
|
|
|
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
|
assert_eq!(content, "# Version A\n");
|
|
}
|
|
|
|
#[test]
|
|
fn test_commit_conflict_resolution() {
|
|
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
|
let vp_b = clone_b.path().to_str().unwrap();
|
|
|
|
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
|
|
|
let result = git_commit_conflict_resolution(vp_b);
|
|
assert!(result.is_ok());
|
|
|
|
let log = Command::new("git")
|
|
.args(["log", "--oneline", "-1"])
|
|
.current_dir(clone_b.path())
|
|
.output()
|
|
.unwrap();
|
|
let log_str = String::from_utf8_lossy(&log.stdout);
|
|
assert!(log_str.contains("Resolve merge conflicts"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_commit_conflict_resolution_fails_with_unresolved() {
|
|
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
|
let vp_b = clone_b.path().to_str().unwrap();
|
|
|
|
let result = git_commit_conflict_resolution(vp_b);
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.contains("still have unresolved conflicts"));
|
|
}
|
|
|
|
#[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();
|
|
|
|
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());
|
|
|
|
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();
|
|
|
|
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();
|
|
|
|
git_pull(vp_b).unwrap();
|
|
|
|
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();
|
|
|
|
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
|
git_commit(vp_b, "B's change").unwrap();
|
|
|
|
let output = Command::new("git")
|
|
.args(["pull", "--rebase"])
|
|
.current_dir(clone_b_dir.path())
|
|
.output()
|
|
.unwrap();
|
|
|
|
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");
|
|
|
|
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
|
let remaining = get_conflict_files(vp_b).unwrap();
|
|
assert!(remaining.is_empty());
|
|
|
|
let result = git_commit_conflict_resolution(vp_b);
|
|
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
|
|
|
|
assert_eq!(get_conflict_mode(vp_b), "none");
|
|
}
|
|
}
|