use crate::git::GitFlow;
use serde_json::Value;
use std::path::{Path, PathBuf};
const AUTO_CHAIN_KEY: &str = "_auto_chain_active";
const WORKFLOW_KEY: &str = "workflow";
#[derive(Debug, thiserror::Error)]
pub enum GsdConfigError {
#[error("GSD config I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("GSD config JSON failed: {0}")]
Json(#[from] serde_json::Error),
#[error("no GSD config at {0}")]
Missing(PathBuf),
}
#[must_use]
pub fn config_path(root: &Path) -> PathBuf {
root.join(".planning").join("config.json")
}
fn read_config(path: &Path) -> Result<Value, GsdConfigError> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(GsdConfigError::Missing(path.to_path_buf()));
}
Err(err) => return Err(GsdConfigError::Io(err)),
};
Ok(serde_json::from_str(&contents)?)
}
pub fn auto_chain_active(root: &Path) -> Result<bool, GsdConfigError> {
let value = read_config(&config_path(root))?;
Ok(read_flag(&value))
}
fn read_flag(value: &Value) -> bool {
value
.get(WORKFLOW_KEY)
.and_then(|workflow| workflow.get(AUTO_CHAIN_KEY))
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub fn set_auto_chain_active(root: &Path, active: bool) -> Result<bool, GsdConfigError> {
let path = config_path(root);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(GsdConfigError::Missing(path));
}
Err(err) => return Err(GsdConfigError::Io(err)),
};
let mut value: Value = serde_json::from_str(&raw)?;
if read_flag(&value) == active {
return Ok(false);
}
if !value.get(WORKFLOW_KEY).is_some_and(Value::is_object) {
if let Some(map) = value.as_object_mut() {
map.insert(
WORKFLOW_KEY.to_string(),
Value::Object(serde_json::Map::new()),
);
} else {
return Err(GsdConfigError::Json(serde::de::Error::custom(
"GSD config root is not a JSON object",
)));
}
}
value[WORKFLOW_KEY][AUTO_CHAIN_KEY] = Value::Bool(active);
let mut contents = serde_json::to_string_pretty(&value)?;
if raw.ends_with('\n') {
contents.push('\n');
}
write_atomic(&path, &contents)?;
Ok(true)
}
#[derive(Debug, Default)]
pub struct ClearOutcome {
pub working_tree_repaired: bool,
pub committed_tree_repaired: bool,
pub commit_refused: Option<String>,
}
impl ClearOutcome {
#[must_use]
pub fn repaired_anything(&self) -> bool {
self.working_tree_repaired || self.committed_tree_repaired
}
}
const CONFIG_PATHSPEC: &str = ".planning/config.json";
pub fn force_clear_auto_chain(root: &Path) -> Result<ClearOutcome, GsdConfigError> {
let mut outcome = ClearOutcome::default();
match set_auto_chain_active(root, false) {
Ok(changed) => outcome.working_tree_repaired = changed,
Err(GsdConfigError::Missing(_)) => return Ok(outcome),
Err(err) => return Err(err),
}
match probe_head(root) {
HeadProbe::Agrees => return Ok(outcome),
HeadProbe::Differs => {}
HeadProbe::Unknown(reason) => {
outcome.commit_refused = Some(reason);
return Ok(outcome);
}
}
let head_text = match head_copy(root) {
Ok(text) => text,
Err(reason) => {
outcome.commit_refused = Some(reason);
return Ok(outcome);
}
};
let working_text = std::fs::read_to_string(config_path(root))?;
match (
serde_json::from_str::<Value>(&head_text),
serde_json::from_str::<Value>(&working_text),
) {
(Ok(mut head), Ok(mut working)) => {
without_flag(&mut head);
without_flag(&mut working);
if head != working {
outcome.commit_refused = Some(format!(
"{CONFIG_PATHSPEC} carries changes beyond the chain flag — the \
branch-tip repair was deferred rather than sweep an unrelated \
edit into a DevFlow commit"
));
return Ok(outcome);
}
}
_ => {
outcome.commit_refused = Some(format!(
"could not parse both copies of {CONFIG_PATHSPEC} — the branch-tip \
repair was deferred rather than committed unverified"
));
return Ok(outcome);
}
}
if let Err(err) = GitFlow::new(root).commit_path(CONFIG_PATHSPEC, REPAIR_COMMIT_MESSAGE) {
outcome.commit_refused = Some(format!(
"the branch-tip repair could not be committed ({err}) — the working \
tree is corrected, but this branch still carries the leaked value"
));
return Ok(outcome);
}
match probe_head(root) {
HeadProbe::Agrees => outcome.committed_tree_repaired = true,
HeadProbe::Differs => {
outcome.commit_refused = Some(format!(
"the branch-tip repair reported success but {CONFIG_PATHSPEC} still \
disagrees with HEAD — this branch may still carry the leaked value"
));
}
HeadProbe::Unknown(reason) => outcome.commit_refused = Some(reason),
}
Ok(outcome)
}
const REPAIR_COMMIT_MESSAGE: &str = "\
fix(gsd): clear a leaked auto-chain flag before launch
A previous run for this phase was killed before its in-process guard could
clear workflow._auto_chain_active, and the leaked value reached this branch.
Left in place it travels through Ship into develop, where a later phase's
plan-phase invocation reads the same boolean as \"chain into execute-phase\"
rather than \"approve this checkpoint\".
Repaired forward by devflow start/resume (35.1 D-01).";
enum HeadProbe {
Agrees,
Differs,
Unknown(String),
}
fn probe_head(root: &Path) -> HeadProbe {
match crate::git::git_command(root)
.args(["diff", "--quiet", "HEAD", "--", CONFIG_PATHSPEC])
.output()
{
Ok(output) => match output.status.code() {
Some(0) => HeadProbe::Agrees,
Some(1) => HeadProbe::Differs,
other => HeadProbe::Unknown(format!(
"could not compare {CONFIG_PATHSPEC} against HEAD (git exited \
{other:?}) — the branch-tip repair was deferred rather than \
assumed unnecessary"
)),
},
Err(err) => HeadProbe::Unknown(format!(
"could not run git to compare {CONFIG_PATHSPEC} against HEAD ({err}) — \
the branch-tip repair was deferred rather than assumed unnecessary"
)),
}
}
fn head_copy(root: &Path) -> Result<String, String> {
match crate::git::git_command(root)
.args(["show", &format!("HEAD:{CONFIG_PATHSPEC}")])
.output()
{
Ok(output) if output.status.success() => {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
Ok(_) => Err(format!(
"HEAD holds no readable {CONFIG_PATHSPEC} to compare against — the \
branch-tip repair was deferred rather than committed unverified"
)),
Err(err) => Err(format!(
"could not read HEAD's copy of {CONFIG_PATHSPEC} ({err}) — the \
branch-tip repair was deferred rather than committed unverified"
)),
}
}
fn without_flag(value: &mut Value) {
if let Some(workflow) = value.get_mut(WORKFLOW_KEY).and_then(Value::as_object_mut) {
workflow.remove(AUTO_CHAIN_KEY);
}
}
fn write_atomic(path: &Path, contents: &str) -> Result<(), GsdConfigError> {
let tmp = path.with_extension("json.devflow-tmp");
std::fs::write(&tmp, contents)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const REAL_SHAPE: &str = r#"{
"commit_docs": true,
"workflow": {
"granularity": "medium",
"auto_mode": true,
"auto_advance": true,
"commit_docs": true,
"subagent_timeout": 300000,
"_auto_chain_active": false,
"nyquist_validation": true,
"tdd_mode": true
},
"git": {
"main": "main",
"develop": "develop"
},
"intel": {
"enabled": true
},
"review": {
"default_reviewers": [
"codex"
]
},
"model_overrides": {
"gsd-executor": "inherit"
},
"mempalace": {
"enabled": true
}
}
"#;
fn project(contents: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join(".planning")).unwrap();
std::fs::write(config_path(&root), contents).unwrap();
(dir, root)
}
fn real_shape(active: bool) -> String {
let replaced = REAL_SHAPE.replace(
"\"_auto_chain_active\": false",
&format!("\"_auto_chain_active\": {active}"),
);
assert!(
replaced.contains(&format!("\"_auto_chain_active\": {active}")),
"the fixture must actually carry the requested flag value"
);
replaced
}
fn git(root: &Path, args: &[&str]) {
let output = crate::git::git_command(root)
.args(args)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_output(root: &Path, args: &[&str]) -> String {
let output = crate::git::git_command(root)
.args(args)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn git_project() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
git(&root, &["init", "-q"]);
git(&root, &["config", "user.email", "devflow@example.com"]);
git(&root, &["config", "user.name", "DevFlow Tests"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.hooksPath", "/dev/null"]);
std::fs::write(root.join("README.md"), "base\n").unwrap();
git(&root, &["add", "README.md"]);
git(&root, &["commit", "-q", "-m", "base"]);
std::fs::create_dir_all(root.join(".planning")).unwrap();
(dir, root)
}
fn write_config(root: &Path, contents: &str) {
std::fs::write(config_path(root), contents).unwrap();
}
fn commit_config(root: &Path, message: &str) {
git(root, &["add", CONFIG_PATHSPEC]);
git(root, &["commit", "-q", "-m", message]);
}
fn head_sha(root: &Path) -> String {
git_output(root, &["rev-parse", "HEAD"])
}
fn flag_at_head(root: &Path) -> Value {
let raw = git_output(root, &["show", &format!("HEAD:{CONFIG_PATHSPEC}")]);
let value: Value = serde_json::from_str(&raw).unwrap();
value["workflow"]["_auto_chain_active"].clone()
}
#[test]
fn force_clear_repairs_a_leaked_working_tree_value() {
let (_dir, root) = git_project();
write_config(&root, &real_shape(false));
commit_config(&root, "add gsd config");
let head_before = head_sha(&root);
write_config(&root, &real_shape(true));
let outcome = force_clear_auto_chain(&root).unwrap();
assert!(
outcome.working_tree_repaired,
"a set flag on disk must be reported as a working-tree repair"
);
assert!(
!outcome.committed_tree_repaired,
"the branch tip never carried the leak, so nothing may be committed"
);
assert_eq!(outcome.commit_refused, None);
assert!(
!auto_chain_active(&root).unwrap(),
"a subsequent read must see the cleared value"
);
assert_eq!(
head_sha(&root),
head_before,
"a working-tree-only repair must not add a commit"
);
}
#[test]
fn force_clear_commits_when_the_leak_reached_head() {
let (_dir, root) = git_project();
write_config(&root, &real_shape(true));
commit_config(&root, "add gsd config carrying the leak");
assert_eq!(
flag_at_head(&root),
Value::Bool(true),
"the fixture must actually commit the leak, or the assertions below \
are vacuous"
);
let outcome = force_clear_auto_chain(&root).unwrap();
assert!(outcome.working_tree_repaired);
assert!(
outcome.committed_tree_repaired,
"a leak that reached HEAD must be repaired in the commit too, not \
only in the working tree — otherwise the branch → merge → develop \
→ next-phase-chains path stays open (35.1 D-01)"
);
assert_eq!(outcome.commit_refused, None);
assert_eq!(flag_at_head(&root), Value::Bool(false));
}
#[test]
fn force_clear_refuses_to_commit_when_the_file_carries_other_changes() {
let (_dir, root) = git_project();
write_config(&root, &real_shape(true));
commit_config(&root, "add gsd config carrying the leak");
let head_before = head_sha(&root);
write_config(
&root,
&real_shape(true).replace("\"granularity\": \"medium\"", "\"granularity\": \"large\""),
);
let outcome = force_clear_auto_chain(&root).unwrap();
assert!(
outcome.working_tree_repaired,
"the working-tree clear disarms the bypass for THIS run and must \
happen even when the commit is declined"
);
assert!(
!outcome.committed_tree_repaired,
"the branch-tip repair must be deferred, not attempted"
);
let reason = outcome
.commit_refused
.expect("a declined commit must say why, loudly");
assert!(
reason.contains("beyond"),
"the refusal must name the cause — got: {reason}"
);
assert!(
!auto_chain_active(&root).unwrap(),
"the working tree is still cleared"
);
assert!(
std::fs::read_to_string(config_path(&root))
.unwrap()
.contains("\"granularity\": \"large\""),
"the operator's in-flight edit must survive untouched"
);
assert_eq!(
head_sha(&root),
head_before,
"nothing may be committed — this assertion, not the returned \
refusal, is what distinguishes a genuine refusal from a commit \
that reported one"
);
}
#[test]
fn force_clear_on_an_already_clean_config_reports_nothing_and_writes_nothing() {
let (_dir, root) = git_project();
write_config(&root, &real_shape(false));
commit_config(&root, "add a clean gsd config");
let head_before = head_sha(&root);
let bytes_before = std::fs::read(config_path(&root)).unwrap();
let outcome = force_clear_auto_chain(&root).unwrap();
assert!(!outcome.working_tree_repaired);
assert!(!outcome.committed_tree_repaired);
assert_eq!(outcome.commit_refused, None);
assert!(
!outcome.repaired_anything(),
"an ordinary clean launch must have nothing to be loud about"
);
assert_eq!(std::fs::read(config_path(&root)).unwrap(), bytes_before);
assert_eq!(head_sha(&root), head_before);
}
#[test]
fn force_clear_on_a_project_without_a_gsd_config_is_a_clean_no_op() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let outcome = force_clear_auto_chain(&root).expect("an absent config is not an error");
assert!(!outcome.working_tree_repaired);
assert!(!outcome.committed_tree_repaired);
assert_eq!(outcome.commit_refused, None);
}
#[test]
fn force_clear_on_a_malformed_config_is_an_error() {
let (_dir, root) = git_project();
let malformed = "{ \"workflow\": { \"_auto_chain_active\": tru";
write_config(&root, malformed);
assert!(matches!(
force_clear_auto_chain(&root),
Err(GsdConfigError::Json(_))
));
assert_eq!(
std::fs::read_to_string(config_path(&root)).unwrap(),
malformed,
"a failed certification must leave the operator's file exactly as \
it was"
);
}
fn empty_project() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join(".planning")).unwrap();
(dir, root)
}
#[test]
fn writing_the_flag_leaves_every_other_key_byte_identical() {
let (_dir, root) = project(REAL_SHAPE);
let before = std::fs::read_to_string(config_path(&root)).unwrap();
assert!(set_auto_chain_active(&root, true).unwrap());
let after = std::fs::read_to_string(config_path(&root)).unwrap();
let expected = before.replace(
"\"_auto_chain_active\": false",
"\"_auto_chain_active\": true",
);
assert_ne!(
expected, before,
"the fixture must actually contain the key this test flips, or the \
comparison below is vacuous"
);
assert_eq!(
after, expected,
"the written file must differ from the original in EXACTLY the one \
value this module owns — same key order, same number rendering, \
same whitespace"
);
assert!(auto_chain_active(&root).unwrap());
}
#[test]
fn writing_the_flag_never_touches_auto_advance() {
let (_dir, root) = project(REAL_SHAPE);
assert_eq!(
serde_json::from_str::<Value>(REAL_SHAPE).unwrap()["workflow"]["auto_advance"],
Value::Bool(true)
);
set_auto_chain_active(&root, true).unwrap();
set_auto_chain_active(&root, false).unwrap();
let after: Value =
serde_json::from_str(&std::fs::read_to_string(config_path(&root)).unwrap()).unwrap();
assert_eq!(
after["workflow"]["auto_advance"],
Value::Bool(true),
"auto_advance is the operator's, and neither setting nor clearing \
the chain flag may disturb it"
);
}
#[test]
fn setting_the_value_it_already_holds_is_a_no_op() {
let (_dir, root) = project(REAL_SHAPE);
let path = config_path(&root);
let before = std::fs::read(&path).unwrap();
let before_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(
!set_auto_chain_active(&root, false).unwrap(),
"a write that changes nothing must report that it changed nothing"
);
assert_eq!(before, std::fs::read(&path).unwrap());
assert_eq!(
before_mtime,
std::fs::metadata(&path).unwrap().modified().unwrap()
);
assert!(set_auto_chain_active(&root, true).unwrap());
}
#[test]
fn a_missing_workflow_object_is_created_rather_than_rejected() {
let (_dir, root) = project("{\n \"commit_docs\": true\n}\n");
assert!(set_auto_chain_active(&root, true).unwrap());
assert!(auto_chain_active(&root).unwrap());
let after: Value =
serde_json::from_str(&std::fs::read_to_string(config_path(&root)).unwrap()).unwrap();
assert_eq!(
after["commit_docs"],
Value::Bool(true),
"creating the workflow object must not disturb the keys already there"
);
}
#[test]
fn a_malformed_config_is_an_error_not_a_panic() {
let malformed = "{ \"workflow\": { \"auto_advance\": tru";
let (_dir, root) = project(malformed);
assert!(matches!(
set_auto_chain_active(&root, true),
Err(GsdConfigError::Json(_))
));
assert!(matches!(
auto_chain_active(&root),
Err(GsdConfigError::Json(_))
));
assert_eq!(
std::fs::read_to_string(config_path(&root)).unwrap(),
malformed,
"a failed write must leave the operator's file exactly as it was"
);
}
#[test]
fn a_non_object_config_root_is_an_error_not_a_replacement() {
let (_dir, root) = project("[1, 2, 3]\n");
assert!(matches!(
set_auto_chain_active(&root, true),
Err(GsdConfigError::Json(_))
));
assert_eq!(
std::fs::read_to_string(config_path(&root)).unwrap(),
"[1, 2, 3]\n"
);
}
#[test]
fn an_absent_config_is_an_error_not_a_panic() {
let (_dir, root) = empty_project();
assert!(matches!(
set_auto_chain_active(&root, true),
Err(GsdConfigError::Missing(_))
));
assert!(matches!(
auto_chain_active(&root),
Err(GsdConfigError::Missing(_))
));
assert!(
!config_path(&root).exists(),
"a failed write must not leave a file behind"
);
}
#[test]
fn reading_the_flag_defaults_to_the_inactive_value_on_a_shape_it_does_not_recognise() {
for shape in [
"{ \"commit_docs\": true }",
"{ \"workflow\": \"medium\" }",
"{ \"workflow\": { \"_auto_chain_active\": \"true\" } }",
] {
let (_dir, root) = project(shape);
assert!(
!auto_chain_active(&root).unwrap(),
"unrecognised shape must read inactive, not panic: {shape}"
);
}
let (_dir, root) = project("{ \"workflow\": { \"_auto_chain_active\": true } }");
assert!(auto_chain_active(&root).unwrap());
}
#[test]
fn the_trailing_newline_convention_survives_a_write() {
let (_dir, root) = project(REAL_SHAPE);
set_auto_chain_active(&root, true).unwrap();
assert!(
std::fs::read_to_string(config_path(&root))
.unwrap()
.ends_with('\n')
);
let without = REAL_SHAPE.trim_end_matches('\n').to_string();
let (_dir2, root2) = project(&without);
set_auto_chain_active(&root2, true).unwrap();
assert!(
!std::fs::read_to_string(config_path(&root2))
.unwrap()
.ends_with('\n')
);
}
#[test]
fn the_atomic_write_leaves_no_temp_file_behind() {
let (_dir, root) = project(REAL_SHAPE);
set_auto_chain_active(&root, true).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(root.join(".planning"))
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name != "config.json")
.collect();
assert!(
leftovers.is_empty(),
"stray files in .planning: {leftovers:?}"
);
}
}