feat: project scanner CLI in Rust
scans git repos, reports status/staleness, writes state files to ~/.agents/projects/ for cross-agent continuity. usage: projscan [--json|--update]
This commit is contained in:
commit
56919cbfab
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
|
||||||
1077
Cargo.lock
generated
Normal file
1077
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "projscan"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
colored = "2"
|
||||||
|
git2 = "0.19"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
344
src/main.rs
Normal file
344
src/main.rs
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use clap::Parser;
|
||||||
|
use colored::Colorize;
|
||||||
|
use git2::{Repository, StatusOptions};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "projscan", about = "Scan project directories and report git status")]
|
||||||
|
struct Cli {
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
|
||||||
|
/// Write state files to ~/.agents/projects/
|
||||||
|
#[arg(long)]
|
||||||
|
update: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Config {
|
||||||
|
project: Vec<ProjectConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct ProjectConfig {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
priority: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct ProjectState {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
priority: String,
|
||||||
|
status: String,
|
||||||
|
branch: String,
|
||||||
|
uncommitted: usize,
|
||||||
|
untracked: usize,
|
||||||
|
last_commit: String,
|
||||||
|
last_message: String,
|
||||||
|
staleness_days: i64,
|
||||||
|
ahead: Option<usize>,
|
||||||
|
behind: Option<usize>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
dirs_or_default("config").join("projscan/config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agents_projects_dir() -> PathBuf {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||||
|
PathBuf::from(home).join(".agents/projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_or_default(kind: &str) -> PathBuf {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||||
|
match kind {
|
||||||
|
"config" => PathBuf::from(&home).join(".config"),
|
||||||
|
_ => PathBuf::from(&home),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config() -> Result<Config> {
|
||||||
|
let path = config_path();
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("Failed to read config: {}", path.display()))?;
|
||||||
|
let config: Config = toml::from_str(&content)
|
||||||
|
.with_context(|| "Failed to parse config")?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_project(project: &ProjectConfig) -> ProjectState {
|
||||||
|
match scan_project_inner(project) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(e) => ProjectState {
|
||||||
|
name: project.name.clone(),
|
||||||
|
path: project.path.clone(),
|
||||||
|
priority: project.priority.clone(),
|
||||||
|
status: "error".into(),
|
||||||
|
branch: String::new(),
|
||||||
|
uncommitted: 0,
|
||||||
|
untracked: 0,
|
||||||
|
last_commit: String::new(),
|
||||||
|
last_message: String::new(),
|
||||||
|
staleness_days: 0,
|
||||||
|
ahead: None,
|
||||||
|
behind: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_project_inner(project: &ProjectConfig) -> Result<ProjectState> {
|
||||||
|
let repo = Repository::open(&project.path)
|
||||||
|
.with_context(|| format!("Not a git repo: {}", project.path))?;
|
||||||
|
|
||||||
|
// Current branch
|
||||||
|
let branch = match repo.head() {
|
||||||
|
Ok(head) => head.shorthand().unwrap_or("HEAD").to_string(),
|
||||||
|
Err(_) => "HEAD".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status counts
|
||||||
|
let mut opts = StatusOptions::new();
|
||||||
|
opts.include_untracked(true);
|
||||||
|
let statuses = repo.statuses(Some(&mut opts))?;
|
||||||
|
|
||||||
|
let mut uncommitted = 0;
|
||||||
|
let mut untracked = 0;
|
||||||
|
for entry in statuses.iter() {
|
||||||
|
let s = entry.status();
|
||||||
|
if s.contains(git2::Status::WT_NEW) {
|
||||||
|
untracked += 1;
|
||||||
|
} else if s.intersects(
|
||||||
|
git2::Status::INDEX_NEW
|
||||||
|
| git2::Status::INDEX_MODIFIED
|
||||||
|
| git2::Status::INDEX_DELETED
|
||||||
|
| git2::Status::INDEX_RENAMED
|
||||||
|
| git2::Status::WT_MODIFIED
|
||||||
|
| git2::Status::WT_DELETED
|
||||||
|
| git2::Status::WT_RENAMED,
|
||||||
|
) {
|
||||||
|
uncommitted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last commit
|
||||||
|
let head_commit = repo.head()?.peel_to_commit()?;
|
||||||
|
let commit_time = head_commit.time();
|
||||||
|
let timestamp = commit_time.seconds();
|
||||||
|
let commit_dt = DateTime::from_timestamp(timestamp, 0)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.naive_utc();
|
||||||
|
let last_commit = commit_dt.format("%Y-%m-%d").to_string();
|
||||||
|
let last_message = head_commit
|
||||||
|
.summary()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Staleness
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let staleness_days = (now - commit_dt).num_days();
|
||||||
|
|
||||||
|
// Ahead/behind remote
|
||||||
|
let (ahead, behind) = get_ahead_behind(&repo, &branch);
|
||||||
|
|
||||||
|
// Status logic
|
||||||
|
let status = if uncommitted > 0 || staleness_days <= 3 {
|
||||||
|
"active".into()
|
||||||
|
} else if staleness_days > 7 {
|
||||||
|
"stale".into()
|
||||||
|
} else {
|
||||||
|
"clean".into()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ProjectState {
|
||||||
|
name: project.name.clone(),
|
||||||
|
path: project.path.clone(),
|
||||||
|
priority: project.priority.clone(),
|
||||||
|
status,
|
||||||
|
branch,
|
||||||
|
uncommitted,
|
||||||
|
untracked,
|
||||||
|
last_commit,
|
||||||
|
last_message,
|
||||||
|
staleness_days,
|
||||||
|
ahead: ahead.map(|v| v),
|
||||||
|
behind: behind.map(|v| v),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_ahead_behind(
|
||||||
|
repo: &Repository,
|
||||||
|
branch: &str,
|
||||||
|
) -> (Option<usize>, Option<usize>) {
|
||||||
|
let local = match repo.revparse_single(&format!("refs/heads/{}", branch)) {
|
||||||
|
Ok(obj) => obj.id(),
|
||||||
|
Err(_) => return (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let upstream_ref = format!("refs/remotes/origin/{}", branch);
|
||||||
|
let remote = match repo.revparse_single(&upstream_ref) {
|
||||||
|
Ok(obj) => obj.id(),
|
||||||
|
Err(_) => return (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
match repo.graph_ahead_behind(local, remote) {
|
||||||
|
Ok((ahead, behind)) => (Some(ahead), Some(behind)),
|
||||||
|
Err(_) => (None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_pretty(states: &[ProjectState]) {
|
||||||
|
println!("{}", "─".repeat(60).dimmed());
|
||||||
|
println!("{}", " PROJECT SCAN RESULTS".bold());
|
||||||
|
println!("{}", "─".repeat(60).dimmed());
|
||||||
|
|
||||||
|
for state in states {
|
||||||
|
let status_colored = match state.status.as_str() {
|
||||||
|
"active" => state.status.green().bold(),
|
||||||
|
"stale" => state.status.red().bold(),
|
||||||
|
"clean" => state.status.cyan().bold(),
|
||||||
|
"error" => state.status.yellow().bold(),
|
||||||
|
_ => state.status.normal(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let priority_colored = match state.priority.as_str() {
|
||||||
|
"high" => state.priority.red(),
|
||||||
|
"medium" => state.priority.yellow(),
|
||||||
|
"low" => state.priority.dimmed(),
|
||||||
|
_ => state.priority.normal(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} [{}] ({})",
|
||||||
|
state.name.bold(),
|
||||||
|
status_colored,
|
||||||
|
priority_colored
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(ref err) = state.error {
|
||||||
|
println!(" {} {}", "error:".red(), err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
"branch:".dimmed(),
|
||||||
|
state.branch
|
||||||
|
);
|
||||||
|
|
||||||
|
if state.uncommitted > 0 || state.untracked > 0 {
|
||||||
|
let changes = format!(
|
||||||
|
"{} uncommitted, {} untracked",
|
||||||
|
state.uncommitted, state.untracked
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
"changes:".dimmed(),
|
||||||
|
changes.yellow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {} ({} days ago)",
|
||||||
|
"commit:".dimmed(),
|
||||||
|
state.last_commit,
|
||||||
|
state.staleness_days
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
"message:".dimmed(),
|
||||||
|
state.last_message.dimmed()
|
||||||
|
);
|
||||||
|
|
||||||
|
if let (Some(a), Some(b)) = (state.ahead, state.behind) {
|
||||||
|
if a > 0 || b > 0 {
|
||||||
|
println!(
|
||||||
|
" {} ↑{} ↓{}",
|
||||||
|
"remote:".dimmed(),
|
||||||
|
a,
|
||||||
|
b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", "─".repeat(60).dimmed());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_state_files(states: &[ProjectState]) -> Result<()> {
|
||||||
|
let dir = agents_projects_dir();
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
for state in states {
|
||||||
|
if state.error.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
"status: {}\n\
|
||||||
|
branch: {}\n\
|
||||||
|
priority: {}\n\
|
||||||
|
uncommitted: {}\n\
|
||||||
|
last-commit: {}\n\
|
||||||
|
last-message: {}\n\
|
||||||
|
staleness: {} days\n",
|
||||||
|
state.status,
|
||||||
|
state.branch,
|
||||||
|
state.priority,
|
||||||
|
state.uncommitted,
|
||||||
|
state.last_commit,
|
||||||
|
state.last_message,
|
||||||
|
state.staleness_days,
|
||||||
|
);
|
||||||
|
|
||||||
|
let path = dir.join(format!("{}.md", state.name));
|
||||||
|
fs::write(&path, &content)
|
||||||
|
.with_context(|| {
|
||||||
|
format!("Failed to write state: {}", path.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
"wrote:".green(),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config = load_config()?;
|
||||||
|
|
||||||
|
let states: Vec<ProjectState> = config
|
||||||
|
.project
|
||||||
|
.iter()
|
||||||
|
.map(|p| scan_project(p))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if cli.json {
|
||||||
|
let json = serde_json::to_string_pretty(&states)?;
|
||||||
|
println!("{}", json);
|
||||||
|
} else {
|
||||||
|
print_pretty(&states);
|
||||||
|
|
||||||
|
if cli.update {
|
||||||
|
println!("{}", " Writing state files...".dimmed());
|
||||||
|
write_state_files(&states)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user