use std::fs;
use std::path::Path;
use std::process::ExitCode;
use anyhow::{bail, ensure, Context, Result};
use serde::Serialize;
use crate::commands::sync::{self, SyncProfile};
use crate::handoff::{self, BranchMode, GitState};
use crate::output::CommandReport;
use crate::paths::git as git_paths;
use crate::paths::state::StateLayout;
use crate::profile;
use crate::repo::marker as repo_marker;
use crate::repo::truth as project_truth;
use crate::state::runtime as runtime_state;
use crate::state::session as session_state;
#[derive(Serialize, Clone)]
pub struct DriftCheck {
file: String,
status: &'static str,
severity: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
last_commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_commit_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
commits_since: Option<usize>,
message: String,
}
#[derive(Serialize)]
pub struct DriftReport {
command: &'static str,
ok: bool,
repo_root: String,
profile: String,
locality_id: String,
source_order: &'static str,
stale_count: usize,
checks: Vec<DriftCheck>,
}
impl CommandReport for DriftReport {
fn exit_code(&self) -> ExitCode {
if self.stale_count > 0 {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn render_text(&self) {
for check in &self.checks {
let label = match check.status {
"fresh" => "FRESH",
"stale" => "STALE",
"untracked" => "UNTRACKED",
"missing" => "MISSING",
_ => "INFO",
};
println!("[{label}] {}", check.message);
}
println!(
"Drift summary: {} stale surface(s) out of {} checked.",
self.stale_count,
self.checks.len()
);
}
}
pub fn run(repo_root: &Path) -> Result<DriftReport> {
if !git_paths::is_git_work_tree(repo_root) {
bail!("not a git repository: {}", repo_root.display());
}
let profile = profile::resolve(None)?;
let layout = StateLayout::resolve(repo_root, profile.clone())?;
if !layout.profile_root().is_dir() {
bail!(
"profile `{}` does not exist at {}; bootstrap it with `ccd attach` before using `ccd drift`",
layout.profile(),
layout.profile_root().display()
);
}
let marker = repo_marker::load(repo_root)?.ok_or_else(|| {
anyhow::anyhow!(
"repo is not linked: {} is missing; bootstrap this clone with `ccd attach` or `ccd link` first",
repo_root.join(repo_marker::MARKER_FILE).display()
)
})?;
let manifest = project_truth::resolve_manifest(repo_root, &layout, &marker.locality_id)?;
let mut checks = manifest
.project_truth_paths
.iter()
.map(|path| {
let label = path
.strip_prefix(repo_root)
.unwrap_or(path)
.display()
.to_string();
check_file_drift(repo_root, &label)
})
.collect::<Result<Vec<_>>>()?;
let git = handoff::read_git_state(repo_root, BranchMode::AllowDetachedHead)?;
let sid = session_state::load_session_id(&layout)?;
checks.push(check_handoff_drift(&layout, &git, sid.as_deref())?);
let agents_path = repo_root.join("AGENTS.md");
if agents_path.is_file() {
let agents = fs::read_to_string(&agents_path)?;
let hash = sync::sha256(&agents);
let config = layout.load_profile_config()?;
let mut sync_profile = SyncProfile::default();
sync_profile.merge_config(&config.sync);
let generated = sync::render_all(repo_root, &agents, &hash, &sync_profile)?;
let sync_check = sync::check_generated(&generated);
for target in &generated.files {
if !target.path.is_file() || checks.iter().any(|c| c.file == target.label) {
continue;
}
if let Some(issue) = sync_check.issues.iter().find(|i| i.label == target.label) {
checks.push(DriftCheck {
file: issue.label.clone(),
status: "stale",
severity: "error",
last_commit: None,
last_commit_date: None,
commits_since: None,
message: sync::issue_message(issue, sync::SyncIssueSurface::Drift, repo_root),
});
} else {
checks.push(DriftCheck {
file: target.label.clone(),
status: "fresh",
severity: "info",
last_commit: None,
last_commit_date: None,
commits_since: None,
message: format!("{} is in sync", target.label),
});
}
}
}
let stale_count = checks
.iter()
.filter(|check| check.status == "stale" || check.status == "missing")
.count();
Ok(DriftReport {
command: "drift",
ok: stale_count == 0,
repo_root: repo_root.display().to_string(),
profile: profile.to_string(),
locality_id: marker.locality_id,
source_order: manifest.source_order,
stale_count,
checks,
})
}
fn check_file_drift(repo_root: &Path, file: &str) -> Result<DriftCheck> {
let path = repo_root.join(file);
if !path.exists() {
return Ok(DriftCheck {
file: file.to_string(),
status: "missing",
severity: "error",
last_commit: None,
last_commit_date: None,
commits_since: None,
message: format!("{file} does not exist"),
});
}
let commit_info = git_last_commit_for_file(repo_root, file)?;
match commit_info {
None => Ok(DriftCheck {
file: file.to_string(),
status: "untracked",
severity: "info",
last_commit: None,
last_commit_date: None,
commits_since: None,
message: format!("{file} exists but is not tracked by git"),
}),
Some((full_hash, short_hash, date)) => {
let commits_since = git_commits_since(repo_root, &full_hash)?;
if commits_since == 0 {
Ok(DriftCheck {
file: file.to_string(),
status: "fresh",
severity: "info",
last_commit: Some(short_hash.clone()),
last_commit_date: Some(date),
commits_since: Some(0),
message: format!("{file} was updated in the latest commit ({short_hash})"),
})
} else {
Ok(DriftCheck {
file: file.to_string(),
status: "stale",
severity: "warning",
last_commit: Some(short_hash.clone()),
last_commit_date: Some(date),
commits_since: Some(commits_since),
message: format!(
"{file} is {commits_since} commit(s) behind HEAD (last touched in {short_hash})"
),
})
}
}
}
}
fn check_handoff_drift(
layout: &StateLayout,
git: &GitState,
session_id: Option<&str>,
) -> Result<DriftCheck> {
let label = layout.state_db_path().display().to_string();
let surface = runtime_state::load_canonical_handoff_surface(layout)?;
if surface.is_missing() {
return Ok(DriftCheck {
file: label.clone(),
status: "missing",
severity: "error",
last_commit: None,
last_commit_date: None,
commits_since: None,
message: format!("{label} native handoff state does not exist"),
});
}
let _ = (git, session_id);
let (status, severity, commits_since, message) = (
"fresh",
"info",
Some(0),
format!(
"{label} stores canonical handoff continuity only; checkout context is derived live"
),
);
Ok(DriftCheck {
file: label,
status,
severity,
last_commit: None,
last_commit_date: None,
commits_since,
message,
})
}
pub(crate) fn git_last_commit_for_file(
repo: &Path,
file: &str,
) -> Result<Option<(String, String, String)>> {
let output = std::process::Command::new("git")
.args(["log", "-1", "--format=%H %h %cI", "--", file])
.current_dir(repo)
.output()
.context("failed to run git log")?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.trim();
if line.is_empty() {
return Ok(None);
}
let mut parts = line.splitn(3, ' ');
let full_hash = parts.next().unwrap_or_default().to_string();
let short_hash = parts.next().unwrap_or_default().to_string();
let date = parts.next().unwrap_or_default().to_string();
if full_hash.is_empty() || short_hash.is_empty() || date.is_empty() {
return Ok(None);
}
Ok(Some((full_hash, short_hash, date)))
}
pub(crate) fn git_commits_since(repo: &Path, commit_hash: &str) -> Result<usize> {
let output = std::process::Command::new("git")
.args(["rev-list", "--count", &format!("{commit_hash}..HEAD")])
.current_dir(repo)
.output()
.context("failed to run git rev-list")?;
ensure!(
output.status.success(),
"git rev-list failed for {commit_hash}..HEAD"
);
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.trim()
.parse()
.context("failed to parse commit count")
}