use std::path::{Path, PathBuf};
use toml::Value;
fn non_empty_env(key: &str) -> Option<std::ffi::OsString> {
match std::env::var_os(key) {
Some(v) if !v.is_empty() => Some(v),
_ => None,
}
}
fn global_config_path() -> Option<PathBuf> {
if let Some(p) = non_empty_env("FNO_GLOBAL_SETTINGS_PATH") {
return Some(PathBuf::from(p).with_file_name("config.toml"));
}
std::env::var_os("HOME").map(|h| Path::new(&h).join(".fno/config.toml"))
}
fn config_candidates(cwd: &Path) -> Vec<PathBuf> {
if let Some(explicit) = non_empty_env("FNO_CONFIG") {
let path = PathBuf::from(explicit);
warn_once_if_yaml(&path);
return vec![path];
}
let mut out = vec![cwd.join(".fno/config.toml")];
let suppress_canonical = std::env::var_os("FNO_NO_CANONICAL_CONFIG")
.is_some_and(|v| v == *std::ffi::OsStr::new("1"));
if !suppress_canonical {
if let Some(canonical) = crate::paths::canonical_repo_root(cwd)
.map(|root| root.join(".fno/config.toml"))
.filter(|c| !out.contains(c))
{
out.push(canonical);
}
}
if let Some(g) = global_config_path() {
out.push(g);
}
out
}
fn warn_once_if_yaml(path: &Path) {
static WARNED: std::sync::Once = std::sync::Once::new();
if matches!(
path.extension().and_then(|e| e.to_str()),
Some("yaml" | "yml")
) {
WARNED.call_once(|| {
eprintln!(
"fno-agents: $FNO_CONFIG points at {}, which this reader parses as TOML \
and cannot read; every config value falls back to its built-in default. \
Point it at a config.toml, or unset it to use the migrated file.",
path.display()
);
});
}
}
fn parse_config(content: &str) -> Option<toml::Table> {
content.parse::<toml::Table>().ok()
}
fn resolve<T>(cwd: &Path, extract: impl Fn(&toml::Table) -> Option<T>) -> Option<T> {
for path in config_candidates(cwd) {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Some(table) = parse_config(&content) {
if let Some(v) = extract(&table) {
return Some(v);
}
}
}
}
None
}
fn table_headless_yolo(t: &toml::Table, provider: &str) -> Option<bool> {
t.get("agents")?
.as_table()?
.get(provider)?
.as_table()?
.get("headless_yolo")?
.as_bool()
}
fn table_agents_scalar(t: &toml::Table, key: &str) -> Option<Value> {
t.get("agents")?.as_table()?.get(key).cloned()
}
fn table_mux_bool(t: &toml::Table, key: &str) -> Option<bool> {
t.get("mux")?.as_table()?.get(key)?.as_bool()
}
fn review_login_scalar(v: &Value) -> Option<String> {
let raw = match v {
Value::String(s) => s.trim().to_string(),
Value::Integer(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Boolean(b) => b.to_string(),
_ => return None,
};
(!raw.is_empty()).then_some(raw)
}
fn table_review_optional_apps(t: &toml::Table) -> Option<Vec<String>> {
let value = t.get("review")?.as_table()?.get("optional_apps")?;
Some(match value {
Value::Array(items) => items.iter().filter_map(review_login_scalar).collect(),
Value::String(_) | Value::Integer(_) | Value::Float(_) | Value::Boolean(_) => {
review_login_scalar(value).into_iter().collect()
}
_ => Vec::new(),
})
}
fn scalar_to_string(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.trim().to_ascii_lowercase()),
Value::Integer(i) => Some(i.to_string()),
Value::Float(f) => Some(f.to_string()),
Value::Boolean(b) => Some(b.to_string()),
_ => None,
}
}
pub fn headless_yolo_enabled(provider: &str, cwd: &Path) -> bool {
resolve(cwd, |t| table_headless_yolo(t, provider)).unwrap_or(false)
}
pub fn effective_yolo(yolo: bool, headless_default: bool) -> bool {
yolo || headless_default
}
pub const DEFAULT_DEAD_ROW_GRACE_SECS: u64 = 3600;
pub fn dead_row_grace_secs(cwd: &Path) -> u64 {
if let Some(v) = non_empty_env("FNO_AGENTS_DEAD_ROW_GRACE_SECS")
.and_then(|s| s.to_str().and_then(|s| s.trim().parse::<u64>().ok()))
{
return v;
}
resolve(cwd, |t| {
table_agents_scalar(t, "dead_row_grace")?
.as_integer()
.and_then(|i| u64::try_from(i).ok())
})
.unwrap_or(DEFAULT_DEAD_ROW_GRACE_SECS)
}
pub const DEFAULT_MAX_LIVE: u32 = 3;
pub const DEFAULT_MIN_FREE_GB: f64 = 4.0;
pub fn max_live(cwd: &Path) -> u32 {
match resolve_agents_value(cwd, "max_live").and_then(|raw| raw.parse::<u32>().ok()) {
Some(v) if v >= 1 => v,
_ => DEFAULT_MAX_LIVE,
}
}
pub fn min_free_gb(cwd: &Path) -> f64 {
resolve_agents_value(cwd, "min_free_gb")
.and_then(|raw| raw.parse::<f64>().ok())
.unwrap_or(DEFAULT_MIN_FREE_GB)
}
pub fn worker_qos_enabled(cwd: &Path) -> bool {
!matches!(
resolve_agents_value(cwd, "worker_qos").as_deref(),
Some("off")
)
}
pub fn dispatch_auto_merge(cwd: &Path) -> bool {
resolve(cwd, |t| {
t.get("dispatch")?.as_table()?.get("auto_merge")?.as_bool()
})
.unwrap_or(false)
}
pub fn review_optional_apps(cwd: &Path) -> Vec<String> {
resolve(cwd, table_review_optional_apps).unwrap_or_default()
}
pub fn auto_merge_strategy(cwd: &Path) -> String {
resolve(cwd, |t| {
t.get("auto_merge")?
.as_table()?
.get("merge_strategy")?
.as_str()
.map(|s| s.trim().to_string())
})
.filter(|v| matches!(v.as_str(), "merge" | "squash" | "rebase"))
.unwrap_or_else(|| "merge".to_string())
}
pub fn auto_merge_delete_branch(cwd: &Path) -> bool {
resolve(cwd, |t| {
t.get("auto_merge")?
.as_table()?
.get("delete_branch_on_merge")
.and_then(scalar_to_string)
})
.map(|raw| matches!(raw.as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(true)
}
fn resolve_agents_value(cwd: &Path, key: &str) -> Option<String> {
resolve(cwd, |t| {
table_agents_scalar(t, key)
.as_ref()
.and_then(scalar_to_string)
})
}
pub fn notify_on_blocked_enabled(cwd: &Path) -> bool {
mux_bool(cwd, "notify_on_blocked", true)
}
pub fn notify_on_done_enabled(cwd: &Path) -> bool {
mux_bool(cwd, "notify_on_done", false)
}
fn mux_bool(cwd: &Path, key: &str, default: bool) -> bool {
resolve(cwd, |t| table_mux_bool(t, key)).unwrap_or(default)
}
#[cfg(test)]
pub(crate) fn read_headless_yolo(content: &str, provider: &str) -> Option<bool> {
table_headless_yolo(&parse_config(content)?, provider)
}
#[cfg(test)]
pub(crate) fn read_dead_row_grace(content: &str) -> Option<u64> {
table_agents_scalar(&parse_config(content)?, "dead_row_grace")?
.as_integer()
.and_then(|i| u64::try_from(i).ok())
}
#[cfg(test)]
pub(crate) fn read_agents_value(content: &str, key: &str) -> Option<String> {
scalar_to_string(&table_agents_scalar(&parse_config(content)?, key)?)
}
#[cfg(test)]
pub(crate) fn read_mux_bool(content: &str, key: &str) -> Option<bool> {
table_mux_bool(&parse_config(content)?, key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headless_yolo_default_true_when_absent() {
assert_eq!(read_headless_yolo("schema_version = 1\n", "gemini"), None);
assert_eq!(read_headless_yolo("schema_version = 1\n", "codex"), None);
}
#[test]
fn headless_yolo_reads_per_provider_optout() {
let cfg = "[agents.gemini]\nheadless_yolo = false\n";
assert_eq!(read_headless_yolo(cfg, "gemini"), Some(false));
assert_eq!(read_headless_yolo(cfg, "codex"), None);
}
#[test]
fn dead_row_grace_reads_agents_child_key() {
let cfg = "[agents]\nconfirm = \"auto\"\ndead_row_grace = 7200\n";
assert_eq!(read_dead_row_grace(cfg), Some(7200));
}
#[test]
fn dead_row_grace_absent_is_none() {
assert_eq!(read_dead_row_grace("[agents]\nconfirm = \"auto\"\n"), None);
assert_eq!(read_dead_row_grace("schema_version = 1\n"), None);
}
#[test]
fn dead_row_grace_ignores_provider_nested_and_bad_values() {
let nested = "[agents.codex]\ndead_row_grace = 5\n";
assert_eq!(read_dead_row_grace(nested), None);
let bad = "[agents]\ndead_row_grace = \"banana\"\n";
assert_eq!(read_dead_row_grace(bad), None);
}
#[test]
fn headless_yolo_does_not_confuse_providers_or_sibling_keys() {
let cfg = "[agents]\nconfirm = \"auto\"\n\n[agents.a2a]\nauto = true\n\n\
[agents.codex]\nheadless_yolo = false\n\n\
[agents.gemini]\nheadless_yolo = true\n";
assert_eq!(read_headless_yolo(cfg, "codex"), Some(false));
assert_eq!(read_headless_yolo(cfg, "gemini"), Some(true));
}
#[test]
fn headless_yolo_reads_inline_provider_table() {
let cfg = "[agents]\ngemini = { headless_yolo = false }\n";
assert_eq!(read_headless_yolo(cfg, "gemini"), Some(false));
}
#[test]
fn headless_yolo_malformed_value_is_none_not_a_guess() {
let cfg = "[agents.gemini]\nheadless_yolo = \"banana\"\n";
assert_eq!(read_headless_yolo(cfg, "gemini"), None);
}
#[test]
fn headless_yolo_ignores_non_agents_config() {
let cfg = "[target]\nheadless_yolo = false\n";
assert_eq!(read_headless_yolo(cfg, "gemini"), None);
}
#[test]
fn agents_value_reads_spawn_gate_keys() {
let cfg =
"[agents]\nconfirm = \"auto\"\nmax_live = 5\nmin_free_gb = 2.5\nworker_qos = \"off\"\n";
assert_eq!(read_agents_value(cfg, "max_live").as_deref(), Some("5"));
assert_eq!(
read_agents_value(cfg, "min_free_gb").as_deref(),
Some("2.5")
);
assert_eq!(read_agents_value(cfg, "worker_qos").as_deref(), Some("off"));
}
#[test]
fn agents_value_absent_nested_or_prefix_is_none() {
assert_eq!(read_agents_value("schema_version = 1\n", "max_live"), None);
let nested = "[agents.codex]\nmax_live = 9\n";
assert_eq!(read_agents_value(nested, "max_live"), None);
let prefix = "[agents]\nmax_live_extra = 9\n";
assert_eq!(read_agents_value(prefix, "max_live"), None);
}
#[test]
fn spawn_gate_knobs_coerce_invalid_to_defaults() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let f = write_file(
"gate-coerce",
"[agents]\nmax_live = 0\nmin_free_gb = \"banana\"\nworker_qos = \"turbo\"\n",
);
std::env::set_var("FNO_CONFIG", &f);
let cwd = std::env::temp_dir();
let (ml, mf, qos) = (max_live(&cwd), min_free_gb(&cwd), worker_qos_enabled(&cwd));
clear_config_env();
assert_eq!(ml, DEFAULT_MAX_LIVE);
assert_eq!(mf, DEFAULT_MIN_FREE_GB);
assert!(qos, "unknown worker_qos coerces to utility (enabled)");
}
#[test]
fn spawn_gate_knobs_read_valid_values() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let f = write_file(
"gate-valid",
"[agents]\nmax_live = 7\nmin_free_gb = 0\nworker_qos = \"off\"\n",
);
std::env::set_var("FNO_CONFIG", &f);
let cwd = std::env::temp_dir();
let (ml, mf, qos) = (max_live(&cwd), min_free_gb(&cwd), worker_qos_enabled(&cwd));
clear_config_env();
assert_eq!(ml, 7);
assert_eq!(mf, 0.0, "min_free_gb: 0 is valid (guard disabled)");
assert!(!qos);
}
#[test]
fn mux_bool_reads_mux_child_key() {
let cfg = "[mux]\nnotify_on_blocked = false\nnotify_on_done = true\n";
assert_eq!(read_mux_bool(cfg, "notify_on_blocked"), Some(false));
assert_eq!(read_mux_bool(cfg, "notify_on_done"), Some(true));
}
#[test]
fn mux_bool_absent_is_none() {
assert_eq!(
read_mux_bool("[agents]\nconfirm = \"auto\"\n", "notify_on_blocked"),
None
);
assert_eq!(
read_mux_bool("schema_version = 1\n", "notify_on_done"),
None
);
}
#[test]
fn mux_bool_ignores_nested_and_bad_values() {
let nested = "[mux.pane]\nnotify_on_blocked = false\n";
assert_eq!(read_mux_bool(nested, "notify_on_blocked"), None);
let bad = "[mux]\nnotify_on_blocked = \"banana\"\n";
assert_eq!(read_mux_bool(bad, "notify_on_blocked"), None);
let prefix = "[mux]\nnotify_on_blocked_extra = true\n";
assert_eq!(read_mux_bool(prefix, "notify_on_blocked"), None);
}
#[test]
fn mux_bool_reads_true() {
let cfg = "[mux]\nnotify_on_done = true\n";
assert_eq!(read_mux_bool(cfg, "notify_on_done"), Some(true));
}
#[test]
fn effective_yolo_or_semantics() {
assert!(effective_yolo(true, false));
assert!(effective_yolo(false, true));
assert!(!effective_yolo(false, false));
assert!(effective_yolo(true, true));
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clear_config_env() {
std::env::remove_var("FNO_CONFIG");
let iso = std::env::temp_dir().join(format!("fno-agents-noglobal-{}", std::process::id()));
std::fs::create_dir_all(&iso).unwrap();
std::env::set_var("FNO_GLOBAL_SETTINGS_PATH", iso.join("settings.json"));
}
fn write_project_settings(name: &str, body: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fno-headless-{}-{name}", std::process::id()));
let fnodir = dir.join(".fno");
std::fs::create_dir_all(&fnodir).unwrap();
std::fs::write(fnodir.join("config.toml"), body).unwrap();
dir
}
fn write_file(name: &str, body: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fno-headless-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("explicit.toml");
std::fs::write(&f, body).unwrap();
f
}
#[test]
fn headless_yolo_enabled_reads_project_local_optout() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("optout", "[agents.gemini]\nheadless_yolo = false\n");
assert!(!headless_yolo_enabled("gemini", &cwd));
}
#[test]
fn headless_yolo_enabled_reads_project_local_on() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("on", "[agents.gemini]\nheadless_yolo = true\n");
assert!(headless_yolo_enabled("gemini", &cwd));
}
#[test]
fn headless_yolo_enabled_honors_fno_config_short_circuit() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let f = write_file(
"explicit-fullyolo",
"[agents.gemini]\nheadless_yolo = true\n",
);
std::env::set_var("FNO_CONFIG", &f);
let cwd = std::env::temp_dir().join(format!("fno-headless-{}-nocfg", std::process::id()));
std::fs::create_dir_all(&cwd).unwrap();
let got = headless_yolo_enabled("gemini", &cwd);
clear_config_env();
assert!(
got,
"FNO_CONFIG full-yolo opt-in must be honored on the Rust path"
);
}
#[test]
fn headless_yolo_enabled_fno_config_absent_key_defaults_bounded() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let f = write_file("explicit-empty", "schema_version = 1\n");
std::env::set_var("FNO_CONFIG", &f);
let cwd = std::env::temp_dir().join(format!("fno-headless-{}-nocfg2", std::process::id()));
std::fs::create_dir_all(&cwd).unwrap();
let got = headless_yolo_enabled("codex", &cwd);
clear_config_env();
assert!(
!got,
"absent key under FNO_CONFIG -> hang-safe BOUNDED default (false)"
);
}
#[test]
fn dispatch_auto_merge_absent_is_false() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("am-absent", "schema_version = 1\n");
std::env::set_var("FNO_CONFIG", cwd.join(".fno/config.toml"));
let got = dispatch_auto_merge(&cwd);
clear_config_env();
assert!(!got);
}
#[test]
fn dispatch_auto_merge_true_grants() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("am-true", "[dispatch]\nauto_merge = true\n");
assert!(dispatch_auto_merge(&cwd));
}
#[test]
fn dispatch_auto_merge_false_no_merge() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("am-false", "[dispatch]\nauto_merge = false\n");
assert!(!dispatch_auto_merge(&cwd));
}
#[test]
fn review_optional_apps_accepts_list_and_scalar_forms() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for (name, body, want) in [
(
"optional-list",
"[review]\noptional_apps = [\"chatgpt-codex-connector\", \"gemini-code-assist\"]\n",
vec!["chatgpt-codex-connector", "gemini-code-assist"],
),
(
"optional-scalar",
"[review]\noptional_apps = \"chatgpt-codex-connector\"\n",
vec!["chatgpt-codex-connector"],
),
] {
clear_config_env();
let cwd = write_project_settings(name, body);
assert_eq!(review_optional_apps(&cwd), want);
}
}
#[test]
fn review_optional_apps_explicit_local_empty_masks_global() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let global = write_file(
"optional-global",
"[review]\noptional_apps = [\"chatgpt-codex-connector\"]\n",
);
std::env::set_var(
"FNO_GLOBAL_SETTINGS_PATH",
global.with_file_name("settings.json"),
);
let cwd = write_project_settings("optional-local-empty", "[review]\noptional_apps = []\n");
let got = review_optional_apps(&cwd);
clear_config_env();
assert!(
got.is_empty(),
"explicit local [] must mask the global list"
);
}
#[test]
fn review_optional_apps_malformed_value_degrades_to_empty() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings(
"optional-malformed",
"[review]\noptional_apps = { login = \"chatgpt-codex-connector\" }\n",
);
assert!(review_optional_apps(&cwd).is_empty());
}
#[test]
fn auto_merge_strategy_absent_is_merge() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("ams-absent", "schema_version = 1\n");
assert_eq!(auto_merge_strategy(&cwd), "merge");
}
#[test]
fn auto_merge_strategy_honors_squash_and_rebase() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for want in ["squash", "rebase", "merge"] {
clear_config_env();
let cwd = write_project_settings(
&format!("ams-{want}"),
&format!("[auto_merge]\nmerge_strategy = \"{want}\"\n"),
);
assert_eq!(auto_merge_strategy(&cwd), want);
}
}
#[test]
fn auto_merge_strategy_invalid_degrades_to_merge() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("ams-bad", "[auto_merge]\nmerge_strategy = \"octopus\"\n");
assert_eq!(auto_merge_strategy(&cwd), "merge");
}
#[test]
fn auto_merge_delete_branch_absent_is_true() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("amd-absent", "schema_version = 1\n");
assert!(auto_merge_delete_branch(&cwd));
}
#[test]
fn auto_merge_delete_branch_present_non_affirmative_is_false() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for body in [
"[auto_merge]\ndelete_branch_on_merge = false\n",
"[auto_merge]\ndelete_branch_on_merge = \"no\"\n",
"[auto_merge]\ndelete_branch_on_merge = \"false\"\n",
"[auto_merge]\ndelete_branch_on_merge = 0\n",
] {
clear_config_env();
let cwd = write_project_settings("amd-off", body);
assert!(
!auto_merge_delete_branch(&cwd),
"a present non-affirmative value must suppress --delete-branch: {body}"
);
}
}
#[test]
fn auto_merge_delete_branch_affirmative_forms_are_true() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for body in [
"[auto_merge]\ndelete_branch_on_merge = true\n",
"[auto_merge]\ndelete_branch_on_merge = \"yes\"\n",
"[auto_merge]\ndelete_branch_on_merge = 1\n",
"[auto_merge]\ndelete_branch_on_merge = \" YES \"\n",
] {
clear_config_env();
let cwd = write_project_settings("amd-on", body);
assert!(auto_merge_delete_branch(&cwd), "affirmative: {body}");
}
}
fn linked_worktree_with_canonical_config(name: &str, body: &str) -> Option<PathBuf> {
fn git(dir: &Path, args: &[&str]) -> bool {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
std::process::Command::new("git")
.arg("--version")
.output()
.ok()?;
let base = std::env::temp_dir().join(format!("fno-canon-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let main = base.join("main");
std::fs::create_dir_all(&main).ok()?;
git(&main, &["init", "-q"]).then_some(())?;
git(&main, &["config", "user.email", "t@t"]);
git(&main, &["config", "user.name", "t"]);
git(&main, &["commit", "-q", "--allow-empty", "-m", "init"]);
std::fs::create_dir_all(main.join(".fno")).ok()?;
std::fs::write(main.join(".fno/config.toml"), body).ok()?;
let linked = base.join("wt");
git(
&main,
&["worktree", "add", "-q", linked.to_str()?, "-b", "feat"],
)
.then_some(())?;
assert!(!linked.join(".fno/config.toml").exists());
Some(linked)
}
#[test]
fn canonical_checkout_config_is_read_from_a_linked_worktree() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let Some(linked) = linked_worktree_with_canonical_config(
"read",
"[auto_merge]\nmerge_strategy = \"squash\"\ndelete_branch_on_merge = false\n",
) else {
return;
};
assert_eq!(auto_merge_strategy(&linked), "squash");
assert!(!auto_merge_delete_branch(&linked));
}
#[test]
fn canonical_checkout_config_is_dropped_by_the_suppress_env() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let Some(linked) = linked_worktree_with_canonical_config(
"suppress",
"[auto_merge]\nmerge_strategy = \"squash\"\n",
) else {
return;
};
std::env::set_var("FNO_NO_CANONICAL_CONFIG", "1");
let got = auto_merge_strategy(&linked);
std::env::remove_var("FNO_NO_CANONICAL_CONFIG");
assert_eq!(
got, "merge",
"suppressed canonical must fall to the default"
);
}
#[test]
fn auto_merge_strategy_tolerates_padding() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd =
write_project_settings("ams-pad", "[auto_merge]\nmerge_strategy = \" squash \"\n");
assert_eq!(auto_merge_strategy(&cwd), "squash");
}
#[test]
fn dispatch_auto_merge_non_bool_degrades_to_false() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_config_env();
let cwd = write_project_settings("am-str", "[dispatch]\nauto_merge = \"yes\"\n");
std::env::set_var("FNO_CONFIG", cwd.join(".fno/config.toml"));
let got = dispatch_auto_merge(&cwd);
clear_config_env();
assert!(!got);
}
}