use super::basic::run_git;
use super::status_policy::{
read_only_status_argv, CHANGE_MONITOR_STATUS_ARGS, PORCELAIN_STATUS_ARGS,
};
use crate::events::CommitOutputStream;
use crate::vcs::commands::{run_vcs_command_captured, run_vcs_command_streamed, VcsCommandOutput};
use crate::vcs::{CommitRejection, VcsBackend, VcsError, VcsResult, VerifiedCommitOutcome};
use std::path::Path;
use tracing::debug;
pub type CommitOutputSink<'a> = &'a (dyn Fn(CommitOutputStream, &str) + Send + Sync);
pub const GIT_REPOSITORY_REJECTION_EXIT_CODE: i32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifiedCommitMode {
AddAndCommit,
Amend,
}
pub fn verified_commit_args(mode: VerifiedCommitMode, message: &str) -> Vec<String> {
let mut args = vec!["commit".to_string()];
if mode == VerifiedCommitMode::Amend {
args.push("--amend".to_string());
args.push("--allow-empty".to_string());
}
args.push("-m".to_string());
args.push(message.to_string());
args
}
pub fn classify_verified_commit_output(
output: VcsCommandOutput,
working_dir: &Path,
) -> VcsResult<VerifiedCommitOutcome> {
if output.success {
return Ok(VerifiedCommitOutcome::Committed);
}
if output.exit_code == Some(GIT_REPOSITORY_REJECTION_EXIT_CODE) {
debug!(
"Verified commit rejected by repository verification: {}",
output.command
);
return Ok(VerifiedCommitOutcome::RepositoryRejected(CommitRejection {
command: output.command,
exit_code: output.exit_code,
stdout: output.stdout,
stderr: output.stderr,
}));
}
Err(VcsError::Command {
backend: VcsBackend::Git,
message: format!("{} failed: {}", output.command, output.stderr),
command: Some(output.command),
working_dir: Some(working_dir.to_path_buf()),
stderr: Some(output.stderr),
stdout: Some(output.stdout),
})
}
async fn run_verified_commit<P: AsRef<Path>>(
cwd: P,
args: &[String],
sink: Option<CommitOutputSink<'_>>,
) -> VcsResult<VerifiedCommitOutcome> {
debug_assert!(
!args.iter().any(|arg| arg == "--no-verify"),
"verified commits must run repository hooks"
);
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
let output = match sink {
Some(sink) => {
run_vcs_command_streamed(
"git",
&arg_refs,
cwd.as_ref(),
VcsBackend::Git,
|stream, line| sink(stream, line),
)
.await?
}
None => run_vcs_command_captured("git", &arg_refs, cwd.as_ref(), VcsBackend::Git).await?,
};
classify_verified_commit_output(output, cwd.as_ref())
}
pub async fn create_verified_commit<P: AsRef<Path>>(
cwd: P,
message: &str,
) -> VcsResult<VerifiedCommitOutcome> {
create_verified_commit_streamed(cwd, message, None).await
}
pub async fn create_verified_commit_streamed<P: AsRef<Path>>(
cwd: P,
message: &str,
sink: Option<CommitOutputSink<'_>>,
) -> VcsResult<VerifiedCommitOutcome> {
let mode = if has_changes_to_commit(&cwd).await? {
run_git(&["add", "-A"], &cwd).await?;
validate_staged_snapshot(&cwd).await?;
VerifiedCommitMode::AddAndCommit
} else {
VerifiedCommitMode::Amend
};
run_verified_commit(&cwd, &verified_commit_args(mode, message), sink).await
}
fn staged_snapshot_anomaly<'a>(paths: impl Iterator<Item = &'a str>) -> Option<String> {
const MAX_STAGED_FILES: usize = 5_000;
let mut by_top_level = std::collections::BTreeMap::<&str, usize>::new();
let mut total = 0;
for path in paths {
total += 1;
*by_top_level
.entry(path.split('/').next().unwrap_or(path))
.or_default() += 1;
}
if let Some((directory, count)) = by_top_level
.iter()
.find(|(directory, _)| directory.starts_with(".agent-target"))
{
return Some(format!("forbidden temporary path {directory}: {count}"));
}
if total <= MAX_STAGED_FILES {
return None;
}
let summary = by_top_level
.iter()
.take(20)
.map(|(directory, count)| format!("{directory}: {count}"))
.collect::<Vec<_>>()
.join(", ");
Some(format!("{total} staged files ({summary})"))
}
pub async fn validate_staged_snapshot<P: AsRef<Path>>(cwd: P) -> VcsResult<()> {
let cwd = cwd.as_ref();
let output = run_git(
&[
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR",
"-z",
],
cwd,
)
.await?;
let paths = output
.split('\0')
.filter(|path| !path.is_empty())
.collect::<Vec<_>>();
let Some(summary) = staged_snapshot_anomaly(paths.iter().copied()) else {
return Ok(());
};
let _ = run_git(&["reset"], cwd).await;
Err(VcsError::git_command(format!(
"Refusing suspicious snapshot ({summary}); staged changes were reset"
)))
}
pub async fn create_archive_wip_commit<P: AsRef<Path>>(
cwd: P,
change_id: &str,
attempt: u32,
) -> VcsResult<()> {
let message = format!("WIP(archive): {} (attempt#{})", change_id, attempt);
run_git(&["add", "-A"], &cwd).await?;
validate_staged_snapshot(&cwd).await?;
run_git(&["commit", "--allow-empty", "-m", &message], cwd).await?;
Ok(())
}
pub async fn squash_archive_wip_commits<P: AsRef<Path>>(cwd: P, change_id: &str) -> VcsResult<()> {
let wip_pattern = format!("WIP(archive): {}", change_id);
let wip_commits = run_git(
&[
"rev-list",
"--reverse",
"--grep",
&wip_pattern,
"--fixed-strings",
"HEAD",
],
&cwd,
)
.await?;
let first_wip = wip_commits
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.ok_or_else(|| {
VcsError::git_command(format!("No archive WIP commits found for {}", change_id))
})?;
let parent_revision = run_git(&["rev-parse", &format!("{}^", first_wip)], &cwd).await?;
let parent_revision = parent_revision.trim();
run_git(&["reset", "--soft", parent_revision], &cwd).await?;
validate_staged_snapshot(&cwd).await?;
let archive_message = format!("Archive: {}", change_id);
run_git(&["commit", "--allow-empty", "-m", &archive_message], cwd).await?;
Ok(())
}
pub async fn list_changes_in_head<P: AsRef<Path>>(cwd: P) -> VcsResult<Vec<String>> {
let cwd_ref = cwd.as_ref();
let output = run_git(
&["ls-tree", "-d", "--name-only", "HEAD:openspec/changes"],
cwd_ref,
)
.await?;
let mut change_ids: Vec<String> = Vec::new();
for name in output.lines().map(str::trim) {
if name.is_empty() || name == "archive" || name.starts_with('.') {
continue;
}
let proposal_path = format!("HEAD:openspec/changes/{}/proposal.md", name);
match run_git(&["cat-file", "-e", &proposal_path], cwd_ref).await {
Ok(_) => {
change_ids.push(name.to_string());
}
Err(_) => {
debug!("Skipping change '{}' in HEAD - no proposal.md found", name);
}
}
}
change_ids.sort();
Ok(change_ids)
}
#[allow(dead_code)]
pub async fn has_changes_to_commit<P: AsRef<Path>>(cwd: P) -> VcsResult<bool> {
let output = run_git(&read_only_status_argv(PORCELAIN_STATUS_ARGS), cwd).await?;
Ok(!output.is_empty())
}
fn uncommitted_monitor_argv() -> Vec<&'static str> {
read_only_status_argv(CHANGE_MONITOR_STATUS_ARGS)
}
pub async fn list_changes_with_uncommitted_files<P: AsRef<Path>>(cwd: P) -> VcsResult<Vec<String>> {
let cwd_ref = cwd.as_ref();
let output = run_git(&uncommitted_monitor_argv(), cwd_ref).await?;
let mut change_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
let bytes = line.as_bytes();
let path_field = if bytes.len() >= 3 && bytes[2] == b' ' {
&line[3..]
} else if bytes.len() >= 2 && bytes[1] == b' ' {
&line[2..]
} else {
continue;
};
let path = path_field
.split_once(" -> ")
.map(|(_, new_path)| new_path)
.unwrap_or(path_field)
.trim();
if let Some(change_id) = extract_change_id_from_path(path) {
change_ids.insert(change_id);
}
}
let mut result: Vec<String> = change_ids.into_iter().collect();
result.sort();
Ok(result)
}
fn extract_change_id_from_path(path: &str) -> Option<String> {
let normalized_path = path.replace('\\', "/");
let prefix = "openspec/changes/";
if !normalized_path.starts_with(prefix) {
return None;
}
let remainder = &normalized_path[prefix.len()..];
let change_id = if let Some(pos) = remainder.find('/') {
&remainder[..pos]
} else {
remainder
};
if change_id.is_empty() || change_id == "archive" || change_id.starts_with('.') {
return None;
}
Some(change_id.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::process::Command;
#[tokio::test]
async fn test_list_changes_in_head_filters_special_dirs() {
let temp_dir = TempDir::new().unwrap();
debug!(
module = module_path!(),
"Executing git command: git init (cwd: {:?})",
temp_dir.path()
);
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return;
}
debug!(
module = module_path!(),
"Executing git command: git config user.email test@example.com (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
debug!(
module = module_path!(),
"Executing git command: git config user.name Test User (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
let base_dir = temp_dir.path().join("openspec/changes");
std::fs::create_dir_all(base_dir.join("change-b")).unwrap();
std::fs::create_dir_all(base_dir.join("change-a")).unwrap();
std::fs::create_dir_all(base_dir.join("archive")).unwrap();
std::fs::create_dir_all(base_dir.join(".hidden")).expect("create hidden dir");
std::fs::write(base_dir.join("change-a").join("proposal.md"), "test").unwrap();
std::fs::write(base_dir.join("change-b").join("proposal.md"), "test").unwrap();
std::fs::write(base_dir.join("archive").join("proposal.md"), "test").unwrap();
debug!(
module = module_path!(),
"Executing git command: git add . (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
debug!(
module = module_path!(),
"Executing git command: git commit -m add changes (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["commit", "-m", "add changes"])
.current_dir(temp_dir.path())
.output()
.await;
let changes = list_changes_in_head(temp_dir.path()).await.unwrap();
assert_eq!(
changes,
vec!["change-a".to_string(), "change-b".to_string()]
);
}
#[tokio::test]
async fn test_list_changes_in_head_excludes_without_proposal() {
let temp_dir = TempDir::new().unwrap();
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
let base_dir = temp_dir.path().join("openspec/changes");
std::fs::create_dir_all(base_dir.join("change-a")).unwrap();
std::fs::write(base_dir.join("change-a").join("proposal.md"), "test").unwrap();
std::fs::write(base_dir.join("change-a").join("tasks.md"), "- [ ] Task 1").unwrap();
std::fs::create_dir_all(base_dir.join("change-b")).unwrap();
std::fs::write(base_dir.join("change-b").join("tasks.md"), "- [ ] Task 1").unwrap();
std::fs::create_dir_all(base_dir.join("change-c")).unwrap();
std::fs::write(base_dir.join("change-c").join("proposal.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "add changes"])
.current_dir(temp_dir.path())
.output()
.await;
let changes = list_changes_in_head(temp_dir.path()).await.unwrap();
assert_eq!(
changes,
vec!["change-a".to_string(), "change-c".to_string()]
);
}
#[tokio::test]
async fn test_list_changes_with_uncommitted_files() {
let temp_dir = TempDir::new().unwrap();
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
let base_dir = temp_dir.path().join("openspec/changes");
std::fs::create_dir_all(base_dir.join("change-a")).unwrap();
std::fs::write(base_dir.join("change-a").join("proposal.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "add change-a"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::create_dir_all(base_dir.join("change-b")).unwrap();
std::fs::write(base_dir.join("change-b").join("proposal.md"), "uncommitted").unwrap();
std::fs::write(base_dir.join("change-a").join("tasks.md"), "new task").unwrap();
let _ = Command::new("git")
.args(["add", "openspec/changes/change-a/tasks.md"])
.current_dir(temp_dir.path())
.output()
.await;
let uncommitted_changes = list_changes_with_uncommitted_files(temp_dir.path())
.await
.unwrap();
assert_eq!(
uncommitted_changes,
vec!["change-a".to_string(), "change-b".to_string()]
);
}
#[tokio::test]
async fn test_list_changes_with_uncommitted_files_filters_archive() {
let temp_dir = TempDir::new().unwrap();
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
let base_dir = temp_dir.path().join("openspec/changes");
std::fs::create_dir_all(&base_dir).unwrap();
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::create_dir_all(base_dir.join("archive")).unwrap();
std::fs::write(base_dir.join("archive").join("old.md"), "archived").unwrap();
std::fs::create_dir_all(base_dir.join("change-c")).unwrap();
std::fs::write(base_dir.join("change-c").join("proposal.md"), "test").unwrap();
let uncommitted_changes = list_changes_with_uncommitted_files(temp_dir.path())
.await
.unwrap();
assert_eq!(uncommitted_changes, vec!["change-c".to_string()]);
}
#[tokio::test]
async fn test_list_changes_with_uncommitted_files_detects_unstaged_modified_first_line() {
let temp_dir = TempDir::new().unwrap();
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
let change_dir = temp_dir.path().join("openspec/changes/change-a");
std::fs::create_dir_all(&change_dir).unwrap();
std::fs::write(change_dir.join("proposal.md"), "test").unwrap();
std::fs::write(change_dir.join("tasks.md"), "- [ ] task").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "add change-a"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(change_dir.join("tasks.md"), "- [x] task").unwrap();
let uncommitted_changes = list_changes_with_uncommitted_files(temp_dir.path())
.await
.unwrap();
assert_eq!(uncommitted_changes, vec!["change-a".to_string()]);
}
#[test]
fn staged_snapshot_anomaly_rejects_thousands_of_files() {
let paths = (0..5_001)
.map(|index| format!("generated/artifact-{index}"))
.collect::<Vec<_>>();
let anomaly = staged_snapshot_anomaly(paths.iter().map(String::as_str));
assert!(anomaly.is_some());
assert!(anomaly.unwrap().contains("5001 staged files"));
}
#[test]
fn staged_snapshot_anomaly_rejects_temporary_paths() {
assert!(staged_snapshot_anomaly([".agent-target/debug/artifact"].into_iter()).is_some());
}
#[test]
fn staged_snapshot_anomaly_allows_large_legitimate_changes() {
let paths = (0..5_000)
.map(|index| format!("src/generated-{index}.rs"))
.collect::<Vec<_>>();
assert!(staged_snapshot_anomaly(paths.iter().map(String::as_str)).is_none());
}
#[tokio::test]
async fn validate_staged_snapshot_resets_suspicious_index() {
let temp_dir = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
let generated = temp_dir.path().join(".agent-target");
std::fs::create_dir(&generated).unwrap();
std::fs::write(generated.join("artifact"), "x").unwrap();
run_git(&["add", "-A"], temp_dir.path()).await.unwrap();
let error = validate_staged_snapshot(temp_dir.path()).await.unwrap_err();
let staged = run_git(&["diff", "--cached", "--name-only"], temp_dir.path())
.await
.unwrap();
assert!(error.to_string().contains("forbidden temporary path"));
assert!(staged.is_empty());
}
#[tokio::test]
async fn validate_staged_snapshot_supports_non_ascii_paths() {
let temp_dir = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
std::fs::write(temp_dir.path().join(" 日本語.rs"), "fn main() {}").unwrap();
run_git(&["add", "-A"], temp_dir.path()).await.unwrap();
validate_staged_snapshot(temp_dir.path()).await.unwrap();
}
#[tokio::test]
async fn archive_wip_rejects_suspicious_snapshots() {
let temp_dir = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
let generated = temp_dir.path().join(".agent-target");
std::fs::create_dir(&generated).unwrap();
std::fs::write(generated.join("artifact"), "x").unwrap();
assert!(create_archive_wip_commit(temp_dir.path(), "change-a", 1)
.await
.is_err());
}
#[test]
fn test_extract_change_id_from_path() {
assert_eq!(
extract_change_id_from_path("openspec/changes/my-change/proposal.md"),
Some("my-change".to_string())
);
assert_eq!(
extract_change_id_from_path("openspec/changes/my-change/specs/auth/spec.md"),
Some("my-change".to_string())
);
assert_eq!(
extract_change_id_from_path("openspec/changes/archive/old.md"),
None
);
assert_eq!(
extract_change_id_from_path("openspec/changes/.hidden/file.md"),
None
);
assert_eq!(extract_change_id_from_path("src/main.rs"), None);
assert_eq!(
extract_change_id_from_path("openspec/specs/auth/spec.md"),
None
);
}
#[test]
fn uncommitted_monitor_argv_disables_optional_locks_before_subcommand() {
let argv = uncommitted_monitor_argv();
assert_eq!(
argv,
["--no-optional-locks", "status", "--porcelain", "-u"],
"monitoring argv must stay exactly `--no-optional-locks status --porcelain -u`"
);
let option_index = argv
.iter()
.position(|arg| *arg == "--no-optional-locks")
.expect("monitoring argv must disable optional locks");
let subcommand_index = argv
.iter()
.position(|arg| *arg == "status")
.expect("monitoring argv must run the status subcommand");
assert!(
option_index < subcommand_index,
"`--no-optional-locks` is a global option and must precede `status`"
);
assert!(
std::env::var_os("GIT_OPTIONAL_LOCKS").is_none(),
"optional-lock suppression must not be delivered through process-wide environment state"
);
}
async fn git_fixture(cwd: &Path, args: &[&str]) -> String {
run_git(args, cwd)
.await
.unwrap_or_else(|error| panic!("git fixture command {args:?} failed: {error}"))
}
async fn init_fixture_repo(cwd: &Path) {
git_fixture(cwd, &["init", "-q"]).await;
git_fixture(cwd, &["config", "user.email", "test@example.com"]).await;
git_fixture(cwd, &["config", "user.name", "Test User"]).await;
git_fixture(cwd, &["config", "commit.gpgsign", "false"]).await;
}
fn write_fixture_file(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().expect("fixture path has a parent"))
.expect("create fixture directory");
std::fs::write(path, contents).expect("write fixture file");
}
#[tokio::test]
async fn monitoring_classifies_active_changes_and_keeps_exclusions() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
init_fixture_repo(root).await;
let changes = root.join("openspec/changes");
write_fixture_file(&changes.join("change-staged-modify/proposal.md"), "base");
write_fixture_file(&changes.join("change-staged-delete/proposal.md"), "base");
write_fixture_file(&changes.join("change-unstaged-modify/proposal.md"), "base");
write_fixture_file(&changes.join("change-unstaged-delete/proposal.md"), "base");
write_fixture_file(&changes.join("change-rename/old.md"), "base");
write_fixture_file(&changes.join("change-clean/proposal.md"), "base");
write_fixture_file(&changes.join("change-ignored/proposal.md"), "base");
write_fixture_file(&root.join("docs/notes.md"), "base");
write_fixture_file(&root.join(".gitignore"), "*.log\n");
git_fixture(root, &["add", "-A"]).await;
git_fixture(root, &["commit", "-qm", "baseline"]).await;
write_fixture_file(&changes.join("change-staged-add/proposal.md"), "added");
git_fixture(
root,
&["add", "openspec/changes/change-staged-add/proposal.md"],
)
.await;
write_fixture_file(
&changes.join("change-staged-modify/proposal.md"),
"modified",
);
git_fixture(
root,
&["add", "openspec/changes/change-staged-modify/proposal.md"],
)
.await;
git_fixture(
root,
&[
"rm",
"-q",
"openspec/changes/change-staged-delete/proposal.md",
],
)
.await;
write_fixture_file(
&changes.join("change-unstaged-modify/proposal.md"),
"modified",
);
std::fs::remove_file(changes.join("change-unstaged-delete/proposal.md"))
.expect("remove fixture file");
write_fixture_file(&changes.join("change-untracked/proposal.md"), "untracked");
git_fixture(
root,
&[
"mv",
"openspec/changes/change-rename/old.md",
"openspec/changes/change-rename/new.md",
],
)
.await;
write_fixture_file(&changes.join("archive/change-old/proposal.md"), "archived");
write_fixture_file(&changes.join(".hidden-change/proposal.md"), "hidden");
write_fixture_file(&changes.join("change-ignored/debug.log"), "ignored");
write_fixture_file(&root.join("docs/notes.md"), "unrelated change");
let uncommitted = list_changes_with_uncommitted_files(root).await.unwrap();
assert_eq!(
uncommitted,
vec![
"change-rename".to_string(),
"change-staged-add".to_string(),
"change-staged-delete".to_string(),
"change-staged-modify".to_string(),
"change-unstaged-delete".to_string(),
"change-unstaged-modify".to_string(),
"change-untracked".to_string(),
],
"classification drifted; raw status was: {:?}",
run_git(&uncommitted_monitor_argv(), root).await.unwrap()
);
}
#[tokio::test]
async fn monitoring_does_not_persist_optional_index_refresh() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
init_fixture_repo(root).await;
let changes = root.join("openspec/changes");
write_fixture_file(&changes.join("change-a/proposal.md"), "base");
write_fixture_file(&changes.join("change-a/tasks.md"), "- [ ] task\n");
git_fixture(root, &["add", "-A"]).await;
git_fixture(root, &["commit", "-qm", "baseline"]).await;
write_fixture_file(
&changes.join("change-a/tasks.md"),
"- [x] task\n- [ ] second task\n",
);
let stale_target = changes.join("change-a/proposal.md");
let stale_time = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
std::fs::File::options()
.write(true)
.open(&stale_target)
.expect("open fixture file")
.set_times(std::fs::FileTimes::new().set_modified(stale_time))
.expect("backdate fixture mtime");
let index_path = root.join(".git/index");
let stale_index = std::fs::read(&index_path).expect("read index");
git_fixture(root, &["status", "--porcelain", "-u"]).await;
let control_index = std::fs::read(&index_path).expect("read index after control");
assert_ne!(
stale_index, control_index,
"fixture cannot detect an optional index refresh; the index-safety assertion would be vacuous"
);
std::fs::write(&index_path, &stale_index).expect("restore stale index");
let uncommitted = list_changes_with_uncommitted_files(root).await.unwrap();
let after_index = std::fs::read(&index_path).expect("read index after monitoring");
assert_eq!(
uncommitted,
vec!["change-a".to_string()],
"monitoring query must still report current worktree changes"
);
assert_eq!(
stale_index, after_index,
"monitoring query must leave the complete Git index bytes unchanged"
);
}
}