use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
use serde::Serialize;
use tracing::debug;
pub(crate) const KEY_FILES_SECTION_TITLE: &str = "Key Files for This Session";
pub(crate) const TRUNK_BRANCH_NAMES: &[&str] = &["main", "master", "develop"];
pub(crate) const REQUIRED_SECTIONS: &[&str] = &[
"## Immediate Actions",
"## Completed State",
"## Operational Guardrails",
"## Key Files for This Session",
"## Definition of Done",
];
pub(crate) fn is_trunk_branch(branch: &str) -> bool {
TRUNK_BRANCH_NAMES.contains(&branch)
}
#[derive(Serialize, Clone)]
pub(crate) struct GitState {
pub(crate) head: String,
pub(crate) branch: String,
pub(crate) upstream: Option<String>,
pub(crate) ahead: usize,
pub(crate) behind: usize,
pub(crate) clean: bool,
pub(crate) staged_files: Vec<String>,
pub(crate) unstaged_files: Vec<String>,
pub(crate) untracked_files: Vec<String>,
pub(crate) recent_commits: Vec<GitCommit>,
}
#[derive(Serialize, Clone)]
pub(crate) struct GitCommit {
pub(crate) sha: String,
pub(crate) subject: String,
}
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, Copy)]
pub(crate) enum BranchMode {
RequireNamedBranch,
AllowDetachedHead,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum CheckoutContinuityAdvisory {
DetachedHead,
LandedBranch { trunk: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CheckoutStateStatus {
NamedBranch,
DetachedHead,
LandedBranch,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct CheckoutStateView {
pub(crate) status: CheckoutStateStatus,
pub(crate) advisory: bool,
pub(crate) summary: String,
}
impl CheckoutContinuityAdvisory {
fn advisory_summary(&self, git: &GitState) -> String {
match self {
Self::DetachedHead => format!("checkout is detached at HEAD `{}`", git.head),
Self::LandedBranch { trunk } => format!(
"branch `{}` is already landed on local `origin/{trunk}`",
git.branch
),
}
}
pub(crate) fn start_alert_message(&self, git: &GitState) -> String {
format!(
"{}; `ccd start` preserves session continuity instead of auto-resetting the handoff, and live checkout context remains advisory only, so verify whether the current session should continue or be cleared deliberately",
self.advisory_summary(git)
)
}
pub(crate) fn doctor_message(&self, git: &GitState) -> String {
format!(
"{}; continuity analysis remains available and checkout context is derived live",
self.advisory_summary(git)
)
}
pub(crate) fn handoff_refresh_warning(&self, git: &GitState) -> String {
match self {
Self::DetachedHead => format!(
"checkout is detached at HEAD `{}`; `ccd handoff refresh` reports live checkout context without mutating the handoff",
git.head
),
Self::LandedBranch { trunk } => format!(
"branch `{}` is already landed on local `origin/{trunk}`; `ccd handoff refresh` reports live checkout context without mutating the handoff",
git.branch
),
}
}
}
type ParsedStatus = (
String,
Option<String>,
usize,
usize,
Vec<String>,
Vec<String>,
Vec<String>,
);
pub(crate) fn read_git_state(repo_root: &Path, branch_mode: BranchMode) -> Result<GitState> {
let (head, status_output, log_output) = std::thread::scope(|s| {
let h = s.spawn(|| git_output(repo_root, &["rev-parse", "--short", "HEAD"]));
let st = s.spawn(|| git_output(repo_root, &["status", "--porcelain=v1", "--branch"]));
let lg = s.spawn(|| git_output(repo_root, &["log", "--oneline", "-5"]));
(
h.join().expect("git rev-parse thread panicked"),
st.join().expect("git status thread panicked"),
lg.join().expect("git log thread panicked"),
)
});
let head = head?;
let status_output = status_output?;
let log_output = log_output?;
let (branch, upstream, ahead, behind, staged_files, unstaged_files, untracked_files) =
parse_status(&status_output, branch_mode)?;
let recent_commits = parse_recent_commits(&log_output);
let clean = staged_files.is_empty() && unstaged_files.is_empty() && untracked_files.is_empty();
Ok(GitState {
head,
branch,
upstream,
ahead,
behind,
clean,
staged_files,
unstaged_files,
untracked_files,
recent_commits,
})
}
pub(crate) fn current_system_state_lines(
git: Option<&GitState>,
session_id: Option<&str>,
) -> Vec<String> {
refresh_current_system_state(&[], git, session_id)
}
fn normalize_section_label(section: &str) -> &str {
section.trim().trim_start_matches('#').trim()
}
fn find_section_bounds(contents: &str, section: &str) -> Option<(usize, usize)> {
let section = normalize_section_label(section);
let parser = Parser::new_ext(contents, Options::empty());
let mut start = None;
let mut heading_byte = 0;
let mut in_h2 = false;
for (event, range) in parser.into_offset_iter() {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H2,
..
}) => {
if let Some(s) = start {
return Some((s, range.start));
}
heading_byte = range.start;
in_h2 = true;
}
Event::Text(text) if in_h2 => {
in_h2 = false;
if text.as_ref().starts_with(section) {
start = Some(heading_byte);
}
}
_ => {
in_h2 = false;
}
}
}
start.map(|value| (value, contents.len()))
}
pub(crate) fn has_section(contents: &str, section: &str) -> bool {
find_section_bounds(contents, section).is_some()
}
pub(crate) fn checkout_continuity_advisory(
repo_root: &Path,
git: &GitState,
) -> Option<CheckoutContinuityAdvisory> {
if git.branch == "HEAD" {
return Some(CheckoutContinuityAdvisory::DetachedHead);
}
branch_merged_into_trunk(repo_root, &git.branch)
.map(|trunk| CheckoutContinuityAdvisory::LandedBranch { trunk })
}
pub(crate) fn checkout_state_view(repo_root: &Path, git: &GitState) -> CheckoutStateView {
match checkout_continuity_advisory(repo_root, git) {
Some(CheckoutContinuityAdvisory::DetachedHead) => CheckoutStateView {
status: CheckoutStateStatus::DetachedHead,
advisory: true,
summary: format!(
"checkout is detached at HEAD `{}`; continuity analysis remains available and checkout snapshot drift is advisory only",
git.head
),
},
Some(CheckoutContinuityAdvisory::LandedBranch { trunk }) => CheckoutStateView {
status: CheckoutStateStatus::LandedBranch,
advisory: true,
summary: format!(
"branch `{}` is already landed on local `origin/{trunk}`; continuity analysis remains available and checkout snapshot drift is advisory only",
git.branch
),
},
None => CheckoutStateView {
status: CheckoutStateStatus::NamedBranch,
advisory: false,
summary: format!(
"branch `{}` uses standard continuity and checkout freshness rules",
git.branch
),
},
}
}
pub(crate) fn extract_title(contents: &str) -> Result<String> {
let parser = Parser::new_ext(contents, Options::empty());
let mut in_h1 = false;
for (event, _range) in parser.into_offset_iter() {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H1,
..
}) => {
in_h1 = true;
}
Event::Text(text) if in_h1 => {
return Ok(text.to_string());
}
_ => {
in_h1 = false;
}
}
}
bail!("handoff.md is missing a top-level title")
}
pub(crate) fn extract_numbered_section(contents: &str, section: &str) -> Vec<String> {
extract_section_lines(contents, section)
.into_iter()
.filter_map(parse_numbered_item)
.collect()
}
pub(crate) fn extract_bulleted_section(contents: &str, section: &str) -> Vec<String> {
extract_section_lines(contents, section)
.into_iter()
.filter_map(|line| line.strip_prefix("- ").map(str::trim).map(str::to_owned))
.collect()
}
fn refresh_current_system_state(
current_lines: &[String],
git: Option<&GitState>,
session_id: Option<&str>,
) -> Vec<String> {
let mut refreshed = Vec::new();
if let Some(sid) = session_id {
refreshed.push(format!("Session: `{sid}`"));
}
if let Some(git) = git {
refreshed.push(format!("Branch: `{}`", git.branch));
refreshed.push(render_head_line(git));
refreshed.push(render_worktree_status_line(git));
}
refreshed.extend(
current_lines
.iter()
.filter(|line| {
matches!(
classify_current_system_state_line(line),
CurrentSystemStateLineKind::Other
)
})
.cloned(),
);
refreshed
}
fn render_head_line(git: &GitState) -> String {
match git.recent_commits.first() {
Some(commit) => format!("HEAD: `{}` (`{}`)", git.head, commit.subject),
None => format!("HEAD: `{}`", git.head),
}
}
fn render_worktree_status_line(git: &GitState) -> String {
let tracking_state = if git.branch == "HEAD" {
"checkout is detached".to_owned()
} else {
match (git.upstream.as_deref(), git.ahead, git.behind) {
(Some(upstream), 0, 0) => format!("`{}` is in sync with `{upstream}`", git.branch),
(Some(upstream), ahead, 0) => {
format!(
"`{}` is ahead of `{upstream}` by {ahead} commit(s)",
git.branch
)
}
(Some(upstream), 0, behind) => {
format!(
"`{}` is behind `{upstream}` by {behind} commit(s)",
git.branch
)
}
(Some(upstream), ahead, behind) => format!(
"`{}` is ahead of `{upstream}` by {ahead} commit(s) and behind by {behind} commit(s)",
git.branch
),
(None, _, _) => format!("`{}` has no configured upstream", git.branch),
}
};
if git.clean {
return format!("The tracked worktree is clean and {tracking_state}.");
}
let mut changes = Vec::new();
if !git.staged_files.is_empty() {
changes.push(format!("staged: {}", git.staged_files.join(", ")));
}
if !git.unstaged_files.is_empty() {
changes.push(format!("unstaged: {}", git.unstaged_files.join(", ")));
}
if !git.untracked_files.is_empty() {
changes.push(format!("untracked: {}", git.untracked_files.join(", ")));
}
if changes.is_empty() {
format!("The tracked worktree is dirty and {tracking_state}.")
} else {
format!(
"The tracked worktree is dirty ({}) and {tracking_state}.",
changes.join("; ")
)
}
}
enum CurrentSystemStateLineKind {
Session,
Branch,
Head,
Worktree,
Other,
}
fn classify_current_system_state_line(line: &str) -> CurrentSystemStateLineKind {
if line.starts_with("Session: `") {
return CurrentSystemStateLineKind::Session;
}
if line.starts_with("Branch: `") {
return CurrentSystemStateLineKind::Branch;
}
if line.starts_with("HEAD: `") {
return CurrentSystemStateLineKind::Head;
}
let lower = line.to_ascii_lowercase();
if lower.contains("worktree")
|| lower.contains("branch is clean")
|| lower.contains("ahead of `")
|| lower.contains("behind `")
|| lower.contains("in sync with `")
|| lower.contains("has no configured upstream")
{
CurrentSystemStateLineKind::Worktree
} else {
CurrentSystemStateLineKind::Other
}
}
fn extract_section_lines<'a>(contents: &'a str, section: &str) -> Vec<&'a str> {
let Some((start, end)) = find_section_bounds(contents, section) else {
return Vec::new();
};
let body = &contents[start..end];
body.lines()
.skip_while(|line| line.starts_with("## "))
.filter(|line| !line.trim().is_empty())
.collect()
}
fn parse_numbered_item(line: &str) -> Option<String> {
let trimmed = line.trim();
let dot = trimmed.find(". ")?;
trimmed[..dot].parse::<usize>().ok()?;
Some(trimmed[dot + 2..].trim().to_owned())
}
fn parse_recent_commits(output: &str) -> Vec<GitCommit> {
output
.lines()
.filter_map(|line| {
let (sha, subject) = line.split_once(' ')?;
Some(GitCommit {
sha: sha.to_owned(),
subject: subject.to_owned(),
})
})
.collect()
}
fn parse_status(output: &str, branch_mode: BranchMode) -> Result<ParsedStatus> {
let mut lines = output.lines();
let branch_line = lines
.next()
.ok_or_else(|| anyhow::anyhow!("git status did not return branch metadata"))?;
let (branch, upstream, ahead, behind) = parse_branch_line(branch_line, branch_mode)?;
let mut staged_files = Vec::new();
let mut unstaged_files = Vec::new();
let mut untracked_files = Vec::new();
for line in lines {
if let Some(path) = line.strip_prefix("?? ") {
untracked_files.push(path.to_owned());
continue;
}
if line.len() < 4 {
continue;
}
let mut chars = line.chars();
let x = chars.next().unwrap_or(' ');
let y = chars.next().unwrap_or(' ');
let path = line[3..].to_owned();
if x != ' ' {
staged_files.push(path.clone());
}
if y != ' ' {
unstaged_files.push(path);
}
}
Ok((
branch,
upstream,
ahead,
behind,
staged_files,
unstaged_files,
untracked_files,
))
}
fn parse_branch_line(
line: &str,
branch_mode: BranchMode,
) -> Result<(String, Option<String>, usize, usize)> {
let raw = line
.strip_prefix("## ")
.ok_or_else(|| anyhow::anyhow!("unexpected git status header: {line}"))?;
if raw == "HEAD (no branch)" {
return match branch_mode {
BranchMode::RequireNamedBranch => {
bail!("detached HEAD is not supported by `ccd radar-state` yet")
}
BranchMode::AllowDetachedHead => Ok(("HEAD".to_owned(), None, 0, 0)),
};
}
let mut branch_and_tracking = raw;
let mut ahead = 0usize;
let mut behind = 0usize;
if let Some((left, right)) = raw.split_once(" [") {
branch_and_tracking = left;
let counts = right.trim_end_matches(']');
for entry in counts.split(", ") {
if let Some(value) = entry.strip_prefix("ahead ") {
ahead = value.parse().unwrap_or(0);
}
if let Some(value) = entry.strip_prefix("behind ") {
behind = value.parse().unwrap_or(0);
}
}
}
let (branch, upstream) = if let Some((branch, upstream)) = branch_and_tracking.split_once("...")
{
(branch.to_owned(), Some(upstream.to_owned()))
} else {
(branch_and_tracking.to_owned(), None)
};
Ok((branch, upstream, ahead, behind))
}
pub(crate) fn branch_merged_into_trunk(repo_root: &Path, branch: &str) -> Option<String> {
if is_trunk_branch(branch) {
return None;
}
let head_sha = git_output_optional(repo_root, &["rev-parse", "HEAD"]);
let head_sha = head_sha?;
for trunk in TRUNK_BRANCH_NAMES {
let ref_name = format!("origin/{trunk}");
let trunk_tree_ref = format!("{ref_name}^{{tree}}");
if git_output_optional(repo_root, &["rev-parse", "--verify", &ref_name]).is_none() {
continue;
}
if git_exit_code(
repo_root,
&["merge-base", "--is-ancestor", "HEAD", &ref_name],
) == Some(0)
{
let merge_parents = git_output_optional(
repo_root,
&[
"log",
"--merges",
"--ancestry-path",
"--format=%P",
&format!("{head_sha}..{ref_name}"),
],
);
if merge_parents.as_deref().is_some_and(|parents| {
parents
.lines()
.any(|line| line.split_whitespace().any(|parent| parent == head_sha))
}) {
return Some(trunk.to_string());
}
}
let merge_base = git_output_optional(repo_root, &["merge-base", "HEAD", &ref_name]);
let Some(merge_base) = merge_base else {
continue;
};
if merge_base == head_sha {
continue;
}
if git_exit_code(repo_root, &["diff", "--quiet", &merge_base, "HEAD"]) == Some(0) {
continue;
}
let merged_tree = git_output_optional(
repo_root,
&["merge-tree", "--write-tree", &ref_name, "HEAD"],
);
let trunk_tree = git_output_optional(repo_root, &["rev-parse", &trunk_tree_ref]);
if merged_tree.as_deref() != trunk_tree.as_deref() {
continue;
}
return Some(trunk.to_string());
}
None
}
fn git_output(repo_root: &Path, args: &[&str]) -> Result<String> {
debug!(args = ?args, dir = %repo_root.display(), "spawning git");
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(args)
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
debug!(success = output.status.success(), "git completed");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let detail = if !stderr.is_empty() { stderr } else { stdout };
bail!("git {} failed: {detail}", args.join(" "));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn git_output_optional(repo_root: &Path, args: &[&str]) -> Option<String> {
debug!(args = ?args, dir = %repo_root.display(), "spawning git");
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(args)
.output()
.ok()?;
debug!(success = output.status.success(), "git completed");
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn git_exit_code(repo_root: &Path, args: &[&str]) -> Option<i32> {
debug!(args = ?args, dir = %repo_root.display(), "spawning git");
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(args)
.output()
.ok()?;
debug!(success = output.status.success(), "git completed");
output.status.code()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_title_returns_h1_text() {
let doc = "# My Handoff\n\n## Section\n\nBody text.";
assert_eq!(extract_title(doc).unwrap(), "My Handoff");
}
#[test]
fn extract_title_ignores_h2() {
let doc = "## Not A Title\n\nBody text.";
assert!(extract_title(doc).is_err());
}
#[test]
fn extract_title_empty_document() {
assert!(extract_title("").is_err());
}
#[test]
fn find_section_bounds_single_section() {
let doc = "# Title\n\n## Alpha\n\nContent here.\n";
let (start, end) = find_section_bounds(doc, "Alpha").unwrap();
assert!(doc[start..end].contains("Alpha"));
assert!(doc[start..end].contains("Content here."));
}
#[test]
fn find_section_bounds_middle_section() {
let doc = "## One\n\nA\n\n## Two\n\nB\n\n## Three\n\nC\n";
let (start, end) = find_section_bounds(doc, "Two").unwrap();
let section = &doc[start..end];
assert!(section.contains("Two"));
assert!(section.contains("B"));
assert!(!section.contains("C"));
}
#[test]
fn find_section_bounds_last_section_extends_to_end() {
let doc = "## First\n\nA\n\n## Last\n\nZ\n";
let (start, end) = find_section_bounds(doc, "Last").unwrap();
assert_eq!(end, doc.len());
assert!(doc[start..end].contains("Z"));
}
#[test]
fn find_section_bounds_missing_returns_none() {
let doc = "## Alpha\n\nA\n";
assert!(find_section_bounds(doc, "Missing").is_none());
}
#[test]
fn has_section_matches_immediate_actions_with_suffix() {
let doc = "## Immediate Actions (in order)\n\n1. Review the state.\n";
assert!(has_section(doc, "## Immediate Actions"));
}
#[test]
fn has_section_does_not_treat_similar_key_files_heading_as_match() {
let doc = "## Key Files Changed\n\n- `src/lib.rs`\n";
assert!(!has_section(doc, "## Key Files for This Session"));
}
#[test]
fn extract_bulleted_section_normal() {
let doc = "## Items\n\n- First\n- Second\n- Third\n";
let items = extract_bulleted_section(doc, "Items");
assert_eq!(items, vec!["First", "Second", "Third"]);
}
#[test]
fn extract_bulleted_section_strips_whitespace() {
let doc = "## Items\n\n- Padded \n";
let items = extract_bulleted_section(doc, "Items");
assert_eq!(items, vec!["Padded"]);
}
#[test]
fn extract_bulleted_section_missing() {
let doc = "## Other\n\n- X\n";
assert!(extract_bulleted_section(doc, "Missing").is_empty());
}
#[test]
fn extract_bulleted_section_skips_non_bullet_lines() {
let doc = "## Items\n\nNarrative line.\n- Real item\nAnother line.\n";
let items = extract_bulleted_section(doc, "Items");
assert_eq!(items, vec!["Real item"]);
}
#[test]
fn extract_numbered_section_normal() {
let doc = "## Steps\n\n1. Alpha\n2. Beta\n3. Gamma\n";
let items = extract_numbered_section(doc, "Steps");
assert_eq!(items, vec!["Alpha", "Beta", "Gamma"]);
}
#[test]
fn extract_numbered_section_skips_malformed() {
let doc = "## Steps\n\n1. Good\nNot numbered\n2. Also good\n";
let items = extract_numbered_section(doc, "Steps");
assert_eq!(items, vec!["Good", "Also good"]);
}
#[test]
fn extract_numbered_section_missing() {
assert!(extract_numbered_section("## Other\n\n1. X\n", "Missing").is_empty());
}
#[test]
fn parse_numbered_item_valid() {
assert_eq!(
parse_numbered_item("1. Do the thing"),
Some("Do the thing".into())
);
}
#[test]
fn parse_numbered_item_high_number() {
assert_eq!(
parse_numbered_item("42. Item forty-two"),
Some("Item forty-two".into())
);
}
#[test]
fn parse_numbered_item_not_a_number() {
assert_eq!(parse_numbered_item("abc. Not valid"), None);
}
#[test]
fn parse_numbered_item_no_space_after_dot() {
assert_eq!(parse_numbered_item("1.No space"), None);
}
#[test]
fn parse_branch_line_simple() {
let (branch, upstream, ahead, behind) =
parse_branch_line("## main", BranchMode::RequireNamedBranch).unwrap();
assert_eq!(branch, "main");
assert!(upstream.is_none());
assert_eq!(ahead, 0);
assert_eq!(behind, 0);
}
#[test]
fn parse_branch_line_with_tracking() {
let (branch, upstream, ahead, behind) =
parse_branch_line("## main...origin/main", BranchMode::RequireNamedBranch).unwrap();
assert_eq!(branch, "main");
assert_eq!(upstream.as_deref(), Some("origin/main"));
assert_eq!(ahead, 0);
assert_eq!(behind, 0);
}
#[test]
fn parse_branch_line_ahead_behind() {
let (branch, upstream, ahead, behind) = parse_branch_line(
"## feature...origin/feature [ahead 3, behind 2]",
BranchMode::RequireNamedBranch,
)
.unwrap();
assert_eq!(branch, "feature");
assert_eq!(upstream.as_deref(), Some("origin/feature"));
assert_eq!(ahead, 3);
assert_eq!(behind, 2);
}
#[test]
fn parse_branch_line_ahead_only() {
let (_, _, ahead, behind) = parse_branch_line(
"## dev...origin/dev [ahead 5]",
BranchMode::RequireNamedBranch,
)
.unwrap();
assert_eq!(ahead, 5);
assert_eq!(behind, 0);
}
#[test]
fn parse_branch_line_detached_head_allowed() {
let (branch, upstream, _, _) =
parse_branch_line("## HEAD (no branch)", BranchMode::AllowDetachedHead).unwrap();
assert_eq!(branch, "HEAD");
assert!(upstream.is_none());
}
#[test]
fn parse_branch_line_detached_head_rejected() {
assert!(parse_branch_line("## HEAD (no branch)", BranchMode::RequireNamedBranch).is_err());
}
#[test]
fn parse_branch_line_missing_prefix() {
assert!(parse_branch_line("main", BranchMode::RequireNamedBranch).is_err());
}
#[test]
fn parse_status_clean() {
let output = "## main...origin/main";
let (branch, upstream, ahead, behind, staged, unstaged, untracked) =
parse_status(output, BranchMode::RequireNamedBranch).unwrap();
assert_eq!(branch, "main");
assert_eq!(upstream.as_deref(), Some("origin/main"));
assert_eq!((ahead, behind), (0, 0));
assert!(staged.is_empty());
assert!(unstaged.is_empty());
assert!(untracked.is_empty());
}
#[test]
fn parse_status_with_untracked() {
let output = "## main\n?? new_file.rs\n?? another.txt";
let (_, _, _, _, staged, unstaged, untracked) =
parse_status(output, BranchMode::RequireNamedBranch).unwrap();
assert!(staged.is_empty());
assert!(unstaged.is_empty());
assert_eq!(untracked, vec!["new_file.rs", "another.txt"]);
}
#[test]
fn parse_status_staged_and_unstaged() {
let output = "## main\nM staged.rs\n M unstaged.rs";
let (_, _, _, _, staged, unstaged, untracked) =
parse_status(output, BranchMode::RequireNamedBranch).unwrap();
assert_eq!(staged, vec!["staged.rs"]);
assert_eq!(unstaged, vec!["unstaged.rs"]);
assert!(untracked.is_empty());
}
#[test]
fn parse_status_both_staged_and_unstaged_same_file() {
let output = "## main\nMM both.rs";
let (_, _, _, _, staged, unstaged, _) =
parse_status(output, BranchMode::RequireNamedBranch).unwrap();
assert_eq!(staged, vec!["both.rs"]);
assert_eq!(unstaged, vec!["both.rs"]);
}
#[test]
fn parse_status_empty_fails() {
assert!(parse_status("", BranchMode::RequireNamedBranch).is_err());
}
#[test]
fn parse_recent_commits_normal() {
let output = "abc1234 fix: typo in readme\ndef5678 feat: add new command";
let commits = parse_recent_commits(output);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].sha, "abc1234");
assert_eq!(commits[0].subject, "fix: typo in readme");
assert_eq!(commits[1].sha, "def5678");
assert_eq!(commits[1].subject, "feat: add new command");
}
#[test]
fn parse_recent_commits_empty() {
assert!(parse_recent_commits("").is_empty());
}
fn make_git_state(
branch: &str,
upstream: Option<&str>,
ahead: usize,
behind: usize,
clean: bool,
) -> GitState {
GitState {
head: "abc1234".into(),
branch: branch.into(),
upstream: upstream.map(Into::into),
ahead,
behind,
clean,
staged_files: Vec::new(),
unstaged_files: Vec::new(),
untracked_files: Vec::new(),
recent_commits: Vec::new(),
}
}
#[test]
fn render_worktree_clean_in_sync() {
let git = make_git_state("main", Some("origin/main"), 0, 0, true);
let line = render_worktree_status_line(&git);
assert!(line.contains("clean"));
assert!(line.contains("in sync with `origin/main`"));
}
#[test]
fn render_worktree_clean_ahead() {
let git = make_git_state("dev", Some("origin/dev"), 3, 0, true);
let line = render_worktree_status_line(&git);
assert!(line.contains("ahead of `origin/dev` by 3 commit(s)"));
}
#[test]
fn render_worktree_no_upstream() {
let git = make_git_state("feature", None, 0, 0, true);
let line = render_worktree_status_line(&git);
assert!(line.contains("has no configured upstream"));
}
#[test]
fn render_worktree_dirty_with_staged() {
let mut git = make_git_state("main", Some("origin/main"), 0, 0, false);
git.staged_files = vec!["file.rs".into()];
let line = render_worktree_status_line(&git);
assert!(line.contains("dirty"));
assert!(line.contains("staged: file.rs"));
}
#[test]
fn current_system_state_lines_are_derived_from_live_git_state() {
let git = make_git_state("main", Some("origin/main"), 0, 0, true);
let lines = current_system_state_lines(Some(&git), Some("ses_TEST"));
assert_eq!(lines[0], "Session: `ses_TEST`");
assert_eq!(lines[1], "Branch: `main`");
assert!(lines[2].starts_with("HEAD: `abc1234`"));
assert_eq!(
lines[3],
"The tracked worktree is clean and `main` is in sync with `origin/main`."
);
}
#[test]
fn current_system_state_lines_skip_git_sections_when_git_is_unavailable() {
let lines = current_system_state_lines(None, Some("ses_TEST"));
assert_eq!(lines, vec!["Session: `ses_TEST`".to_owned()]);
}
#[test]
fn classify_branch_line() {
assert!(matches!(
classify_current_system_state_line("Branch: `main`"),
CurrentSystemStateLineKind::Branch
));
}
#[test]
fn classify_head_line() {
assert!(matches!(
classify_current_system_state_line("HEAD: `abc1234`"),
CurrentSystemStateLineKind::Head
));
}
#[test]
fn classify_worktree_line() {
assert!(matches!(
classify_current_system_state_line("The tracked worktree is clean"),
CurrentSystemStateLineKind::Worktree
));
}
#[test]
fn classify_session_line() {
assert!(matches!(
classify_current_system_state_line("Session: `01JNQX1234`"),
CurrentSystemStateLineKind::Session
));
}
#[test]
fn classify_other_line() {
assert!(matches!(
classify_current_system_state_line("All 203 tests pass"),
CurrentSystemStateLineKind::Other
));
}
}