diff --git a/crates/forge-cli/src/commands/core.rs b/crates/forge-cli/src/commands/core.rs index c0195b2..1b6568f 100644 --- a/crates/forge-cli/src/commands/core.rs +++ b/crates/forge-cli/src/commands/core.rs @@ -8,9 +8,11 @@ use forge_protocol::{ }; use forge_store::ForgeError; use serde_json::{json, Value}; +use std::collections::HashSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use crate::args::*; use crate::schema; @@ -481,6 +483,82 @@ pub(crate) fn collect_diff_warnings( } } +fn native_git_drift_warnings(cwd: &Path, changed_paths: &[String]) -> Vec { + if changed_paths.is_empty() { + return Vec::new(); + } + + let Ok(context) = forge_store::open_repository(cwd) else { + return Vec::new(); + }; + if context.content_backend != "native" || context.worktree_path != context.root_path { + return Vec::new(); + } + + let Some(git_changed_paths) = git_status_changed_paths(&context.root_path) else { + return Vec::new(); + }; + let git_clean_paths: Vec<&str> = changed_paths + .iter() + .map(String::as_str) + .filter(|path| !git_changed_paths.contains(*path)) + .collect(); + if git_clean_paths.is_empty() { + return Vec::new(); + } + + let preview = git_clean_paths + .iter() + .take(5) + .copied() + .collect::>() + .join(", "); + let more = git_clean_paths.len().saturating_sub(5); + let more_suffix = if more == 0 { + String::new() + } else { + format!(" (+{more} more)") + }; + vec![format!( + "{} changed path(s) are clean in Git but changed relative to Forge native base: {}{}. This usually means Forge native history and Git HEAD have drifted; review these paths before accepting.", + git_clean_paths.len(), + preview, + more_suffix + )] +} + +fn git_status_changed_paths(repo_root: &Path) -> Option> { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let mut paths = HashSet::new(); + let mut entries = output + .stdout + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()); + while let Some(entry) = entries.next() { + if entry.len() < 4 { + continue; + } + let status = &entry[..2]; + let path = String::from_utf8_lossy(&entry[3..]).into_owned(); + paths.insert(path); + if status.contains(&b'R') || status.contains(&b'C') { + if let Some(source_path) = entries.next() { + paths.insert(String::from_utf8_lossy(source_path).into_owned()); + } + } + } + Some(paths) +} + pub(crate) fn save_response( request_id: Option, args: AttemptScopedArgs, @@ -517,10 +595,11 @@ pub(crate) fn save_response( // not yet checkpointed. On reopen, WAL recovery must show the committed ref // AND its durably-retained object. forge_content::maybe_crash("after_db_commit_before_checkpoint"); + let warnings = native_git_drift_warnings(&cwd, &saved.changed_paths); Ok(( Some(saved.operation_id.clone()), serde_json::to_value(saved)?, - Vec::new(), + warnings, )) }) } @@ -732,10 +811,11 @@ pub(crate) fn propose_response(request_id: Option, args: ProposeArgs) -> args.attempt.as_deref(), args.summary.as_deref(), )?; + let warnings = native_git_drift_warnings(&cwd, &proposal.changed_paths); Ok(( Some(proposal.operation_id.clone()), serde_json::to_value(proposal)?, - Vec::new(), + warnings, )) }) } diff --git a/crates/forge-cli/tests/forge_start_save.rs b/crates/forge-cli/tests/forge_start_save.rs index e20d2e5..8a39ac5 100644 --- a/crates/forge-cli/tests/forge_start_save.rs +++ b/crates/forge-cli/tests/forge_start_save.rs @@ -310,6 +310,61 @@ fn native_restore_refuses_unsaved_dirty_worktree() { ); } +#[test] +fn native_save_and_propose_warn_when_changed_paths_are_clean_in_git() { + let repo = TestRepo::new_git(); + repo.forge() + .args(["--json", "init", "--content-backend", "native"]) + .assert() + .success(); + repo.forge() + .args(["--json", "start", "native git drift"]) + .assert() + .success(); + + std::fs::write(repo.path().join("git_only.txt"), "committed only to git\n") + .expect("write git-only file"); + git(repo.path(), &["add", "git_only.txt"]); + git(repo.path(), &["commit", "-m", "git-only change"]); + std::fs::write(repo.path().join("README.md"), "actual attempt edit\n").expect("write readme"); + + let saved = json_output(repo.forge().args(["--json", "save"]).assert().success()); + assert!(saved["data"]["changed_paths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == "README.md")); + assert!(saved["data"]["changed_paths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == "git_only.txt")); + assert_native_git_drift_warning(&saved, "git_only.txt"); + assert!(!saved["warnings"][0].as_str().unwrap().contains("README.md")); + + let proposed = json_output( + repo.forge() + .args(["--json", "propose", "--summary", "native git drift"]) + .assert() + .success(), + ); + assert_native_git_drift_warning(&proposed, "git_only.txt"); +} + +fn assert_native_git_drift_warning(output: &Value, path: &str) { + let warnings = output["warnings"].as_array().expect("warnings array"); + assert!( + warnings.iter().any(|warning| { + warning.as_str().is_some_and(|message| { + message.contains("Forge native base") + && message.contains("clean in Git") + && message.contains(path) + }) + }), + "expected native/Git drift warning for {path}, got {warnings:?}" + ); +} + fn assert_native_objects_do_not_contain(repo_path: &std::path::Path, needle: &str) { let objects = repo_path.join(".forge/objects/sha256"); for prefix in std::fs::read_dir(objects).expect("object prefixes") {