From nobody Sun Feb 8 16:32:03 2026 Received: from smtp.kernel.org (aws-us-west-2-korg-mail-1.web.codeaurora.org [10.30.226.201]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by smtp.subspace.kernel.org (Postfix) with ESMTPS id E38EF350A19 for ; Fri, 19 Dec 2025 18:16:37 +0000 (UTC) Authentication-Results: smtp.subspace.kernel.org; arc=none smtp.client-ip=10.30.226.201 ARC-Seal: i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1766168198; cv=none; b=SXkJTx+BKIZqIZuSSiN6L0VPryFBKwGjxaB09Vt7VN4ZBFwwN34o9dJV+nL0V1qV37P2sjLK7WnlfHnJ0u9MD9dt3aCdIytC5bphTSPKpC8fKQ41OI6Uw/LcdVgnkhs6JsJRx3YzpTgofCGwKFuXh0ffUzAD/C1PhmFlEDsiMTk= ARC-Message-Signature: i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1766168198; c=relaxed/simple; bh=gt2tED3ycuCsnRgfcF6HtCI1KxSxn6L+l6tv6FCGDCA=; h=From:To:Cc:Subject:Date:Message-ID:In-Reply-To:References: MIME-Version; b=qjluVvUeI6ECBPp23iBzmcCT0eec7iswig+ZbRdAkKveRDhwGagKev0mHJZYiMyg054tavyz1Kn/Eqw+oFL/+wqmG0plqaglpwkDpOQDURGnYQIOPEl4k99SW/mGiQkJqq9cRHK25oqJ0H1ApRiQ6zbnhf+8kHV6JGgowmB9Sqg= ARC-Authentication-Results: i=1; smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=kernel.org header.i=@kernel.org header.b=DUtUoiD/; arc=none smtp.client-ip=10.30.226.201 Authentication-Results: smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=kernel.org header.i=@kernel.org header.b="DUtUoiD/" Received: by smtp.kernel.org (Postfix) with ESMTPSA id 1ECDEC116B1; Fri, 19 Dec 2025 18:16:37 +0000 (UTC) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=kernel.org; s=k20201202; t=1766168197; bh=gt2tED3ycuCsnRgfcF6HtCI1KxSxn6L+l6tv6FCGDCA=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=DUtUoiD/0Q2kwUtiSfDL4oY7OxL1zmh/hqhY1zX/Y3F/kQ8qnnJ41BDHBVRfRn0Go xtIinMUeJRYXo1bzS+WNYkMBGXwHx9UVAGNkOpm6UXW0lhgrAE9xVumV1eCe5jn5qF WEORwjyvQftOcE7qKZsLMdqIXT4beIhx9/fycJqvSOfSKpYYrAOXVn1afOMD5RzCKZ cjqgr4yqx31F9AXCuYdFeoYU7o/MWHQmt1+zkNHINktJXna57cBpf4sSf01xPZ3lEi 1gR9agsgOarL8fV59HClqRs2O/77eGSPvlXKcPRYh0yIH4Cv5roGv38pjTXbk8SxjR WGIxLvp8X1q4Q== From: Sasha Levin To: tools@kernel.org Cc: linux-kernel@vger.kernel.org, torvalds@linux-foundation.org, broonie@kernel.org, Sasha Levin Subject: [RFC 1/5] LLMinus: Add skeleton project with learn command Date: Fri, 19 Dec 2025 13:16:25 -0500 Message-ID: <20251219181629.1123823-2-sashal@kernel.org> X-Mailer: git-send-email 2.51.0 In-Reply-To: <20251219181629.1123823-1-sashal@kernel.org> References: <20251219181629.1123823-1-sashal@kernel.org> Precedence: bulk X-Mailing-List: linux-kernel@vger.kernel.org List-Id: List-Subscribe: List-Unsubscribe: MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset="utf-8" Introduce LLMinus, an LLM-powered git conflict resolution tool for the Linux kernel. This initial version provides: - CLI structure using clap with derive macros - Data structures for storing conflict resolutions: - DiffHunk: Individual diff hunks with line information - FileResolution: Per-file conflict resolution data - MergeResolution: Per-commit resolution with metadata - ResolutionStore: JSON-based persistence - The 'learn' command that: - Walks merge commit history (optionally filtered by range) - Identifies files modified in both parent branches - Extracts actual conflict resolutions (not trivial merges) - Stores the ours/theirs/resolution diffs for each file - Tracks commits to avoid duplicate processing The tool is designed to build a database of historical conflict resolutions that can later be used for RAG-based similarity search to assist with future merge conflicts. Signed-off-by: Sasha Levin --- tools/llminus/.gitignore | 1 + tools/llminus/Cargo.toml | 18 + tools/llminus/src/main.rs | 693 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 712 insertions(+) create mode 100644 tools/llminus/.gitignore create mode 100644 tools/llminus/Cargo.toml create mode 100644 tools/llminus/src/main.rs diff --git a/tools/llminus/.gitignore b/tools/llminus/.gitignore new file mode 100644 index 0000000000000..b83d22266ac8a --- /dev/null +++ b/tools/llminus/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/tools/llminus/Cargo.toml b/tools/llminus/Cargo.toml new file mode 100644 index 0000000000000..bdb42561a0565 --- /dev/null +++ b/tools/llminus/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name =3D "llminus" +version =3D "0.1.0" +edition =3D "2024" +authors =3D ["Sasha Levin "] +description =3D "LLM-powered git conflict resolution tool for the Linux ke= rnel" +license =3D "GPL-2.0" +repository =3D "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/l= inux.git" + +[dependencies] +anyhow =3D "1" +clap =3D { version =3D "4", features =3D ["derive"] } +rayon =3D "1" +serde =3D { version =3D "1", features =3D ["derive"] } +serde_json =3D "1" + +[dev-dependencies] +tempfile =3D "3" diff --git a/tools/llminus/src/main.rs b/tools/llminus/src/main.rs new file mode 100644 index 0000000000000..1c61836cc93f7 --- /dev/null +++ b/tools/llminus/src/main.rs @@ -0,0 +1,693 @@ +//! llminus - LLM-powered git conflict resolution tool + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::Path; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const STORE_PATH: &str =3D ".llminus-resolutions.json"; + +#[derive(Parser)] +#[command(name =3D "llminus")] +#[command(about =3D "LLM-powered git conflict resolution tool")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Learn from historical merge conflict resolutions + Learn { + /// Git revision range (e.g., "v6.0..v6.1"). If not specified, lea= rns from entire history. + range: Option, + }, +} + +/// A single diff hunk representing a change region +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffHunk { + /// Starting line in the original file + pub start_line: u32, + /// Number of lines in original + pub original_count: u32, + /// Number of lines in new version + pub new_count: u32, + /// The actual diff content (unified diff format lines) + pub content: String, +} + +/// A single file's conflict resolution within a merge +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileResolution { + pub file_path: String, + pub file_type: String, // Extension: "c", "h", "rs", etc. + pub subsystem: String, // Extracted from path: "drivers/gpu" -> "= gpu" + + /// Changes from base to ours (what our branch did) + pub ours_diff: Vec, + /// Changes from base to theirs (what their branch did) + pub theirs_diff: Vec, + /// The final resolution diff (base to merge result) + pub resolution_diff: Vec, +} + +/// Format a section of diff hunks with a title header +fn format_hunk_section(title: &str, hunks: &[DiffHunk]) -> String { + if hunks.is_empty() { + return String::new(); + } + let mut text =3D format!("=3D=3D=3D {} =3D=3D=3D\n", title); + for h in hunks { + text.push_str(&h.content); + text.push('\n'); + } + text.push('\n'); + text +} + +impl FileResolution { + /// Generate embedding text for this file's resolution + pub fn to_embedding_text(&self) -> String { + format!( + "File: {}\n\n{}{}{}", + self.file_path, + format_hunk_section("OURS", &self.ours_diff), + format_hunk_section("THEIRS", &self.theirs_diff), + format_hunk_section("RESOLUTION", &self.resolution_diff), + ) + } +} + +/// A merge commit's conflict resolution (may contain multiple files) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeResolution { + pub commit_hash: String, + pub commit_summary: String, + pub commit_date: String, // ISO format + pub author: String, + + /// All files that required manual conflict resolution in this merge + pub files: Vec, + + /// 384-dimensional embedding vector (BGE-small model) for the entire = merge + #[serde(skip_serializing_if =3D "Option::is_none")] + pub embedding: Option>, +} + +impl MergeResolution { + /// Generate embedding text from all file resolutions + pub fn to_embedding_text(&self) -> String { + let mut text =3D format!("Merge: {}\n{}\n\n", self.commit_hash, se= lf.commit_summary); + for file in &self.files { + text.push_str(&file.to_embedding_text()); + text.push_str("\n---\n\n"); + } + text + } +} + +/// Collection of all learned resolutions +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ResolutionStore { + pub version: u32, + pub resolutions: Vec, +} + +impl ResolutionStore { + pub fn load(path: &Path) -> Result { + if path.exists() { + let content =3D std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) + } else { + Ok(Self { version: 2, resolutions: Vec::new() }) + } + } + + pub fn save(&self, path: &Path) -> Result<()> { + // Use compact JSON for faster serialization (use jq to pretty-pri= nt if needed) + let content =3D serde_json::to_string(self)?; + std::fs::write(path, content)?; + Ok(()) + } +} + +/// Run a git command and return stdout +fn git(args: &[&str]) -> Result { + let output =3D Command::new("git") + .args(args) + .output() + .context("Failed to run git")?; + + if !output.status.success() { + let stderr =3D String::from_utf8_lossy(&output.stderr); + bail!("git {} failed: {}", args.join(" "), stderr); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Run a git command, return stdout, allow failure +fn git_allow_fail(args: &[&str]) -> Option { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) +} + +/// Check we're in a git repository +fn check_repo() -> Result<()> { + git(&["rev-parse", "--git-dir"])?; + Ok(()) +} + +/// Get merge commits in range (or all history) +fn get_merge_commits(range: Option<&str>) -> Result> { + let args: Vec<&str> =3D match range { + Some(r) =3D> vec!["log", "--merges", "--format=3D%H", r], + None =3D> vec!["log", "--merges", "--format=3D%H"], + }; + + let output =3D git(&args)?; + Ok(output.lines().map(|s| s.to_string()).collect()) +} + +/// Metadata extracted from a git commit +struct CommitMetadata { + summary: String, + date: String, + author: String, +} + +/// Get commit metadata +fn get_commit_metadata(hash: &str) -> CommitMetadata { + let format =3D git_allow_fail(&["log", "-1", "--format=3D%s%n%aI%n%an = <%ae>", hash]) + .unwrap_or_default(); + let mut lines =3D format.lines(); + CommitMetadata { + summary: lines.next().unwrap_or_default().to_string(), + date: lines.next().unwrap_or_default().to_string(), + author: lines.next().unwrap_or_default().to_string(), + } +} + +/// Get parent commits of a merge +fn get_parents(hash: &str) -> Result> { + let output =3D git(&["log", "-1", "--format=3D%P", hash])?; + Ok(output.split_whitespace().map(|s| s.to_string()).collect()) +} + +/// Get merge base between two commits +fn get_merge_base(commit1: &str, commit2: &str) -> Option { + git_allow_fail(&["merge-base", commit1, commit2]) + .map(|s| s.trim().to_string()) +} + +/// Extract file type from path +fn get_file_type(path: &str) -> String { + Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string() +} + +/// Extract subsystem from path (first or second directory component) +fn get_subsystem(path: &str) -> String { + let parts: Vec<&str> =3D path.split('/').collect(); + match parts.first() { + Some(&"drivers") | Some(&"fs") | Some(&"net") | Some(&"arch") | So= me(&"sound") =3D> { + parts.get(1).unwrap_or(&"").to_string() + } + Some(first) =3D> first.to_string(), + None =3D> String::new(), + } +} + +/// Get unified diff between two commits for a specific file +fn get_diff(from: &str, to: &str, file: &str) -> Option { + git_allow_fail(&["diff", "-U3", from, to, "--", file]) +} + +/// Get file content at a specific commit +fn get_file_at_commit(commit: &str, path: &str) -> Option { + git_allow_fail(&["show", &format!("{}:{}", commit, path)]) +} + +/// Parse unified diff into hunks +fn parse_diff_hunks(diff: &str) -> Vec { + let mut hunks =3D Vec::new(); + let mut current_hunk: Option<(u32, u32, u32, Vec)> =3D None; + + for line in diff.lines() { + if line.starts_with("@@") { + // Save previous hunk + if let Some((start, orig_count, new_count, lines)) =3D current= _hunk.take() { + hunks.push(DiffHunk { + start_line: start, + original_count: orig_count, + new_count: new_count, + content: lines.join("\n"), + }); + } + + // Parse hunk header: @@ -start,count +start,count @@ + if let Some(header) =3D parse_hunk_header(line) { + current_hunk =3D Some((header.0, header.1, header.2, vec![= line.to_string()])); + } + } else if current_hunk.is_some() && (line.starts_with('+') || line= .starts_with('-') || line.starts_with(' ')) { + if let Some((_, _, _, ref mut lines)) =3D current_hunk { + lines.push(line.to_string()); + } + } + } + + // Save last hunk + if let Some((start, orig_count, new_count, lines)) =3D current_hunk { + hunks.push(DiffHunk { + start_line: start, + original_count: orig_count, + new_count: new_count, + content: lines.join("\n"), + }); + } + + hunks +} + +/// Parse a hunk header like "@@ -10,5 +10,7 @@" -> (start, orig_count, ne= w_count) +fn parse_hunk_header(line: &str) -> Option<(u32, u32, u32)> { + let line =3D line.trim_start_matches("@@ "); + let parts: Vec<&str> =3D line.split(' ').collect(); + if parts.len() < 2 { + return None; + } + + let parse_range =3D |s: &str| -> (u32, u32) { + let s =3D s.trim_start_matches(['-', '+']); + if let Some((start, count)) =3D s.split_once(',') { + (start.parse().unwrap_or(1), count.parse().unwrap_or(1)) + } else { + (s.parse().unwrap_or(1), 1) + } + }; + + let (orig_start, orig_count) =3D parse_range(parts[0]); + let (_, new_count) =3D parse_range(parts[1]); + + Some((orig_start, orig_count, new_count)) +} + +/// Find files modified in both branches +fn find_modified_in_both(parent1: &str, parent2: &str, base: &str) -> Resu= lt> { + let changed1 =3D git_allow_fail(&["diff", "--name-only", base, parent1= ]) + .unwrap_or_default(); + let changed2 =3D git_allow_fail(&["diff", "--name-only", base, parent2= ]) + .unwrap_or_default(); + + let files1: HashSet<_> =3D changed1.lines().collect(); + let files2: HashSet<_> =3D changed2.lines().collect(); + + Ok(files1.intersection(&files2).map(|s| s.to_string()).collect()) +} + +/// Extract conflict resolutions from a merge commit +/// Returns None if no manual conflict resolution was needed +fn extract_resolution(hash: &str) -> Result> { + let parents =3D get_parents(hash)?; + if parents.len() < 2 { + return Ok(None); + } + + let parent1 =3D &parents[0]; + let parent2 =3D &parents[1]; + + let base =3D match get_merge_base(parent1, parent2) { + Some(b) =3D> b, + None =3D> return Ok(None), + }; + + let meta =3D get_commit_metadata(hash); + let modified =3D find_modified_in_both(parent1, parent2, &base)?; + + let mut files =3D Vec::new(); + + for file_path in modified { + // Get diffs: base->ours, base->theirs, base->resolution + let ours_diff_raw =3D get_diff(&base, parent1, &file_path); + let theirs_diff_raw =3D get_diff(&base, parent2, &file_path); + let resolution_diff_raw =3D get_diff(&base, hash, &file_path); + + // Parse into hunks + let ours_hunks =3D ours_diff_raw.as_ref().map(|d| parse_diff_hunks= (d)).unwrap_or_default(); + let theirs_hunks =3D theirs_diff_raw.as_ref().map(|d| parse_diff_h= unks(d)).unwrap_or_default(); + let resolution_hunks =3D resolution_diff_raw.as_ref().map(|d| pars= e_diff_hunks(d)).unwrap_or_default(); + + // Skip if no actual changes + if ours_hunks.is_empty() && theirs_hunks.is_empty() { + continue; + } + + // Skip if ours =3D=3D theirs (no real conflict) + if ours_diff_raw =3D=3D theirs_diff_raw { + continue; + } + + // Only keep if resolution differs from BOTH parents (manual merge= required) + let ours_content =3D get_file_at_commit(parent1, &file_path); + let theirs_content =3D get_file_at_commit(parent2, &file_path); + let resolution_content =3D get_file_at_commit(hash, &file_path); + + if resolution_content =3D=3D ours_content || resolution_content = =3D=3D theirs_content { + continue; // Trivial resolution, no manual merge needed + } + + files.push(FileResolution { + file_path: file_path.clone(), + file_type: get_file_type(&file_path), + subsystem: get_subsystem(&file_path), + ours_diff: ours_hunks, + theirs_diff: theirs_hunks, + resolution_diff: resolution_hunks, + }); + } + + // Only return if there were actual conflicts + if files.is_empty() { + return Ok(None); + } + + Ok(Some(MergeResolution { + commit_hash: hash.to_string(), + commit_summary: meta.summary, + commit_date: meta.date, + author: meta.author, + files, + embedding: None, + })) +} + +fn learn(range: Option<&str>) -> Result<()> { + check_repo()?; + + let store_path =3D Path::new(STORE_PATH); + let mut store =3D ResolutionStore::load(store_path)?; + store.version =3D 3; // Upgrade version (grouped by commit) + + // Track existing commits to avoid duplicates + let existing: HashSet<_> =3D store.resolutions.iter() + .map(|r| r.commit_hash.clone()) + .collect(); + + let merge_commits =3D get_merge_commits(range)?; + let total_commits =3D merge_commits.len(); + + // Filter to only new commits + let new_commits: Vec<_> =3D merge_commits + .into_iter() + .filter(|h| !existing.contains(h)) + .collect(); + + println!("Found {} merge commits ({} new to analyze)", total_commits, = new_commits.len()); + + if new_commits.is_empty() { + println!("No new commits to process."); + return Ok(()); + } + + // Configure thread pool for I/O bound work (git subprocesses) + // Use 2x threads since we're mostly waiting on git + let num_threads =3D std::thread::available_parallelism() + .map(|n| n.get() * 2) + .unwrap_or(16); + + let pool =3D rayon::ThreadPoolBuilder::new() + .num_threads(num_threads) + .build() + .context("Failed to build thread pool")?; + + println!("Using {} threads", num_threads); + + // Progress counter + let processed =3D AtomicUsize::new(0); + let total_new =3D new_commits.len(); + + // Process commits in parallel + let resolutions: Vec =3D pool.install(|| { + new_commits + .par_iter() + .filter_map(|hash| { + let count =3D processed.fetch_add(1, Ordering::Relaxed) + = 1; + if count % 100 =3D=3D 0 || count =3D=3D total_new { + eprintln!(" Progress: {}/{}", count, total_new); + } + + match extract_resolution(hash) { + Ok(Some(resolution)) =3D> Some(resolution), + Ok(None) =3D> None, + Err(e) =3D> { + eprintln!("Warning: Failed to analyze {}: {}", &ha= sh[..12], e); + None + } + } + }) + .collect() + }); + + // Aggregate results + let commits_with_conflicts =3D resolutions.len(); + let total_files: usize =3D resolutions.iter().map(|r| r.files.len()).s= um(); + + store.resolutions.extend(resolutions); + store.save(store_path)?; + + // Calculate approximate size + let json_size =3D std::fs::metadata(store_path).map(|m| m.len()).unwra= p_or(0); + let total_stored_files: usize =3D store.resolutions.iter().map(|r| r.f= iles.len()).sum(); + + println!("\nResults:"); + println!(" Merge commits analyzed: {}", total_commits); + println!(" Commits with conflicts: {}", commits_with_conflicts); + println!(" Files resolved: {}", total_files); + println!(" New commits stored: {}", commits_with_conflicts); + println!(" Total in store: {} commits, {} files", store.resolutions.l= en(), total_stored_files); + println!(" Output size: {:.2} MB", json_size as f64 / 1024.0 / 1024.0= ); + println!("\nResolutions saved to: {}", store_path.display()); + + Ok(()) +} + +fn main() -> Result<()> { + let cli =3D Cli::parse(); + + match cli.command { + Commands::Learn { range } =3D> learn(range.as_deref()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + use std::fs; + use tempfile::TempDir; + + #[test] + fn verify_cli() { + Cli::command().debug_assert(); + } + + #[test] + fn test_learn_command_parses() { + let cli =3D Cli::try_parse_from(["llminus", "learn"]).unwrap(); + match cli.command { + Commands::Learn { range } =3D> assert!(range.is_none()), + } + } + + #[test] + fn test_learn_command_with_range() { + let cli =3D Cli::try_parse_from(["llminus", "learn", "v6.0..v6.1"]= ).unwrap(); + match cli.command { + Commands::Learn { range } =3D> assert_eq!(range, Some("v6.0..v= 6.1".to_string())), + } + } + + #[test] + fn test_get_file_type() { + assert_eq!(get_file_type("foo/bar.c"), "c"); + assert_eq!(get_file_type("foo/bar.rs"), "rs"); + assert_eq!(get_file_type("Makefile"), ""); + assert_eq!(get_file_type("include/linux/module.h"), "h"); + } + + #[test] + fn test_get_subsystem() { + assert_eq!(get_subsystem("drivers/gpu/drm/foo.c"), "gpu"); + assert_eq!(get_subsystem("fs/ext4/inode.c"), "ext4"); + assert_eq!(get_subsystem("kernel/sched/core.c"), "kernel"); + assert_eq!(get_subsystem("net/ipv4/tcp.c"), "ipv4"); + assert_eq!(get_subsystem("mm/memory.c"), "mm"); + } + + #[test] + fn test_parse_hunk_header() { + assert_eq!(parse_hunk_header("@@ -10,5 +10,7 @@"), Some((10, 5, 7)= )); + assert_eq!(parse_hunk_header("@@ -1 +1,2 @@"), Some((1, 1, 2))); + assert_eq!(parse_hunk_header("@@ -100,20 +105,25 @@ func"), Some((= 100, 20, 25))); + } + + #[test] + fn test_parse_diff_hunks() { + let diff =3D r#"diff --git a/file.c b/file.c +index 123..456 789 +--- a/file.c ++++ b/file.c +@@ -10,3 +10,4 @@ context + unchanged +-removed ++added ++another +"#; + let hunks =3D parse_diff_hunks(diff); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].start_line, 10); + assert!(hunks[0].content.contains("-removed")); + assert!(hunks[0].content.contains("+added")); + } + + fn init_test_repo() -> TempDir { + let dir =3D TempDir::new().unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir.path()) + .output() + .unwrap(); + dir + } + + fn create_commit(dir: &TempDir, filename: &str, content: &str, msg: &s= tr) { + fs::write(dir.path().join(filename), content).unwrap(); + Command::new("git") + .args(["add", filename]) + .current_dir(dir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", msg]) + .current_dir(dir.path()) + .output() + .unwrap(); + } + + fn create_branch(dir: &TempDir, name: &str) { + Command::new("git") + .args(["checkout", "-b", name]) + .current_dir(dir.path()) + .output() + .unwrap(); + } + + fn checkout(dir: &TempDir, name: &str) { + Command::new("git") + .args(["checkout", name]) + .current_dir(dir.path()) + .output() + .unwrap(); + } + + fn merge(dir: &TempDir, branch: &str, msg: &str) { + Command::new("git") + .args(["merge", "--no-ff", "-m", msg, branch]) + .current_dir(dir.path()) + .output() + .unwrap(); + } + + #[test] + fn test_resolution_store_roundtrip() { + let dir =3D TempDir::new().unwrap(); + let store_path =3D dir.path().join("resolutions.json"); + + let mut store =3D ResolutionStore { version: 3, resolutions: Vec::= new() }; + store.resolutions.push(MergeResolution { + commit_hash: "abc123".to_string(), + commit_summary: "Test merge".to_string(), + commit_date: "2024-01-15T10:00:00Z".to_string(), + author: "Test ".to_string(), + files: vec![FileResolution { + file_path: "test.c".to_string(), + file_type: "c".to_string(), + subsystem: "test".to_string(), + ours_diff: vec![DiffHunk { + start_line: 10, + original_count: 3, + new_count: 4, + content: "@@ -10,3 +10,4 @@\n-old\n+new".to_string(), + }], + theirs_diff: vec![], + resolution_diff: vec![], + }], + embedding: None, + }); + + store.save(&store_path).unwrap(); + let loaded =3D ResolutionStore::load(&store_path).unwrap(); + + assert_eq!(loaded.version, 3); + assert_eq!(loaded.resolutions.len(), 1); + assert_eq!(loaded.resolutions[0].commit_hash, "abc123"); + assert_eq!(loaded.resolutions[0].files.len(), 1); + assert_eq!(loaded.resolutions[0].files[0].file_path, "test.c"); + assert_eq!(loaded.resolutions[0].files[0].file_type, "c"); + + // Test embedding text generation for merge + let embedding =3D loaded.resolutions[0].to_embedding_text(); + assert!(embedding.contains("Merge: abc123")); + assert!(embedding.contains("File: test.c")); + assert!(embedding.contains("=3D=3D=3D OURS =3D=3D=3D")); + assert!(embedding.contains("-old")); + assert!(embedding.contains("+new")); + } + + #[test] + fn test_git_in_repo() { + let dir =3D init_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + create_commit(&dir, "file.txt", "initial", "initial commit"); + let result =3D check_repo(); + assert!(result.is_ok()); + } + + #[test] + fn test_get_merge_commits() { + let dir =3D init_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + create_commit(&dir, "file.txt", "initial", "initial commit"); + create_branch(&dir, "feature"); + create_commit(&dir, "feature.txt", "feature", "feature commit"); + checkout(&dir, "master"); + create_commit(&dir, "main.txt", "main", "main commit"); + merge(&dir, "feature", "Merge feature"); + + let merges =3D get_merge_commits(None).unwrap(); + assert_eq!(merges.len(), 1); + } +} --=20 2.51.0