- global --dry-run: sync/release/init/collab/license print 'DRY RUN: would...' and exit 0 (no auth needed for previews where possible) - config protected_repos (name/basename, case-insensitive): mutating commands exit 1 on listed repos unless --force; checked before dry-run - VULP_AGENT env: activity::log centrally appends '· via agent' to detail, covering both CLI and TUI writers - sync: unknown --action now rejected before touching any repo
104 lines
3.9 KiB
Rust
104 lines
3.9 KiB
Rust
//! Local activity log (`~/.vulp/activity.log`, one JSON line per event) for
|
|
//! actions vulp itself performed — feeds the hub's Activity tab alongside
|
|
//! (but separately from) the git-log-sourced commit feed, which sees real
|
|
//! commit history regardless of which tool made it.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
/// The kind of action recorded in one activity log entry.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ActivityKind { Commit, Push, Pull, Release, NewRepo, Todo, Pr, Issue, License }
|
|
|
|
impl ActivityKind {
|
|
/// Display label shown in the Activity tab's table.
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
Self::Commit => "commit",
|
|
Self::Push => "push",
|
|
Self::Pull => "pull",
|
|
Self::Release => "release",
|
|
Self::NewRepo => "new repo",
|
|
Self::Todo => "todo",
|
|
Self::Pr => "pr",
|
|
Self::Issue => "issue",
|
|
Self::License => "license",
|
|
}
|
|
}
|
|
|
|
/// Cycle order for the Activity tab's kind filter (`All` is handled
|
|
/// separately as `None`).
|
|
pub fn cycle() -> &'static [ActivityKind] {
|
|
&[
|
|
Self::Commit, Self::Push, Self::Pull, Self::Release,
|
|
Self::NewRepo, Self::Todo, Self::Pr, Self::Issue, Self::License,
|
|
]
|
|
}
|
|
}
|
|
|
|
/// One recorded activity-log event.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ActivityEntry {
|
|
pub ts: i64,
|
|
pub kind: ActivityKind,
|
|
pub repo: String,
|
|
pub detail: String,
|
|
}
|
|
|
|
fn log_path() -> PathBuf {
|
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")).join(".vulp").join("activity.log")
|
|
}
|
|
|
|
/// Appends one event to the local activity log. Only called from code paths
|
|
/// where vulp itself performs the action (push/pull/release/repo-create/
|
|
/// todo-scan) — it has no way to see git activity done outside vulp, which
|
|
/// the Activity tab's commit feed (sourced straight from git log) covers
|
|
/// separately and accurately regardless of how it happened.
|
|
pub fn log(entry: &ActivityEntry) {
|
|
// Executor marker: AI agents set VULP_AGENT so their actions are
|
|
// distinguishable from the human's in the shared log.
|
|
let mut entry = entry.clone();
|
|
if std::env::var("VULP_AGENT").map(|v| !v.is_empty()).unwrap_or(false) {
|
|
entry.detail = if entry.detail.is_empty() {
|
|
"via agent".into()
|
|
} else {
|
|
format!("{} · via agent", entry.detail)
|
|
};
|
|
}
|
|
let Ok(line) = serde_json::to_string(&entry) else { return };
|
|
let path = log_path();
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
|
|
use std::io::Write;
|
|
let _ = writeln!(f, "{line}");
|
|
}
|
|
}
|
|
|
|
/// Loads every logged entry, skipping any line that fails to parse (a
|
|
/// truncated last line from a crash, for instance) rather than discarding
|
|
/// the whole log.
|
|
pub fn load_logged() -> Vec<ActivityEntry> {
|
|
let Ok(content) = std::fs::read_to_string(log_path()) else { return vec![] };
|
|
content.lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
|
|
}
|
|
|
|
/// Keeps the append-only log from growing forever: when it exceeds
|
|
/// `max_entries`, rewrites the file with only the newest `max_entries`.
|
|
/// Called once at hub startup — cheap no-op below the threshold.
|
|
pub fn compact(max_entries: usize) {
|
|
let entries = load_logged();
|
|
if entries.len() <= max_entries { return; }
|
|
let keep = &entries[entries.len() - max_entries..];
|
|
let body: String = keep.iter()
|
|
.filter_map(|e| serde_json::to_string(e).ok())
|
|
.map(|l| l + "\n")
|
|
.collect();
|
|
let path = log_path();
|
|
let tmp = path.with_extension("log.tmp");
|
|
if std::fs::write(&tmp, body).is_ok() {
|
|
let _ = std::fs::rename(&tmp, &path);
|
|
}
|
|
}
|