use crate::sdd::change::archive::{archive_name_for, do_archive_rename, do_ff_merge};
use crate::sdd::project::config::load_required_config;
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::shared::ids::validate_sdd_id;
use anyhow::Result;
use std::path::Path;
#[cfg(test)]
use std::process::Command;
#[derive(Debug, Clone)]
pub struct FinalizeArgs {
pub change: String,
pub no_check: bool,
}
pub fn run_finalize(root: &Path, args: FinalizeArgs) -> Result<()> {
let change_name = crate::sdd::shared::discovery::resolve_change_id_human(root, &args.change)?;
validate_sdd_id(&change_name, "change")?;
let llmanspec = root.join(LLMANSPEC_DIR_NAME);
let _config = load_required_config(&llmanspec)?;
let mut binding =
crate::sdd::change::git_native::enforce_bdd_archive_gates_relaxed(root, &change_name)?;
let acked = crate::sdd::change::lock_gate::rules_edit_acked_for(root, &change_name);
let lock_issues = crate::sdd::change::lock_gate::check(root, &binding.base_sha, acked);
for issue in &lock_issues {
match issue.level {
crate::sdd::spec::validation::ValidationLevel::Error => {
eprintln!("{}", issue.message);
anyhow::bail!("locked-rule gate failed");
}
_ => eprintln!("{}", issue.message),
}
}
let already_checkpointed = binding.checkpointed && binding.checkpoint_sha.is_some();
if already_checkpointed {
eprintln!(
"change `{}` already checkpointed (checkpoint_sha={}); proceeding to archive rename",
change_name,
binding.checkpoint_sha.as_deref().unwrap_or(""),
);
} else {
crate::sdd::shared::validate::run(
root,
crate::sdd::shared::validate::ValidateArgs {
item: None,
all: false,
changes: false,
specs: true,
item_type: None,
strict: true,
json: false,
compact_json: false,
stage: None,
no_interactive: true,
check: !args.no_check,
no_check: args.no_check,
},
)?;
crate::sdd::shared::validate::run(
root,
crate::sdd::shared::validate::ValidateArgs {
item: Some(change_name.clone()),
all: false,
changes: false,
specs: false,
item_type: Some("change".into()),
strict: true,
json: false,
compact_json: false,
stage: None,
no_interactive: true,
check: false,
no_check: true,
},
)?;
binding.checkpointed = true;
binding.checkpoint_sha = Some(binding.base_sha.clone());
crate::sdd::change::git_native::write_binding(root, &change_name, &binding)?;
}
let changes_dir = root.join(LLMANSPEC_DIR_NAME).join("changes");
let change_dir = crate::sdd::shared::discovery::resolve_change_dir(root, &change_name)?;
let archive_dir = changes_dir.join("archive");
let archive_name = archive_name_for(&change_name);
let feature_branch = binding.branch.clone();
do_ff_merge(root, &feature_branch, &change_name);
do_archive_rename(&change_dir, &archive_dir, &archive_name)?;
println!(
"finalized change `{}` → archive `{archive_name}` on branch `{}` (checkpoint_sha=base_sha=`{}`)",
change_name, feature_branch, binding.base_sha,
);
let default_branch = crate::sdd::change::git_native::resolve_default_branch_ref(root)
.map(|r| r.strip_prefix("origin/").unwrap_or(r.as_str()).to_string())
.unwrap_or_else(|_| "<default>".to_string());
println!(
"{}",
t!("sdd.archive.finalize_next_step", default = default_branch)
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sdd::change::git_native::ChangeGitBinding;
use std::fs;
use tempfile::TempDir;
fn setup_repo_with_attached_change(change_id: &str) -> (TempDir, String, String) {
let tmp = TempDir::new().expect("tmp");
let root = tmp.path();
let llmanspec = root.join("llmanspec");
let changes = llmanspec.join("changes").join(change_id);
let specs = llmanspec.join("specs");
fs::create_dir_all(&changes).unwrap();
fs::create_dir_all(&specs).unwrap();
fs::write(
llmanspec.join("config.yaml"),
"schema: spec-driven\nlocale: en\nbdd:\n run_command: \"cargo test --features bdd\"\n",
)
.unwrap();
fs::write(
changes.join("proposal.md"),
"---\ndepends_on: []\n---\n\n# Proposal\n\n## Why\n\nx\n\n## What Changes\n\nx\n",
)
.unwrap();
fs::write(changes.join("tasks.md"), "# Tasks\n\n- [x] done\n").unwrap();
fs::write(
changes.join("design.md"),
"# Design\n\nTest fixture design.\n",
)
.unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("git");
if !out.status.success() {
panic!(
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
out
};
git(&["init", "--initial-branch=main"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "t"]);
git(&["add", "."]);
git(&["commit", "-m", "init"]);
let base_out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.expect("git rev-parse");
let base_sha = String::from_utf8(base_out.stdout)
.unwrap()
.trim()
.to_string();
git(&["checkout", "-b", "feat/x"]);
let binding = ChangeGitBinding {
branch: "feat/x".to_string(),
base_sha: base_sha.clone(),
checkpointed: false,
checkpoint_sha: None,
};
crate::sdd::change::git_native::write_binding(root, change_id, &binding).unwrap();
(tmp, change_id.to_string(), base_sha)
}
#[test]
fn finalize_writes_checkpointed_and_base_sha_then_archives() {
let _env_lock = crate::test_utils::lock_env();
unsafe { std::env::remove_var("LLMANSPEC_BASE_REF") };
let (tmp, id, base_sha) = setup_repo_with_attached_change("finalize-happy");
let root = tmp.path();
let sample_dir = root.join("llmanspec/specs/sample");
fs::create_dir_all(&sample_dir).unwrap();
fs::write(
sample_dir.join("sample.feature"),
"# capability: sample\n\
# purpose: sample for finalize happy-path test\n\
# scope: llmanspec/specs/sample\n\n\
Feature: sample\n\n\
\x20 @req:r1 @human\n\
\x20 Scenario: R1\n\
\x20 System MUST do X.\n",
)
.unwrap();
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "add sample spec"])
.current_dir(root)
.output()
.unwrap();
fs::write(
root.join("llmanspec/specs/sample/impl.txt"),
"dirty implementation",
)
.unwrap();
run_finalize(
root,
FinalizeArgs {
change: id.clone(),
no_check: true,
},
)
.expect("finalize succeeds");
assert!(
!root.join("llmanspec/changes").join(&id).exists(),
"active change dir should be gone"
);
let entries: Vec<_> = std::fs::read_dir(root.join("llmanspec/changes/archive"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
let archived_name = entries
.iter()
.find(|n| n.ends_with(&format!("-{id}")))
.cloned()
.unwrap_or_else(|| panic!("archive entry not found: {entries:?}"));
let proposal = fs::read_to_string(
root.join("llmanspec/changes/archive")
.join(&archived_name)
.join("proposal.md"),
)
.unwrap();
assert!(proposal.contains("checkpointed: true"));
assert!(
proposal.contains(&format!("checkpoint_sha: {base_sha}")),
"expected checkpoint_sha == base_sha in:\n{proposal}"
);
let branch = crate::sdd::change::git_native::current_branch(root).unwrap();
assert_eq!(branch, "main");
}
#[test]
fn finalize_rejects_when_not_attached() {
let (tmp, id, _base) = setup_repo_with_attached_change("finalize-noattach");
let root = tmp.path();
let proposal_path = root.join("llmanspec/changes").join(&id).join("proposal.md");
let stripped =
"---\ndepends_on: []\n---\n\n# Proposal\n\n## Why\n\nx\n\n## What Changes\n\nx\n";
fs::write(&proposal_path, stripped).unwrap();
std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "strip"])
.current_dir(root)
.output()
.unwrap();
let err = run_finalize(
root,
FinalizeArgs {
change: id,
no_check: true,
},
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("attach") || msg.contains("binding"),
"expected attach/binding error, got: {msg}"
);
}
#[test]
fn finalize_idempotent_after_partial_failure() {
let (tmp, id, base_sha) = setup_repo_with_attached_change("finalize-idem");
let root = tmp.path();
let binding = ChangeGitBinding {
branch: "feat/x".to_string(),
base_sha: base_sha.clone(),
checkpointed: true,
checkpoint_sha: Some(base_sha.clone()),
};
crate::sdd::change::git_native::write_binding(root, &id, &binding).unwrap();
run_finalize(
root,
FinalizeArgs {
change: id.clone(),
no_check: false, },
)
.expect("finalize succeeds (idempotent)");
assert!(!root.join("llmanspec/changes").join(&id).exists());
}
#[test]
fn finalize_works_unified_regardless_of_bdd_config() {
let _env_lock = crate::test_utils::lock_env();
unsafe { std::env::remove_var("LLMANSPEC_BASE_REF") };
let (tmp, id, _base) = setup_repo_with_attached_change("finalize-unified");
let root = tmp.path();
let sample_dir = root.join("llmanspec/specs/sample");
fs::create_dir_all(&sample_dir).unwrap();
fs::write(
sample_dir.join("sample.feature"),
"# capability: sample\n# purpose: sample\n# scope: llmanspec/specs/sample\n\nFeature: sample\n\n @req:r1 @human\n Scenario: R1\n System MUST do X.\n",
).unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add sample spec"])
.current_dir(root)
.output()
.unwrap();
fs::write(
root.join("llmanspec/config.yaml"),
"schema: spec-driven\nlocale: en\n",
)
.unwrap();
run_finalize(
root,
FinalizeArgs {
change: id.clone(),
no_check: true,
},
)
.expect("unified finalize should succeed without bdd: block");
assert!(!root.join("llmanspec/changes").join(&id).exists());
}
#[test]
fn _binding_shape_anchor() {
let _ = ChangeGitBinding {
branch: String::new(),
base_sha: String::new(),
checkpointed: false,
checkpoint_sha: None,
};
}
}