use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CheckpointId(pub String);
#[derive(Debug, Clone, Serialize)]
pub struct Checkpoint {
pub id: String,
pub label: String,
pub created: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RestoreReport {
pub restored_to: String,
pub files_changed: usize,
}
pub(crate) const READ_ONLY: &[&str] = &[
"ls", "cat", "head", "tail", "grep", "egrep", "fgrep", "pwd", "echo", "printf", "which",
"whoami", "id", "hostname", "uname", "date", "stat", "file", "wc", "cut", "ps", "df", "du",
"free", "uptime",
];
pub(crate) fn is_read_only(command: &str) -> bool {
if command.contains('>')
|| command.contains("$(")
|| command.contains('`')
|| command.contains("<(")
{
return false;
}
#[allow(clippy::manual_pattern_char_comparison)]
for seg in command.split(|c| matches!(c, '|' | ';' | '&' | '\n' | '\r')) {
let seg = seg.trim();
if seg.is_empty() {
continue;
}
let prog = seg.split_whitespace().next().unwrap_or("");
let base = prog.rsplit('/').next().unwrap_or(prog);
if !READ_ONLY.contains(&base) {
return false;
}
}
true
}
const BUILD_IGNORES: &[&str] = &[
".git",
"node_modules",
"target",
".venv",
"__pycache__",
".mypy_cache",
"dist",
"build",
];
const SECRET_IGNORES: &[&str] = &[".cache", ".ssh", ".gnupg", ".aws", ".netrc", ".execkit"];
pub(crate) fn shq(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub(crate) struct Checkpointer {
token: String,
pub auto: bool,
pub workspace: Option<String>, paths: Vec<String>, ignores: Vec<String>, pub root: Option<String>, pub initialized: bool,
pub git_unavailable: bool,
pub last: Option<String>, }
impl Checkpointer {
pub fn new(token: &str, auto: bool, workspace: Option<String>, paths: Vec<String>) -> Self {
debug_assert!(
token.bytes().all(|b| b.is_ascii_hexdigit()),
"checkpoint token must be hex"
);
let paths = if paths.is_empty() {
vec![".".into()]
} else {
paths
};
Self {
token: token.to_string(),
auto,
workspace,
paths,
ignores: Vec::new(),
root: None,
initialized: false,
git_unavailable: false,
last: None,
}
}
fn git_dir(&self) -> String {
format!("\"$HOME/.execkit/ckpt-{}.git\"", self.token)
}
fn pathspec(&self) -> String {
self.paths
.iter()
.map(|p| shq(p))
.collect::<Vec<_>>()
.join(" ")
}
fn git(&self, root: &str) -> String {
format!(
"git -C {root} --git-dir={gd} --work-tree={root}",
root = shq(root),
gd = self.git_dir()
)
}
pub fn init_cmd(&self, root: &str) -> String {
let mut all: Vec<&str> = BUILD_IGNORES.to_vec();
all.extend(self.ignores.iter().map(String::as_str));
all.extend_from_slice(SECRET_IGNORES);
let excludes = all.join("\n");
let g = self.git(root);
format!(
"mkdir -p \"$HOME/.execkit\" && {g} init -q && \
printf '%s\\n' {ex} > {gd}/info/exclude",
g = g,
ex = shq(&excludes),
gd = self.git_dir(),
)
}
pub fn snapshot_cmd(&self, root: &str, label: &str) -> String {
let g = self.git(root);
format!(
"{g} add -- {paths}; \
{g} -c user.email=execkit@local -c user.name=execkit \
commit -q --allow-empty -m {label} && {g} rev-parse HEAD",
paths = self.pathspec(),
label = shq(label),
)
}
pub fn restore_cmd(&self, root: &str, id: &str) -> String {
let g = self.git(root);
format!(
"{g} checkout {id} -- {paths} && {g} clean -fdq -- {paths}",
id = shq(id),
paths = self.pathspec(),
)
}
pub fn list_cmd(&self, root: &str) -> String {
format!("{} log --format='%H %ct %s'", self.git(root))
}
pub fn set_ignores(&mut self, ignores: Vec<String>) {
self.ignores = ignores;
}
pub fn set_paths(&mut self, paths: Vec<String>) {
self.paths = paths;
}
pub fn diff_count_cmd(&self, root: &str, id: &str) -> String {
format!(
"{} diff --name-only {} -- {} | wc -l",
self.git(root),
shq(id),
self.pathspec()
)
}
}
pub(crate) fn parse_sha(out: &str) -> Option<String> {
out.split_whitespace().next().map(|s| s.to_string())
}
pub(crate) fn parse_log(out: &str) -> Vec<Checkpoint> {
out.lines()
.filter_map(|line| {
let mut it = line.splitn(3, ' ');
let id = it.next()?.trim();
if id.len() != 40 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let created = it.next().unwrap_or("").to_string();
let label = it.next().unwrap_or("").to_string();
Some(Checkpoint {
id: id.to_string(),
label,
created,
})
})
.collect()
}
#[cfg(test)]
mod builder_tests {
use super::{shq, Checkpointer};
fn cp() -> Checkpointer {
Checkpointer::new("abc123", true, None, vec![".".into()])
}
#[test]
fn shell_quote_is_safe() {
assert_eq!(shq("/srv/app"), "'/srv/app'");
assert_eq!(shq("a b"), "'a b'");
assert_eq!(shq("it's"), "'it'\\''s'");
}
#[test]
fn snapshot_and_restore_commands_are_scoped() {
let c = cp();
let root = "/srv/app";
let snap = c.snapshot_cmd(root, "before");
assert!(snap.contains("--git-dir=\"$HOME/.execkit/ckpt-abc123.git\""));
assert!(snap.contains("--work-tree='/srv/app'"));
assert!(snap.contains("add -- '.'"));
assert!(snap.contains("commit -q --allow-empty"));
let restore = c.restore_cmd(root, "deadbeef");
assert!(restore.contains("checkout 'deadbeef' -- '.'"));
assert!(restore.contains("clean -fdq -- '.'"));
}
#[test]
fn multi_path_scopes_each_path() {
let c = Checkpointer::new(
"deadbeef",
true,
Some("/srv/app".into()),
vec!["src".into(), "migrations".into()],
);
let snap = c.snapshot_cmd("/srv/app", "x");
assert!(snap.contains("add -- 'src' 'migrations'"));
}
#[test]
fn parse_commit_sha_takes_first_token() {
assert_eq!(super::parse_sha("a1b2c3d4\n"), Some("a1b2c3d4".to_string()));
assert_eq!(super::parse_sha(" \n"), None);
}
#[test]
fn parse_log_reads_records() {
let a = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let b = "cafebabecafebabecafebabecafebabecafebabe";
let out = format!("{a} 1700000000 before refactor\n{b} 1699999999 init\n");
let list = super::parse_log(&out);
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, a);
assert_eq!(list[0].created, "1700000000");
assert_eq!(list[0].label, "before refactor");
assert_eq!(list[1].id, b);
let noisy = format!("{a} 1700000000 x\n~~~ junk ~~~\n");
assert_eq!(super::parse_log(&noisy).len(), 1);
}
#[test]
fn init_cmd_excludes_defaults_plus_user_ignores() {
let mut cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
cp.set_ignores(vec!["*.log".into(), "secrets".into()]);
let cmd = cp.init_cmd("/srv/app");
assert!(cmd.contains("node_modules"));
assert!(cmd.contains(".ssh"));
assert!(cmd.contains("*.log"));
assert!(cmd.contains("secrets"));
assert!(cmd.contains("info/exclude"));
assert!(
cmd.contains(".execkit"),
".execkit must appear in the excludes to prevent shadow git-dir self-capture"
);
}
#[test]
fn execkit_dir_excluded_as_non_negotiable_secret_ignore() {
let mut cp = Checkpointer::new("abc123", true, Some("/home/user".into()), vec![".".into()]);
cp.set_ignores(vec!["!.execkit".into()]);
let cmd = cp.init_cmd("/home/user");
assert!(cmd.contains("!.execkit"), "user negation must appear");
assert!(cmd.contains(".execkit"), "secret exclude must appear");
let last_pos = cmd
.rfind(".execkit")
.expect(".execkit not found in command");
let byte_before = if last_pos > 0 {
cmd.as_bytes().get(last_pos - 1).copied()
} else {
None
};
assert_ne!(
byte_before,
Some(b'!'),
"last occurrence of .execkit must not be a negation; SECRET_IGNORES must trail user patterns"
);
}
#[test]
fn init_cmd_secret_excludes_appear_after_user_negation() {
let mut cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
cp.set_ignores(vec!["!.ssh".into()]);
let cmd = cp.init_cmd("/srv/app");
assert!(
cmd.contains("!.ssh"),
"user negation must appear in command"
);
assert!(
cmd.contains(".ssh"),
"secret exclude must appear in command"
);
let last_ssh_pos = cmd.rfind(".ssh").expect("`.ssh` not found in command");
let byte_before = if last_ssh_pos > 0 {
cmd.as_bytes().get(last_ssh_pos - 1).copied()
} else {
None
};
assert_ne!(
byte_before,
Some(b'!'),
"last occurrence of `.ssh` must not be a negation (`!.ssh`); \
SECRET_IGNORES must trail user patterns"
);
}
}
#[cfg(test)]
mod read_only_tests {
use super::is_read_only;
#[test]
fn classifies_commands() {
assert!(is_read_only("ls -la"));
assert!(is_read_only("cat f | grep x"));
assert!(is_read_only("ls; cat f"));
assert!(is_read_only("/bin/ls /tmp"));
assert!(is_read_only("ps aux | grep nginx"));
assert!(!is_read_only("rm -rf build"));
assert!(!is_read_only("echo hi > f")); assert!(!is_read_only("sed -i s/a/b/ f")); assert!(!is_read_only("ls && rm x")); assert!(!is_read_only("cat $(whoami)")); assert!(!is_read_only("tee f")); assert!(!is_read_only("npm install")); assert!(!is_read_only("find . -delete")); assert!(!is_read_only("env X=1 rm -rf y")); assert!(!is_read_only("uptime\nrm -rf /tmp/x")); assert!(!is_read_only("cat <(rm x)")); assert!(!is_read_only("uniq in out")); }
}