use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
const CHAIN_SENTINEL: &str = "CHAINSENTINEL";
const RENDER_STDIN_LIMIT_BYTES: usize = 1024 * 1024;
const CONFIG_LIMIT_BYTES: usize = 256 * 1024;
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_token() -> String {
let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{}-{nanos}-{counter}", std::process::id())
}
fn run_understatus(args: &[&str], stdin: &str) -> Vec<u8> {
run_understatus_with_config(args, stdin, "/nonexistent/understatus-test-config.toml")
}
fn run_understatus_with_config(args: &[&str], stdin: &str, config_path: &str) -> Vec<u8> {
let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
.args(args)
.env("NO_COLOR", "1")
.env("UNDERSTATUS_CONFIG", config_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("understatus 바이너리 실행 실패");
child
.stdin
.take()
.expect("stdin 핸들 없음")
.write_all(stdin.as_bytes())
.expect("stdin 쓰기 실패");
let output = child.wait_with_output().expect("자식 종료 대기 실패");
assert!(
output.status.success(),
"종료 코드 비정상: {:?}",
output.status
);
output.stdout
}
fn run_understatus_isolated_home(
args: &[&str],
stdin: &str,
config_path: &str,
home: &str,
) -> Vec<u8> {
let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
.args(args)
.env("NO_COLOR", "1")
.env("UNDERSTATUS_CONFIG", config_path)
.env("HOME", home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("understatus 바이너리 실행 실패");
child
.stdin
.take()
.expect("stdin 핸들 없음")
.write_all(stdin.as_bytes())
.expect("stdin 쓰기 실패");
let output = child.wait_with_output().expect("자식 종료 대기 실패");
assert!(
output.status.success(),
"종료 코드 비정상: {:?}",
output.status
);
output.stdout
}
fn write_chain_config(tag: &str) -> String {
let path = std::env::temp_dir().join(format!(
"understatus-chain-cfg-{}-{}.toml",
std::process::id(),
tag
));
let toml = format!("[chain]\nchain_command = \"printf {CHAIN_SENTINEL}\"\n");
std::fs::write(&path, toml).expect("임시 config 작성 실패");
path.to_string_lossy().into_owned()
}
fn write_stdin_len_chain_config(tag: &str) -> String {
let path = std::env::temp_dir().join(format!(
"understatus-chain-len-cfg-{}-{tag}.toml",
unique_token()
));
let command = "bytes=$(wc -c | tr -d ' '); printf 'LEN:%s' \"$bytes\"";
let toml = format!(
"[chain]\nchain_command = {command:?}\nchain_cache_ttl_seconds = 0\n[display]\nmax_width = 200\nshow_network = false\nshow_disk = false\nshow_battery = false\n"
);
std::fs::write(&path, toml).expect("stdin 길이 chain config 작성 실패");
path.to_string_lossy().into_owned()
}
fn make_isolated_home(tag: &str) -> String {
let path = std::env::temp_dir().join(format!("understatus-home-{}-{tag}", unique_token()));
std::fs::create_dir_all(&path).expect("격리 HOME 생성 실패");
path.to_string_lossy().into_owned()
}
#[test]
fn oneline_emits_single_line_without_trailing_newline() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(!text.ends_with('\n'), "후행 개행이 있으면 안 됨: {text:?}");
assert_eq!(
text.matches('\n').count(),
0,
"정확히 1행이어야 함(개행 0): {text:?}"
);
assert!(!text.is_empty(), "코어 출력이 비면 안 됨");
assert!(text.contains('%'), "시스템 지표(%)가 있어야 함: {text:?}");
}
#[test]
fn default_render_has_trailing_newline() {
let stdout = run_understatus(&["render"], "{}");
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
text.ends_with('\n'),
"기본 render는 println!으로 후행 개행이 있어야 함: {text:?}"
);
}
#[test]
fn oversized_stdin_is_not_forwarded_to_chain() {
let config = write_stdin_len_chain_config("oversized-stdin");
let home = make_isolated_home("oversized-stdin");
let oversized = "x".repeat(RENDER_STDIN_LIMIT_BYTES + 1);
let stdout = run_understatus_isolated_home(&["render"], &oversized, &config, &home);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
text.contains("LEN:0"),
"oversized stdin은 빈 raw_stdin으로 chain에 전달되어야 함: {text:?}"
);
assert!(
!text.contains(&format!("LEN:{}", RENDER_STDIN_LIMIT_BYTES + 1)),
"oversized 원문 길이가 chain에 노출되면 안 됨: {text:?}"
);
let _ = std::fs::remove_file(&config);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn oversized_config_is_ignored_by_render_path() {
let path = std::env::temp_dir().join(format!(
"understatus-oversized-render-cfg-{}.toml",
unique_token()
));
let mut toml = format!("[chain]\nchain_command = \"printf {CHAIN_SENTINEL}\"\n#");
toml.push_str(&"x".repeat(CONFIG_LIMIT_BYTES + 1));
std::fs::write(&path, toml).expect("oversized config 작성 실패");
let config = path.to_string_lossy().into_owned();
let home = make_isolated_home("oversized-config");
let stdout = run_understatus_isolated_home(
&["render"],
r#"{"session_id":"oversized-config"}"#,
&config,
&home,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
!text.contains(CHAIN_SENTINEL),
"oversized config는 파싱되지 않아 chain_command가 실행되면 안 됨: {text:?}"
);
assert!(
text.contains('%'),
"기본 설정 렌더는 계속 성공해야 함: {text:?}"
);
let _ = std::fs::remove_file(&config);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn oneline_does_not_run_chain() {
let config = write_chain_config("oneline-chain-skip");
let oneline_out = run_understatus_with_config(
&["render", "--oneline"],
r#"{"session_id":"oneline-skip-a"}"#,
&config,
);
let oneline_text = String::from_utf8(oneline_out).expect("stdout는 UTF-8이어야 함");
assert!(
!oneline_text.contains(CHAIN_SENTINEL),
"--oneline은 chain을 수행하면 안 됨(센티널 부재): {oneline_text:?}"
);
let control_out = run_understatus_with_config(
&["render"],
r#"{"session_id":"oneline-skip-control"}"#,
&config,
);
let control_text = String::from_utf8(control_out).expect("stdout는 UTF-8이어야 함");
assert!(
control_text.contains(CHAIN_SENTINEL),
"대조군(--oneline 없음, claude)은 chain이 실제로 돌아 센티널이 있어야 함: {control_text:?}"
);
let lterm_out = run_understatus_with_config(
&["render", "--source", "lterm"],
r#"{"source":"lterm","session":"oneline-skip-lterm","pane":"%1"}"#,
&config,
);
let lterm_text = String::from_utf8(lterm_out).expect("stdout는 UTF-8이어야 함");
assert!(
!lterm_text.contains(CHAIN_SENTINEL),
"--source lterm은 chain 기본 off여야 함(센티널 부재): {lterm_text:?}"
);
let _ = std::fs::remove_file(&config);
}
#[test]
fn oneline_cols_hint_does_not_force_truncation() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj","cols":10,"rows":2}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
text.contains("proj"),
"cols 힌트가 cwd를 잘라내면 안 됨(폭 권위는 lterm): {text:?}"
);
}
#[test]
fn oneline_lterm_non_git_cwd_has_no_git_segment() {
let non_git_cwd = std::env::temp_dir().join(format!("understatus-nogit-{}", unique_token()));
let stdin = format!(
r#"{{"source":"lterm","session":"codex","pane":"%3","cwd":{:?}}}"#,
non_git_cwd.to_string_lossy()
);
let stdout = run_understatus(&["render", "--source", "lterm", "--oneline"], &stdin);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
!text.contains('⎇'),
"non-git cwd lterm은 git 세그먼트(⎇)가 없어야 함: {text:?}"
);
}
#[test]
fn oneline_lterm_git_cwd_shows_branch() {
use std::io::Write;
let tmp = std::env::temp_dir().join(format!("understatus-lterm-git-{}", unique_token()));
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
writeln!(file, "ref: refs/heads/main").expect("HEAD 쓰기 실패");
let cwd = tmp.to_string_lossy().into_owned();
let stdin = format!(
r#"{{"source":"lterm","session":"codex","pane":"%3","agent":"codex","cwd":{cwd:?}}}"#
);
let oneline_out = run_understatus(&["render", "--source", "lterm", "--oneline"], &stdin);
let oneline_text = String::from_utf8(oneline_out).expect("stdout는 UTF-8이어야 함");
assert!(
oneline_text.contains("⎇ main"),
"유효 git cwd는 oneline에 ⎇ <branch>를 표시해야 함: {oneline_text:?}"
);
let cmux_out = run_understatus(
&[
"render",
"--source",
"lterm",
"--surface-format",
"cmux-status",
],
&stdin,
);
let cmux_text = String::from_utf8(cmux_out).expect("stdout는 UTF-8이어야 함");
let value: serde_json::Value = serde_json::from_str(&cmux_text).expect("valid JSON");
let pills = value["pills"].as_array().expect("pills 배열");
let git_pill = pills
.iter()
.find(|p| p["key"] == "git")
.expect("git pill이 있어야 함");
assert_eq!(
git_pill["value"], "main",
"git pill 값은 bare 브랜치명: {cmux_text:?}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn oneline_lterm_shows_session_pane_label() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","agent":"codex","cwd":"/x/ios_cleaner"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
text.contains("codex/%3"),
"lterm 출력에 session/pane 라벨이 있어야 함: {text:?}"
);
assert!(
text.contains("codex/%3 · ios_cleaner"),
"session/pane은 cwd 바로 앞에 표시되어야 함: {text:?}"
);
}
fn run_understatus_status(args: &[&str], stdin: &str) -> (Vec<u8>, std::process::ExitStatus) {
let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
.args(args)
.env("NO_COLOR", "1")
.env(
"UNDERSTATUS_CONFIG",
"/nonexistent/understatus-test-config.toml",
)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("understatus 바이너리 실행 실패");
child
.stdin
.take()
.expect("stdin 핸들 없음")
.write_all(stdin.as_bytes())
.expect("stdin 쓰기 실패");
let output = child.wait_with_output().expect("자식 종료 대기 실패");
(output.stdout, output.status)
}
#[test]
fn surface_format_cmux_status_emits_single_line_json() {
let stdout = run_understatus(
&[
"render",
"--source",
"lterm",
"--surface-format",
"cmux-status",
],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj","agent":"codex"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert_eq!(
text.matches('\n').count(),
0,
"cmux-status는 단일 줄 JSON이어야 함: {text:?}"
);
let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON이어야 함");
assert_eq!(value["schema"], "cmux-status");
assert_eq!(value["version"], 1);
assert!(value["pills"].is_array(), "pills 배열 필요: {text:?}");
}
#[test]
fn surface_format_bogus_exits_failure() {
let (_stdout, status) = run_understatus_status(&["render", "--surface-format", "bogus"], "{}");
assert!(
!status.success(),
"미지 surface-format은 실패 종료해야 함: {status:?}"
);
}
#[test]
fn surface_format_unspecified_defaults_to_oneline() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj","agent":"codex"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
!text.contains("\"schema\""),
"미지정 표면은 oneline이어야 함(JSON 아님): {text:?}"
);
assert!(text.contains('%'), "oneline 코어 세그먼트 유지: {text:?}");
}
fn write_deterministic_core_config(tag: &str) -> String {
let path = std::env::temp_dir().join(format!(
"understatus-core-cfg-{}-{}.toml",
std::process::id(),
tag
));
let toml = "[display]\nshow_network = false\nshow_disk = false\nshow_battery = false\n";
std::fs::write(&path, toml).expect("임시 config 작성 실패");
path.to_string_lossy().into_owned()
}
#[test]
fn surface_format_oneline_matches_default_render_skeleton() {
let config = write_deterministic_core_config("surface-oneline-identical");
let stdin = r#"{"session_id":"surface-oneline-identical"}"#;
let default_out = run_understatus_with_config(&["render"], stdin, &config);
let explicit_out =
run_understatus_with_config(&["render", "--surface-format", "oneline"], stdin, &config);
let default_text = String::from_utf8(default_out).expect("stdout는 UTF-8이어야 함");
let explicit_text = String::from_utf8(explicit_out).expect("stdout는 UTF-8이어야 함");
let _ = std::fs::remove_file(&config);
assert!(
explicit_text.ends_with('\n'),
"--surface-format oneline은 terse가 아니라 후행 개행을 유지해야 함: {explicit_text:?}"
);
assert!(
default_text.contains('%'),
"기본 코어 세그먼트: {default_text:?}"
);
assert!(
explicit_text.contains('%'),
"명시 oneline 코어 세그먼트: {explicit_text:?}"
);
let strip_live = |text: &str| -> String {
text.chars()
.filter(|c| {
!c.is_ascii_digit() && *c != '.' && !matches!(c, '○' | '▁' | '▄' | '▆' | '◆')
})
.collect()
};
assert_eq!(
strip_live(&default_text),
strip_live(&explicit_text),
"라이브 산출물을 제외한 세그먼트 골격은 byte-identical이어야 함:\n default={default_text:?}\n explicit={explicit_text:?}"
);
}
#[test]
fn surface_format_cmux_status_unenriched_three_pills() {
let stdout = run_understatus(
&[
"render",
"--source",
"lterm",
"--surface-format",
"cmux-status",
],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/nonexistent-cmux-pill-cwd","agent":"codex"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
let pills = value["pills"].as_array().expect("pills 배열");
let mut keys: Vec<&str> = pills.iter().filter_map(|p| p["key"].as_str()).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["cpu", "mem", "model"],
"enrich-실패 3 pill: {text:?}"
);
assert!(
value.get("progress").is_none(),
"progress 필드는 직렬화에서 제거되어야 함: {text:?}"
);
let model = pills.iter().find(|p| p["key"] == "model").unwrap();
let color = model["color"].as_str().expect("model 색");
assert!(
color.len() == 7 && color.starts_with('#'),
"model 색 #RRGGBB: {color:?}"
);
assert_eq!(model["value"], "codex", "bare agent 토큰 표시");
}
fn write_wide_config() -> String {
let path =
std::env::temp_dir().join(format!("understatus-codex-e2e-cfg-{}.toml", unique_token()));
std::fs::write(&path, "[display]\nmax_width = 200\n").expect("임시 config 작성 실패");
path.to_string_lossy().into_owned()
}
fn run_with_codex_env(
args: &[&str],
stdin: &str,
codex_home: &str,
home: &str,
config_path: &str,
) -> Vec<u8> {
let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
.args(args)
.env("NO_COLOR", "1")
.env("UNDERSTATUS_CONFIG", config_path)
.env("CODEX_HOME", codex_home)
.env("HOME", home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("understatus 바이너리 실행 실패");
child
.stdin
.take()
.expect("stdin 핸들 없음")
.write_all(stdin.as_bytes())
.expect("stdin 쓰기 실패");
let output = child.wait_with_output().expect("자식 종료 대기 실패");
assert!(
output.status.success(),
"종료 코드 비정상: {:?}",
output.status
);
output.stdout
}
fn write_synthetic_codex_session(cwd: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let unique = unique_token();
let codex_home = std::env::temp_dir().join(format!("understatus-e2e-codex-{unique}"));
let cache_home = std::env::temp_dir().join(format!("understatus-e2e-home-{unique}"));
let day_dir = codex_home
.join("sessions")
.join("2026")
.join("06")
.join("05");
std::fs::create_dir_all(&day_dir).expect("일자 디렉터리 생성 실패");
std::fs::create_dir_all(&cache_home).expect("캐시 홈 생성 실패");
let session_meta = format!(
r#"{{"timestamp":"2026-06-05T11:41:50.379Z","type":"session_meta","payload":{{"id":"abc","cwd":"{cwd}","originator":"codex-tui","cli_version":"0.137.0"}}}}"#
);
let turn_context = r#"{"type":"turn_context","payload":{"model":"gpt-5.5","effort":"xhigh","summary":"auto"}}"#;
let token_count = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":9999999},"last_token_usage":{"total_tokens":275},"model_context_window":1000},"rate_limits":{"limit_id":"codex","primary":{"used_percent":3.0,"window_minutes":300},"secondary":{"used_percent":21.0,"window_minutes":10080},"plan_type":"pro"}}}"#;
let path = day_dir.join("rollout-2026-06-05T20-40-45-e2e.jsonl");
let body = format!("{session_meta}\n{turn_context}\n{token_count}\n");
std::fs::write(&path, body).expect("합성 세션 쓰기 실패");
(codex_home, cache_home)
}
#[test]
fn e2e_codex_single_session_full_profile() {
let cwd = "/Users/me/e2e-codex-proj";
let (codex_home, cache_home) = write_synthetic_codex_session(cwd);
let config = write_wide_config();
let stdin = format!(
r#"{{"source":"lterm","session":"codex","pane":"%9","cwd":"{cwd}","agent":"codex"}}"#
);
let stdout = run_with_codex_env(
&["render", "--source", "lterm", "--oneline"],
&stdin,
&codex_home.to_string_lossy(),
&cache_home.to_string_lossy(),
&config,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert_eq!(
text.matches('\n').count(),
0,
"정확히 1행이어야 함: {text:?}"
);
assert!(text.contains("gpt-5.5"), "실모델 표시: {text:?}");
assert!(
text.contains("ctx 28%") || text.contains("ctx 27%"),
"ctx% 표시: {text:?}"
);
assert!(text.contains("5h 3%"), "5h 한도 표시: {text:?}");
assert!(text.contains("wk 21%"), "주간 한도 표시: {text:?}");
assert!(text.contains("pro"), "plan(bare value) 표시: {text:?}");
assert!(text.contains("xhigh"), "effort(bare value) 표시: {text:?}");
assert!(
!text.contains(" codex "),
"model 슬롯이 실모델로 enrich되어야 함: {text:?}"
);
let _ = std::fs::remove_dir_all(&codex_home);
let _ = std::fs::remove_dir_all(&cache_home);
let _ = std::fs::remove_file(&config);
}
#[test]
fn e2e_codex_unmatched_degrades_to_bare_lterm() {
let session_cwd = "/Users/me/e2e-codex-has-session";
let (codex_home, cache_home) = write_synthetic_codex_session(session_cwd);
let config = write_wide_config();
let stdin = r#"{"source":"lterm","session":"codex","pane":"%8","cwd":"/Users/me/e2e-no-match","agent":"codex"}"#;
let stdout = run_with_codex_env(
&["render", "--source", "lterm", "--oneline"],
stdin,
&codex_home.to_string_lossy(),
&cache_home.to_string_lossy(),
&config,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert_eq!(
text.matches('\n').count(),
0,
"정확히 1행이어야 함: {text:?}"
);
assert!(!text.contains("5h "), "미매칭은 5h 세그먼트 없음: {text:?}");
assert!(!text.contains("wk "), "미매칭은 wk 세그먼트 없음: {text:?}");
assert!(
!text.contains("gpt-5.5"),
"미매칭은 실모델 enrich 없음: {text:?}"
);
assert!(
!text.contains("ctx "),
"미매칭은 ctx 세그먼트 없음: {text:?}"
);
assert!(
text.contains("codex"),
"미매칭은 bare codex로 정직하게 저하해야 함: {text:?}"
);
let _ = std::fs::remove_dir_all(&codex_home);
let _ = std::fs::remove_dir_all(&cache_home);
let _ = std::fs::remove_file(&config);
}
#[test]
fn oneline_lterm_git_subdir_shows_branch() {
use std::io::Write;
let token = unique_token();
let root = std::env::temp_dir().join(format!("understatus-lterm-subdir-{token}"));
let git_dir = root.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let short = {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in token.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{:08x}", hash as u32)
};
let branch = format!("wt-{short}");
let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
writeln!(file, "ref: refs/heads/{branch}").expect("HEAD 쓰기 실패");
let subdir = root.join("src");
std::fs::create_dir_all(&subdir).expect("하위 dir 생성 실패");
let cwd = subdir.to_string_lossy().into_owned();
let stdin = format!(
r#"{{"source":"lterm","session":"codex","pane":"%3","agent":"codex","cwd":{cwd:?}}}"#
);
let cfg_path = std::env::temp_dir()
.join(format!("understatus-subdir-cfg-{token}.toml"))
.to_string_lossy()
.into_owned();
std::fs::write(&cfg_path, "[display]\nmax_width = 500\n").expect("임시 config 작성 실패");
let oneline_out = run_understatus_with_config(
&["render", "--source", "lterm", "--oneline"],
&stdin,
&cfg_path,
);
let oneline_text = String::from_utf8(oneline_out).expect("stdout는 UTF-8이어야 함");
assert!(
oneline_text.contains(&format!("⎇ {branch}")),
"repo 하위 dir cwd는 부모 walk-up으로 oneline에 고유 branch(⎇ {branch})를 표시해야 함: {oneline_text:?}"
);
let _ = std::fs::remove_file(&cfg_path);
let _ = std::fs::remove_dir_all(&root);
}