Refactor: Formalize ports and update related files
This commit is contained in:
298
scripts/preflight.rs
Normal file
298
scripts/preflight.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env rust-script
|
||||
//! ```cargo
|
||||
//! [dependencies]
|
||||
//! anyhow = "1"
|
||||
//! colored = "2"
|
||||
//! ```
|
||||
//!
|
||||
//! Quick preflight context surfacer:
|
||||
//! - current shell detection
|
||||
//! - git status + recent history
|
||||
//! - tracked-file tree (git ls-files)
|
||||
//! - HANDOFF.* document summary
|
||||
//!
|
||||
//! Usage: ./scripts/preflight.rs [dir]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn run(cmd: &str, args: &[&str], cwd: &Path) -> Result<String> {
|
||||
let out = Command::new(cmd)
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run `{cmd}`"))?;
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn run_opt(cmd: &str, args: &[&str], cwd: &Path) -> String {
|
||||
run(cmd, args, cwd).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Shell detection ───────────────────────────────────────────────────────────
|
||||
|
||||
fn detect_shell() -> String {
|
||||
// SHELL env is the login shell; $0 in a running session is more precise but
|
||||
// not accessible from a child process, so we use SHELL + FISH_VERSION hint.
|
||||
let shell_env = std::env::var("SHELL").unwrap_or_else(|_| "unknown".into());
|
||||
let name = Path::new(&shell_env)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Nushell doesn't set SHELL; detect via NU_VERSION if present.
|
||||
if std::env::var("NU_VERSION").is_ok() || name == "nu" {
|
||||
return "nushell".into();
|
||||
}
|
||||
if std::env::var("FISH_VERSION").is_ok() {
|
||||
return "fish".into();
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
// ── Git helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn git_root(cwd: &Path) -> Option<PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
Some(PathBuf::from(s))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn git_branch(root: &Path) -> String {
|
||||
run_opt("git", &["branch", "--show-current"], root)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn git_status(root: &Path) -> String {
|
||||
run_opt("git", &["status", "--short"], root)
|
||||
}
|
||||
|
||||
fn git_log(root: &Path) -> String {
|
||||
run_opt(
|
||||
"git",
|
||||
&["log", "--oneline", "-7", "--decorate=short"],
|
||||
root,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tracked-file tree ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a tree from `git ls-files` output and render it as a compact indented
|
||||
/// string. Each directory node is printed once; files are leaves.
|
||||
fn tracked_tree(root: &Path) -> String {
|
||||
let raw = run_opt("git", &["ls-files"], root);
|
||||
let paths: Vec<&str> = raw.lines().collect();
|
||||
|
||||
// Group by first path component for a two-level summary.
|
||||
let mut dirs: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
let mut top_files: Vec<&str> = Vec::new();
|
||||
|
||||
for p in &paths {
|
||||
if let Some(slash) = p.find('/') {
|
||||
dirs.entry(&p[..slash]).or_default().push(&p[slash + 1..]);
|
||||
} else {
|
||||
top_files.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
for f in &top_files {
|
||||
out.push_str(&format!(" {f}\n"));
|
||||
}
|
||||
for (dir, files) in &dirs {
|
||||
out.push_str(&format!(" {dir}/ ({} files)\n", files.len()));
|
||||
// show up to 6 entries per dir to keep output compact
|
||||
for f in files.iter().take(6) {
|
||||
out.push_str(&format!(" {f}\n"));
|
||||
}
|
||||
if files.len() > 6 {
|
||||
out.push_str(&format!(" … and {} more\n", files.len() - 6));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── HANDOFF surfacer ──────────────────────────────────────────────────────────
|
||||
|
||||
fn surface_handoffs(root: &Path) -> Vec<(String, String)> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Collect HANDOFF.* from root and .ctx/
|
||||
let candidates: &[&str] = &["HANDOFF.md", ".ctx"];
|
||||
for c in candidates {
|
||||
let p = root.join(c);
|
||||
if p.is_dir() {
|
||||
// Read all HANDOFF yaml files in .ctx/
|
||||
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||
let mut entries: Vec<_> = rd.flatten().collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("HANDOFF") && !name.ends_with(".state.yaml") {
|
||||
let content =
|
||||
std::fs::read_to_string(entry.path()).unwrap_or_default();
|
||||
results.push((format!(".ctx/{name}"), summarize_handoff(&content)));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if p.is_file() {
|
||||
let content = std::fs::read_to_string(&p).unwrap_or_default();
|
||||
results.push((c.to_string(), summarize_handoff(&content)));
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Extract the most useful lines from a HANDOFF file (yaml or md).
|
||||
fn summarize_handoff(content: &str) -> String {
|
||||
// For YAML: grab project, branch, items, last log entry.
|
||||
// For MD: grab the first 20 non-blank lines.
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
if content.starts_with("project:") || content.contains("items:") {
|
||||
// YAML path — grab key fields
|
||||
let mut summary = Vec::new();
|
||||
let mut in_items = false;
|
||||
let mut item_count = 0;
|
||||
let mut last_log = String::new();
|
||||
let mut in_log = false;
|
||||
|
||||
for line in &lines {
|
||||
let t = line.trim();
|
||||
if t.starts_with("project:") || t.starts_with("branch:") || t.starts_with("updated:") {
|
||||
summary.push(t.to_string());
|
||||
}
|
||||
if t == "items: []" {
|
||||
summary.push("items: (none)".into());
|
||||
}
|
||||
if t.starts_with("- id:") || t.starts_with("- title:") {
|
||||
in_items = true;
|
||||
item_count += 1;
|
||||
summary.push(format!(" item: {}", t.trim_start_matches("- ")));
|
||||
}
|
||||
if t == "log:" {
|
||||
in_log = true;
|
||||
}
|
||||
if in_log && t.starts_with("summary:") {
|
||||
last_log = t.trim_start_matches("summary:").trim().to_string();
|
||||
}
|
||||
}
|
||||
if item_count > 0 {
|
||||
summary.push(format!("total items: {item_count}"));
|
||||
}
|
||||
if !last_log.is_empty() {
|
||||
summary.push(format!("last log: {last_log}"));
|
||||
}
|
||||
summary.join("\n")
|
||||
} else {
|
||||
// MD path — first 20 non-blank lines
|
||||
lines
|
||||
.iter()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.take(20)
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn section(title: &str) {
|
||||
println!("\n{}", format!("── {title} ").bold().cyan());
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cwd = std::env::args()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap());
|
||||
|
||||
println!("{}", "╔══════════════════════════════╗".bold());
|
||||
println!("{}", "║ preflight context surfacer ║".bold());
|
||||
println!("{}", "╚══════════════════════════════╝".bold());
|
||||
|
||||
// ── Shell ────────────────────────────────────────────────────────────────
|
||||
section("shell");
|
||||
println!(" {}", detect_shell().green());
|
||||
|
||||
// ── Git root ─────────────────────────────────────────────────────────────
|
||||
section("workspace");
|
||||
let root = git_root(&cwd);
|
||||
match &root {
|
||||
Some(r) => {
|
||||
let branch = git_branch(r);
|
||||
println!(" root: {}", r.display().to_string().yellow());
|
||||
println!(" branch: {}", branch.green());
|
||||
}
|
||||
None => {
|
||||
println!(" {}", "not a git repository".red());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(root) = &root {
|
||||
// ── Git status ───────────────────────────────────────────────────────
|
||||
section("git status");
|
||||
let status = git_status(root);
|
||||
if status.trim().is_empty() {
|
||||
println!(" {}", "clean".green());
|
||||
} else {
|
||||
for line in status.lines() {
|
||||
// color by status prefix
|
||||
let colored = if line.starts_with('M') || line.starts_with("RM") {
|
||||
line.yellow().to_string()
|
||||
} else if line.starts_with('A') {
|
||||
line.green().to_string()
|
||||
} else if line.starts_with('D') || line.starts_with('R') {
|
||||
line.red().to_string()
|
||||
} else if line.starts_with('?') {
|
||||
line.dimmed().to_string()
|
||||
} else {
|
||||
line.normal().to_string()
|
||||
};
|
||||
println!(" {colored}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git log ──────────────────────────────────────────────────────────
|
||||
section("recent commits");
|
||||
for line in git_log(root).lines() {
|
||||
println!(" {line}");
|
||||
}
|
||||
|
||||
// ── File tree ────────────────────────────────────────────────────────
|
||||
section("tracked files");
|
||||
print!("{}", tracked_tree(root));
|
||||
|
||||
// ── HANDOFF ──────────────────────────────────────────────────────────
|
||||
section("handoff");
|
||||
let handoffs = surface_handoffs(root);
|
||||
if handoffs.is_empty() {
|
||||
println!(" {}", "no HANDOFF files found".dimmed());
|
||||
} else {
|
||||
for (name, summary) in &handoffs {
|
||||
println!(" {}", name.bold().yellow());
|
||||
for line in summary.lines() {
|
||||
println!(" {line}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "─────────────────────────────────".dimmed());
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user