From b93f80e70ec1c0704968bd8e197a1e2225a035d3 Mon Sep 17 00:00:00 2001 From: max kle1nz Date: Sun, 5 Apr 2026 23:42:56 +0200 Subject: [PATCH 1/3] Prefer Git changed sets when watched roots are repos This adds a Git-aware adapter layer to the daemon. When a watched root is inside a Git worktree and native watching is available, the daemon upgrades to and uses Git changed sets to decide what to reconcile, while still falling back to filesystem scanning when Git lookup fails. Constraint: Keep daemon_tick as the single reconciliation path and keep Git awareness advisory instead of mandatory Rejected: A separate public SCM tool | unnecessary surface area because daemon_status already reports backend state and errors Rejected: Replacing native watch wakeups with Git polling alone | loses the benefit of low-latency filesystem wakeups Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep SCM semantics in the adapter layer so future Watchman/Sapling integration can plug in without rewriting daemon core logic Tested: cargo fmt --check; cargo test -p m1nd-mcp daemon_start_detects_git_root_and_head -- --nocapture; cargo test -p m1nd-mcp daemon_tick_uses_git_changed_set_when_available -- --nocapture; MCP smoke for git_native_fs success Not-tested: Non-Git SCMs and long-running Git root changes during daemon lifetime --- m1nd-mcp/src/daemon_handlers.rs | 311 ++++++++++++++++++++++++++++++-- m1nd-mcp/src/server.rs | 7 +- m1nd-mcp/src/session.rs | 5 + 3 files changed, 310 insertions(+), 13 deletions(-) diff --git a/m1nd-mcp/src/daemon_handlers.rs b/m1nd-mcp/src/daemon_handlers.rs index bbbf3642..b205390c 100644 --- a/m1nd-mcp/src/daemon_handlers.rs +++ b/m1nd-mcp/src/daemon_handlers.rs @@ -5,6 +5,7 @@ use serde_json::json; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; fn now_ms() -> u64 { @@ -136,6 +137,86 @@ fn tracked_files_from_inventory( .collect() } +fn git_root_for_watch_paths(watch_paths: &[String]) -> Option { + for raw_path in watch_paths { + let path = PathBuf::from(raw_path); + let root_hint = if path.is_dir() { + path + } else { + path.parent().map(Path::to_path_buf).unwrap_or(path) + }; + + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(&root_hint) + .output() + .ok()?; + if output.status.success() { + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !value.is_empty() { + return Some(PathBuf::from(value)); + } + } + } + None +} + +fn git_head_ref(root: &Path) -> Option { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +fn git_changed_absolute_paths( + root: &Path, + since_ref: Option<&str>, +) -> Result, String> { + let mut changed = Vec::new(); + let diff_args: Vec<&str> = if let Some(reference) = since_ref { + vec!["diff", "--name-only", reference, "--"] + } else { + vec!["status", "--porcelain"] + }; + let output = Command::new("git") + .args(&diff_args) + .current_dir(root) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + for raw_line in stdout.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + let rel = if since_ref.is_some() { + line.to_string() + } else { + line.get(3..).unwrap_or(line).trim().to_string() + }; + if rel.is_empty() { + continue; + } + changed.push(root.join(rel)); + } + + Ok(changed) +} + pub fn handle_daemon_start( state: &mut SessionState, input: layers::DaemonStartInput, @@ -171,6 +252,19 @@ pub fn handle_daemon_start( state.daemon_state.watch_events_seen = 0; state.daemon_state.watch_events_dropped = 0; state.daemon_state.last_watch_event_ms = None; + state.daemon_state.git_root = git_root_for_watch_paths(&state.daemon_state.watch_paths) + .map(|root| root.to_string_lossy().to_string()); + state.daemon_state.git_since_ref = state + .daemon_state + .git_root + .as_deref() + .and_then(|root| git_head_ref(Path::new(root))); + state.daemon_state.last_git_scan_ms = None; + state.daemon_state.last_git_changed_files = 0; + state.daemon_state.git_backend_error = None; + if state.daemon_state.git_root.is_some() { + state.daemon_state.watch_backend = "git_native_fs".into(); + } state.persist_daemon_state()?; Ok(json!({ "status": "started", @@ -181,6 +275,8 @@ pub fn handle_daemon_start( "coalesce_window_ms": state.daemon_state.coalesce_window_ms, "tracked_files": state.daemon_state.tracked_files.len(), "watch_backend": state.daemon_state.watch_backend, + "git_root": state.daemon_state.git_root, + "git_since_ref": state.daemon_state.git_since_ref, })) } @@ -238,6 +334,11 @@ pub fn handle_daemon_status( "watch_events_seen": state.daemon_state.watch_events_seen, "watch_events_dropped": state.daemon_state.watch_events_dropped, "last_watch_event_ms": state.daemon_state.last_watch_event_ms, + "git_root": state.daemon_state.git_root, + "git_since_ref": state.daemon_state.git_since_ref, + "last_git_scan_ms": state.daemon_state.last_git_scan_ms, + "last_git_changed_files": state.daemon_state.last_git_changed_files, + "git_backend_error": state.daemon_state.git_backend_error, "pending_rerun": state.daemon_state.pending_rerun, "tick_in_flight": state.daemon_state.tick_in_flight, "last_coalesced_event_ms": state.daemon_state.last_coalesced_event_ms, @@ -273,18 +374,77 @@ pub fn handle_daemon_tick( let mut changed_entries = Vec::new(); let mut deleted_entries = Vec::new(); - for (external_id, live_entry) in &live_inventory { - let changed = state - .daemon_state - .tracked_files - .get(external_id) - .is_none_or(|known| { - known.last_modified_ms != live_entry.last_modified_ms - || known.size_bytes != live_entry.size_bytes - || known.sha256 != live_entry.sha256 - }); - if changed { - changed_entries.push(live_entry.clone()); + if state.daemon_state.watch_backend == "git_native_fs" { + if let Some(root) = state.daemon_state.git_root.clone() { + match git_changed_absolute_paths( + Path::new(&root), + state.daemon_state.git_since_ref.as_deref(), + ) { + Ok(paths) => { + state.daemon_state.last_git_scan_ms = Some(now_ms()); + state.daemon_state.last_git_changed_files = paths.len(); + state.daemon_state.git_backend_error = None; + for path in paths { + let path_str = path.to_string_lossy().to_string(); + if let Some(entry) = live_inventory + .values() + .find(|entry| entry.file_path == path_str) + .cloned() + { + changed_entries.push(entry); + } + } + state.daemon_state.git_since_ref = + git_head_ref(Path::new(&root)).or(state.daemon_state.git_since_ref.clone()); + } + Err(error) => { + state.daemon_state.git_backend_error = Some(error); + for (external_id, live_entry) in &live_inventory { + let changed = state + .daemon_state + .tracked_files + .get(external_id) + .is_none_or(|known| { + known.last_modified_ms != live_entry.last_modified_ms + || known.size_bytes != live_entry.size_bytes + || known.sha256 != live_entry.sha256 + }); + if changed { + changed_entries.push(live_entry.clone()); + } + } + } + } + } else { + for (external_id, live_entry) in &live_inventory { + let changed = state + .daemon_state + .tracked_files + .get(external_id) + .is_none_or(|known| { + known.last_modified_ms != live_entry.last_modified_ms + || known.size_bytes != live_entry.size_bytes + || known.sha256 != live_entry.sha256 + }); + if changed { + changed_entries.push(live_entry.clone()); + } + } + } + } else { + for (external_id, live_entry) in &live_inventory { + let changed = state + .daemon_state + .tracked_files + .get(external_id) + .is_none_or(|known| { + known.last_modified_ms != live_entry.last_modified_ms + || known.size_bytes != live_entry.size_bytes + || known.sha256 != live_entry.sha256 + }); + if changed { + changed_entries.push(live_entry.clone()); + } } } @@ -503,6 +663,7 @@ mod tests { use crate::server::McpConfig; use m1nd_core::domain::DomainConfig; use m1nd_core::graph::Graph; + use std::process::Command; fn build_state() -> (tempfile::TempDir, SessionState) { let temp = tempfile::tempdir().expect("tempdir"); @@ -879,4 +1040,130 @@ mod tests { assert_eq!(status["tick_in_flight"], false); assert_eq!(status["watch_backend"], "polling"); } + + #[test] + fn daemon_start_detects_git_root_and_head() { + let (temp, mut state) = build_state(); + let repo = temp.path().join("repo"); + std::fs::create_dir_all(repo.join("src")).expect("repo src"); + std::fs::write(repo.join("src/core.py"), "def core():\n return 1\n").expect("write"); + + Command::new("git") + .args(["init"]) + .current_dir(&repo) + .output() + .expect("git init"); + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo) + .output() + .expect("git email"); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&repo) + .output() + .expect("git name"); + Command::new("git") + .args(["add", "."]) + .current_dir(&repo) + .output() + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&repo) + .output() + .expect("git commit"); + + let started = handle_daemon_start( + &mut state, + layers::DaemonStartInput { + agent_id: "test".into(), + watch_paths: vec![repo.to_string_lossy().to_string()], + poll_interval_ms: 200, + }, + ) + .expect("daemon start"); + + assert_eq!(started["watch_backend"], "git_native_fs"); + assert!(started["git_root"].as_str().is_some()); + assert!(started["git_since_ref"].as_str().is_some()); + } + + #[test] + fn daemon_tick_uses_git_changed_set_when_available() { + let (temp, mut state) = build_state(); + let repo = temp.path().join("repo"); + std::fs::create_dir_all(repo.join("src")).expect("repo src"); + let file_path = repo.join("src/core.py"); + std::fs::write(&file_path, "def core():\n return 1\n").expect("write"); + + Command::new("git") + .args(["init"]) + .current_dir(&repo) + .output() + .expect("git init"); + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo) + .output() + .expect("git email"); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&repo) + .output() + .expect("git name"); + Command::new("git") + .args(["add", "."]) + .current_dir(&repo) + .output() + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&repo) + .output() + .expect("git commit"); + + crate::tools::handle_ingest( + &mut state, + crate::protocol::IngestInput { + path: repo.to_string_lossy().to_string(), + agent_id: "test".into(), + mode: "replace".into(), + incremental: false, + adapter: "code".into(), + namespace: None, + include_dotfiles: false, + dotfile_patterns: Vec::new(), + }, + ) + .expect("initial ingest"); + + handle_daemon_start( + &mut state, + layers::DaemonStartInput { + agent_id: "test".into(), + watch_paths: vec![repo.to_string_lossy().to_string()], + poll_interval_ms: 200, + }, + ) + .expect("daemon start"); + + std::fs::write(&file_path, "def core():\n return 2\n").expect("rewrite"); + + let ticked = handle_daemon_tick( + &mut state, + layers::DaemonTickInput { + agent_id: "test".into(), + max_files: 8, + }, + ) + .expect("git tick"); + + assert_eq!(state.daemon_state.watch_backend, "git_native_fs"); + assert_eq!(ticked["changed_files_detected"], 1); + assert_eq!(ticked["files_reingested"], 1); + assert_eq!(state.daemon_state.last_git_changed_files, 1); + assert!(state.daemon_state.last_git_scan_ms.is_some()); + assert!(state.daemon_state.git_backend_error.is_none()); + } } diff --git a/m1nd-mcp/src/server.rs b/m1nd-mcp/src/server.rs index ad25783f..fee9251c 100644 --- a/m1nd-mcp/src/server.rs +++ b/m1nd-mcp/src/server.rs @@ -2494,7 +2494,12 @@ impl McpServer { ) { Ok(watcher) => { runtime.watcher = Some(watcher); - self.state.daemon_state.watch_backend = "native_fs".into(); + self.state.daemon_state.watch_backend = + if self.state.daemon_state.git_root.is_some() { + "git_native_fs".into() + } else { + "native_fs".into() + }; self.state.daemon_state.watch_backend_error = None; } Err(error) => { diff --git a/m1nd-mcp/src/session.rs b/m1nd-mcp/src/session.rs index 6bac957c..4d4e069d 100644 --- a/m1nd-mcp/src/session.rs +++ b/m1nd-mcp/src/session.rs @@ -185,6 +185,11 @@ pub struct DaemonRuntimeState { pub watch_events_seen: u64, pub watch_events_dropped: u64, pub last_watch_event_ms: Option, + pub git_root: Option, + pub git_since_ref: Option, + pub last_git_scan_ms: Option, + pub last_git_changed_files: usize, + pub git_backend_error: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] From fbe13e4b2d7c2995c08e78f9d0c04a00cab84e63 Mon Sep 17 00:00:00 2001 From: max kle1nz Date: Sun, 5 Apr 2026 23:47:57 +0200 Subject: [PATCH 2/3] Defer Git-aware daemon ticks during live repo operations The daemon now detects in-progress Git operations such as merge, rebase, cherry-pick, and index-lock churn and defers Git-aware reconciliation instead of reacting mid-operation. This keeps the daemon from treating transient working-tree churn as meaningful drift while preserving the existing tick/status contract. Constraint: Preserve Git-aware reconciliation while avoiding false work during unstable repository transitions Rejected: Disabling Git-aware mode entirely during SCM operations | loses useful backend state and observability just when the daemon should explain itself Rejected: Forcing raw filesystem fallback during active merge/rebase churn | still risks noisy, misleading reconciliation during unstable states Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep Git operation detection advisory and status-visible so future Watchman/Sapling adapters can reuse the same defer contract Tested: cargo fmt --check; cargo test -p m1nd-mcp daemon_start_detects_git_root_and_head -- --nocapture; cargo test -p m1nd-mcp daemon_tick_uses_git_changed_set_when_available -- --nocapture; cargo test -p m1nd-mcp daemon_tick_defers_when_git_operation_is_in_progress -- --nocapture; MCP smoke for deferred merge state Not-tested: Long-running rebases/checkouts under concurrent watcher traffic --- m1nd-mcp/src/daemon_handlers.rs | 136 ++++++++++++++++++++++++++++++++ m1nd-mcp/src/session.rs | 3 + 2 files changed, 139 insertions(+) diff --git a/m1nd-mcp/src/daemon_handlers.rs b/m1nd-mcp/src/daemon_handlers.rs index b205390c..8d01beaa 100644 --- a/m1nd-mcp/src/daemon_handlers.rs +++ b/m1nd-mcp/src/daemon_handlers.rs @@ -217,6 +217,24 @@ fn git_changed_absolute_paths( Ok(changed) } +fn git_operation_in_progress(root: &Path) -> Option { + let git_dir = root.join(".git"); + let checks = [ + ("rebase-merge", "rebase"), + ("rebase-apply", "rebase"), + ("MERGE_HEAD", "merge"), + ("CHERRY_PICK_HEAD", "cherry-pick"), + ("BISECT_LOG", "bisect"), + ("index.lock", "index-lock"), + ]; + for (relative, kind) in checks { + if git_dir.join(relative).exists() { + return Some(kind.to_string()); + } + } + None +} + pub fn handle_daemon_start( state: &mut SessionState, input: layers::DaemonStartInput, @@ -262,6 +280,9 @@ pub fn handle_daemon_start( state.daemon_state.last_git_scan_ms = None; state.daemon_state.last_git_changed_files = 0; state.daemon_state.git_backend_error = None; + state.daemon_state.git_operation_in_progress = false; + state.daemon_state.git_operation_kind = None; + state.daemon_state.deferred_ticks = 0; if state.daemon_state.git_root.is_some() { state.daemon_state.watch_backend = "git_native_fs".into(); } @@ -277,6 +298,8 @@ pub fn handle_daemon_start( "watch_backend": state.daemon_state.watch_backend, "git_root": state.daemon_state.git_root, "git_since_ref": state.daemon_state.git_since_ref, + "git_operation_in_progress": state.daemon_state.git_operation_in_progress, + "git_operation_kind": state.daemon_state.git_operation_kind, })) } @@ -339,6 +362,9 @@ pub fn handle_daemon_status( "last_git_scan_ms": state.daemon_state.last_git_scan_ms, "last_git_changed_files": state.daemon_state.last_git_changed_files, "git_backend_error": state.daemon_state.git_backend_error, + "git_operation_in_progress": state.daemon_state.git_operation_in_progress, + "git_operation_kind": state.daemon_state.git_operation_kind, + "deferred_ticks": state.daemon_state.deferred_ticks, "pending_rerun": state.daemon_state.pending_rerun, "tick_in_flight": state.daemon_state.tick_in_flight, "last_coalesced_event_ms": state.daemon_state.last_coalesced_event_ms, @@ -376,6 +402,35 @@ pub fn handle_daemon_tick( if state.daemon_state.watch_backend == "git_native_fs" { if let Some(root) = state.daemon_state.git_root.clone() { + if let Some(kind) = git_operation_in_progress(Path::new(&root)) { + state.daemon_state.git_operation_in_progress = true; + state.daemon_state.git_operation_kind = Some(kind); + state.daemon_state.deferred_ticks = + state.daemon_state.deferred_ticks.saturating_add(1); + state.daemon_state.last_tick_trigger = Some("reconciliation".into()); + state.daemon_state.last_tick_ms = Some(now_ms()); + state.daemon_state.tick_count = state.daemon_state.tick_count.saturating_add(1); + state.daemon_state.last_tick_duration_ms = + Some(start.elapsed().as_secs_f64() * 1000.0); + state.daemon_state.last_tick_changed_files = 0; + state.daemon_state.last_tick_deleted_files = 0; + state.daemon_state.last_tick_alerts_emitted = 0; + state.persist_daemon_state()?; + return Ok(json!({ + "active": true, + "status": "deferred", + "deferred_reason": state.daemon_state.git_operation_kind, + "changed_files_detected": 0, + "deleted_files_detected": 0, + "files_reingested": 0, + "ingested_files": [], + "deleted_files": [], + "alerts_emitted": 0, + "alert_ids": [], + })); + } + state.daemon_state.git_operation_in_progress = false; + state.daemon_state.git_operation_kind = None; match git_changed_absolute_paths( Path::new(&root), state.daemon_state.git_since_ref.as_deref(), @@ -1166,4 +1221,85 @@ mod tests { assert!(state.daemon_state.last_git_scan_ms.is_some()); assert!(state.daemon_state.git_backend_error.is_none()); } + + #[test] + fn daemon_tick_defers_when_git_operation_is_in_progress() { + let (temp, mut state) = build_state(); + let repo = temp.path().join("repo"); + std::fs::create_dir_all(repo.join("src")).expect("repo src"); + let file_path = repo.join("src/core.py"); + std::fs::write(&file_path, "def core():\n return 1\n").expect("write"); + + Command::new("git") + .args(["init"]) + .current_dir(&repo) + .output() + .expect("git init"); + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo) + .output() + .expect("git email"); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&repo) + .output() + .expect("git name"); + Command::new("git") + .args(["add", "."]) + .current_dir(&repo) + .output() + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&repo) + .output() + .expect("git commit"); + + crate::tools::handle_ingest( + &mut state, + crate::protocol::IngestInput { + path: repo.to_string_lossy().to_string(), + agent_id: "test".into(), + mode: "replace".into(), + incremental: false, + adapter: "code".into(), + namespace: None, + include_dotfiles: false, + dotfile_patterns: Vec::new(), + }, + ) + .expect("initial ingest"); + + handle_daemon_start( + &mut state, + layers::DaemonStartInput { + agent_id: "test".into(), + watch_paths: vec![repo.to_string_lossy().to_string()], + poll_interval_ms: 200, + }, + ) + .expect("daemon start"); + + std::fs::write(repo.join(".git").join("MERGE_HEAD"), "deadbeef\n").expect("merge head"); + + let ticked = handle_daemon_tick( + &mut state, + layers::DaemonTickInput { + agent_id: "test".into(), + max_files: 8, + }, + ) + .expect("deferred tick"); + + assert_eq!(state.daemon_state.watch_backend, "git_native_fs"); + assert_eq!(ticked["status"], "deferred"); + assert_eq!(ticked["files_reingested"], 0); + assert_eq!(state.daemon_state.git_operation_in_progress, true); + assert_eq!( + state.daemon_state.git_operation_kind.as_deref(), + Some("merge") + ); + assert!(state.daemon_state.deferred_ticks >= 1); + } } diff --git a/m1nd-mcp/src/session.rs b/m1nd-mcp/src/session.rs index 4d4e069d..452f9e57 100644 --- a/m1nd-mcp/src/session.rs +++ b/m1nd-mcp/src/session.rs @@ -190,6 +190,9 @@ pub struct DaemonRuntimeState { pub last_git_scan_ms: Option, pub last_git_changed_files: usize, pub git_backend_error: Option, + pub git_operation_in_progress: bool, + pub git_operation_kind: Option, + pub deferred_ticks: u64, } #[derive(Clone, Debug, Serialize, Deserialize)] From 4f88a548e356a5399df2f411bfd818dc64d3f6a4 Mon Sep 17 00:00:00 2001 From: max kle1nz Date: Mon, 6 Apr 2026 00:04:15 +0200 Subject: [PATCH 3/3] Seed Git-aware daemon baselines from merge-base when possible The Git-aware daemon adapter now prefers a merge-base baseline against the configured upstream when available, falling back to HEAD otherwise. This makes the initial changed-set cursor upstream-aware without changing the daemon's public MCP surface. Constraint: Improve SCM-aware changed-set quality without introducing a full Watchman protocol or altering daemon_tick authority Rejected: Always baseline from HEAD | loses useful repo semantics for feature branches tracking an upstream branch Rejected: Introducing a separate baseline-selection tool | unnecessary public surface for adapter-internal policy Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep baseline selection adapter-local until richer SCM backends share the same contract Tested: cargo fmt --check; cargo test -p m1nd-mcp daemon_start_detects_git_root_and_head -- --nocapture; cargo test -p m1nd-mcp daemon_start_prefers_merge_base_when_upstream_exists -- --nocapture; MCP smoke for merge_base startup state Not-tested: Non-Git SCMs and dynamic upstream changes during long-running daemon sessions --- m1nd-mcp/src/daemon_handlers.rs | 157 +++++++++++++++++++++++++++++++- m1nd-mcp/src/session.rs | 2 + 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/m1nd-mcp/src/daemon_handlers.rs b/m1nd-mcp/src/daemon_handlers.rs index 8d01beaa..15c4af86 100644 --- a/m1nd-mcp/src/daemon_handlers.rs +++ b/m1nd-mcp/src/daemon_handlers.rs @@ -178,6 +178,57 @@ fn git_head_ref(root: &Path) -> Option { } } +fn git_upstream_ref(root: &Path) -> Option { + let output = Command::new("git") + .args([ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]) + .current_dir(root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +fn git_merge_base(root: &Path, lhs: &str, rhs: &str) -> Option { + let output = Command::new("git") + .args(["merge-base", lhs, rhs]) + .current_dir(root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +fn git_initial_baseline(root: &Path) -> (Option, Option, Option) { + let head = git_head_ref(root); + let upstream = git_upstream_ref(root); + if let (Some(head_ref), Some(upstream_ref)) = (head.as_deref(), upstream.as_deref()) { + if let Some(merge_base) = git_merge_base(root, head_ref, upstream_ref) { + return (Some(merge_base), Some("merge_base".to_string()), upstream); + } + } + + (head, Some("head".to_string()), upstream) +} + fn git_changed_absolute_paths( root: &Path, since_ref: Option<&str>, @@ -272,11 +323,15 @@ pub fn handle_daemon_start( state.daemon_state.last_watch_event_ms = None; state.daemon_state.git_root = git_root_for_watch_paths(&state.daemon_state.watch_paths) .map(|root| root.to_string_lossy().to_string()); - state.daemon_state.git_since_ref = state + let (git_baseline_ref, git_baseline_kind, _git_upstream_ref) = state .daemon_state .git_root .as_deref() - .and_then(|root| git_head_ref(Path::new(root))); + .map(|root| git_initial_baseline(Path::new(root))) + .unwrap_or((None, None, None)); + state.daemon_state.git_baseline_ref = git_baseline_ref.clone(); + state.daemon_state.git_baseline_kind = git_baseline_kind; + state.daemon_state.git_since_ref = git_baseline_ref; state.daemon_state.last_git_scan_ms = None; state.daemon_state.last_git_changed_files = 0; state.daemon_state.git_backend_error = None; @@ -297,6 +352,8 @@ pub fn handle_daemon_start( "tracked_files": state.daemon_state.tracked_files.len(), "watch_backend": state.daemon_state.watch_backend, "git_root": state.daemon_state.git_root, + "git_baseline_ref": state.daemon_state.git_baseline_ref, + "git_baseline_kind": state.daemon_state.git_baseline_kind, "git_since_ref": state.daemon_state.git_since_ref, "git_operation_in_progress": state.daemon_state.git_operation_in_progress, "git_operation_kind": state.daemon_state.git_operation_kind, @@ -358,6 +415,8 @@ pub fn handle_daemon_status( "watch_events_dropped": state.daemon_state.watch_events_dropped, "last_watch_event_ms": state.daemon_state.last_watch_event_ms, "git_root": state.daemon_state.git_root, + "git_baseline_ref": state.daemon_state.git_baseline_ref, + "git_baseline_kind": state.daemon_state.git_baseline_kind, "git_since_ref": state.daemon_state.git_since_ref, "last_git_scan_ms": state.daemon_state.last_git_scan_ms, "last_git_changed_files": state.daemon_state.last_git_changed_files, @@ -1142,6 +1201,100 @@ mod tests { assert_eq!(started["watch_backend"], "git_native_fs"); assert!(started["git_root"].as_str().is_some()); assert!(started["git_since_ref"].as_str().is_some()); + assert!(started["git_baseline_ref"].as_str().is_some()); + assert_eq!(started["git_baseline_kind"], "head"); + } + + #[test] + fn daemon_start_prefers_merge_base_when_upstream_exists() { + let (temp, mut state) = build_state(); + let remote = temp.path().join("remote.git"); + let seed = temp.path().join("seed"); + std::fs::create_dir_all(seed.join("src")).expect("seed src"); + std::fs::write(seed.join("src/core.py"), "def core():\n return 1\n").expect("write"); + + Command::new("git") + .args(["init", "--bare", remote.to_string_lossy().as_ref()]) + .output() + .expect("bare init"); + + Command::new("git") + .args(["init"]) + .current_dir(&seed) + .output() + .expect("git init seed"); + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&seed) + .output() + .expect("git email"); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&seed) + .output() + .expect("git name"); + Command::new("git") + .args(["add", "."]) + .current_dir(&seed) + .output() + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&seed) + .output() + .expect("git commit"); + Command::new("git") + .args(["branch", "-M", "main"]) + .current_dir(&seed) + .output() + .expect("branch main"); + Command::new("git") + .args(["remote", "add", "origin", remote.to_string_lossy().as_ref()]) + .current_dir(&seed) + .output() + .expect("remote add"); + Command::new("git") + .args(["push", "-u", "origin", "main"]) + .current_dir(&seed) + .output() + .expect("push main"); + + Command::new("git") + .args(["checkout", "-b", "feature"]) + .current_dir(&seed) + .output() + .expect("feature branch"); + Command::new("git") + .args(["branch", "--set-upstream-to", "origin/main"]) + .current_dir(&seed) + .output() + .expect("set upstream"); + std::fs::write(seed.join("src/core.py"), "def core():\n return 2\n").expect("rewrite"); + Command::new("git") + .args(["add", "."]) + .current_dir(&seed) + .output() + .expect("add feature"); + Command::new("git") + .args(["commit", "-m", "feature"]) + .current_dir(&seed) + .output() + .expect("commit feature"); + + let started = handle_daemon_start( + &mut state, + layers::DaemonStartInput { + agent_id: "test".into(), + watch_paths: vec![seed.to_string_lossy().to_string()], + poll_interval_ms: 200, + }, + ) + .expect("daemon start"); + + assert_eq!(started["watch_backend"], "git_native_fs"); + assert_eq!(started["git_baseline_kind"], "merge_base"); + assert!(started["git_baseline_ref"].as_str().is_some()); + assert!(started["git_since_ref"].as_str().is_some()); } #[test] diff --git a/m1nd-mcp/src/session.rs b/m1nd-mcp/src/session.rs index 452f9e57..f04b2ac4 100644 --- a/m1nd-mcp/src/session.rs +++ b/m1nd-mcp/src/session.rs @@ -186,6 +186,8 @@ pub struct DaemonRuntimeState { pub watch_events_dropped: u64, pub last_watch_event_ms: Option, pub git_root: Option, + pub git_baseline_ref: Option, + pub git_baseline_kind: Option, pub git_since_ref: Option, pub last_git_scan_ms: Option, pub last_git_changed_files: usize,