Skip to main content

lean_ctx/core/git/
shadow.rs

1//! Shadow git history of agent changes.
2//!
3//! Records snapshots of the project's working tree into a git repository kept
4//! entirely **outside** the user's own `.git` — at
5//! `<data>/shadow/<project-hash>/git`, driven via `GIT_DIR` + `GIT_WORK_TREE`.
6//! This lets an agent (or the human) snapshot, review, diff, and revert the
7//! changes the LLM made, independently of the user's commits. The user's
8//! repository is never read or mutated.
9
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use super::{GitOutput, run_git};
14
15const FIELD: char = '\u{1f}'; // ASCII unit separator for safe log parsing
16
17/// One recorded snapshot in the shadow history.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Checkpoint {
20    pub sha: String,
21    /// Committer date, ISO-8601.
22    pub time: String,
23    pub message: String,
24    /// Files changed by this checkpoint (only known right after `snapshot`).
25    pub files_changed: Option<usize>,
26}
27
28/// Create (idempotently) the shadow repo for `project`.
29pub(crate) fn init(project: &Path) -> Result<(), String> {
30    let git_dir = shadow_git_dir(project)?;
31    if git_dir.join("HEAD").exists() {
32        return Ok(());
33    }
34    std::fs::create_dir_all(&git_dir).map_err(|e| format!("cannot create shadow dir: {e}"))?;
35
36    let env = base_env(&git_dir, project);
37    git(&["init", "-q"], project, Duration::from_secs(15), &env)?.ok_stdout()?;
38
39    // Keep snapshots lean and never sign/hook: build artifacts are excluded even
40    // when the project ships no .gitignore.
41    let excludes = write_excludes(&git_dir)?;
42    let exc = excludes.to_string_lossy();
43    for (k, v) in [
44        ("core.excludesFile", exc.as_ref()),
45        ("commit.gpgsign", "false"),
46        ("gc.auto", "0"),
47        // Snapshots must be byte-faithful: never translate line endings, or a
48        // restore on Windows (global core.autocrlf=true) hands back CRLF for an
49        // LF source and the content no longer round-trips.
50        ("core.autocrlf", "false"),
51        ("core.safecrlf", "false"),
52    ] {
53        git(&["config", k, v], project, Duration::from_secs(10), &env)?.ok_stdout()?;
54    }
55    Ok(())
56}
57
58/// Snapshot the current working tree. Returns the new checkpoint, or — when the
59/// tree is unchanged since the last snapshot — the existing HEAD checkpoint.
60pub fn snapshot(project: &Path, message: &str) -> Result<Checkpoint, String> {
61    init(project)?;
62    let git_dir = shadow_git_dir(project)?;
63    let env = base_env(&git_dir, project);
64
65    git(&["add", "-A"], project, Duration::from_mins(2), &env)?.ok_stdout()?;
66
67    let msg = if message.trim().is_empty() {
68        "lean-ctx checkpoint"
69    } else {
70        message
71    };
72    let commit = git(
73        &["commit", "--no-verify", "-q", "-m", msg],
74        project,
75        Duration::from_mins(1),
76        &env,
77    )?;
78
79    if commit.success {
80        let sha = head_sha(project, &env)?;
81        let files = count_changed(project, &env, &sha);
82        return Ok(Checkpoint {
83            sha,
84            time: now_iso(project, &env),
85            message: msg.to_string(),
86            files_changed: Some(files),
87        });
88    }
89
90    // No changes to commit: return the current HEAD as a no-op checkpoint.
91    if let Ok(sha) = head_sha(project, &env) {
92        return Ok(Checkpoint {
93            sha,
94            time: now_iso(project, &env),
95            message: "(no changes since last checkpoint)".to_string(),
96            files_changed: Some(0),
97        });
98    }
99    commit.ok_stdout()?;
100    Err("git commit failed".to_string())
101}
102
103/// Most recent checkpoints, newest first.
104pub fn log(project: &Path, limit: usize) -> Result<Vec<Checkpoint>, String> {
105    let git_dir = shadow_git_dir(project)?;
106    if !git_dir.join("HEAD").exists() {
107        return Ok(Vec::new());
108    }
109    let env = base_env(&git_dir, project);
110    let fmt = format!("--pretty=format:%H{FIELD}%cI{FIELD}%s");
111    let out = git(
112        &["log", &fmt, &format!("--max-count={}", limit.max(1))],
113        project,
114        Duration::from_secs(15),
115        &env,
116    )?;
117    if !out.success {
118        return Ok(Vec::new()); // no commits yet
119    }
120    Ok(out
121        .stdout
122        .lines()
123        .filter_map(|line| {
124            let mut parts = line.splitn(3, FIELD);
125            Some(Checkpoint {
126                sha: parts.next()?.to_string(),
127                time: parts.next().unwrap_or("").to_string(),
128                message: parts.next().unwrap_or("").to_string(),
129                files_changed: None,
130            })
131        })
132        .collect())
133}
134
135/// Unified diff. Defaults to "working tree vs last checkpoint" when `from`/`to`
136/// are omitted.
137pub fn diff(project: &Path, from: Option<&str>, to: Option<&str>) -> Result<String, String> {
138    let git_dir = shadow_git_dir(project)?;
139    if !git_dir.join("HEAD").exists() {
140        return Err("no checkpoints yet — run snapshot first".to_string());
141    }
142    let env = base_env(&git_dir, project);
143    let mut args: Vec<String> = vec!["diff".to_string()];
144    match (from, to) {
145        (Some(f), Some(t)) => {
146            args.push(f.to_string());
147            args.push(t.to_string());
148        }
149        (Some(f), None) => args.push(f.to_string()),
150        (None, _) => args.push("HEAD".to_string()),
151    }
152    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
153    git(&arg_refs, project, Duration::from_secs(30), &env)?.ok_stdout()
154}
155
156/// Restore tracked files from a checkpoint into the working tree. With `path`
157/// scoped to a file/dir; otherwise the whole tree (tracked files only).
158pub fn restore(project: &Path, git_ref: &str, path: Option<&str>) -> Result<String, String> {
159    let git_dir = shadow_git_dir(project)?;
160    if !git_dir.join("HEAD").exists() {
161        return Err("no checkpoints yet — nothing to restore".to_string());
162    }
163    let env = base_env(&git_dir, project);
164    let target = path.unwrap_or(".");
165    git(
166        &["checkout", git_ref, "--", target],
167        project,
168        Duration::from_mins(1),
169        &env,
170    )?
171    .ok_stdout()?;
172    Ok(format!("restored {target} from {git_ref}"))
173}
174
175// ── internals ──────────────────────────────────────────────────────────────
176
177pub fn git(
178    args: &[&str],
179    cwd: &Path,
180    timeout: Duration,
181    env: &[(String, String)],
182) -> Result<GitOutput, String> {
183    let refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
184    run_git(args, cwd, timeout, &refs)
185}
186
187fn base_env(git_dir: &Path, work_tree: &Path) -> Vec<(String, String)> {
188    vec![
189        ("GIT_DIR".into(), git_dir.to_string_lossy().into_owned()),
190        (
191            "GIT_WORK_TREE".into(),
192            work_tree.to_string_lossy().into_owned(),
193        ),
194        ("GIT_AUTHOR_NAME".into(), "lean-ctx".into()),
195        ("GIT_AUTHOR_EMAIL".into(), "agent@lean-ctx.local".into()),
196        ("GIT_COMMITTER_NAME".into(), "lean-ctx".into()),
197        ("GIT_COMMITTER_EMAIL".into(), "agent@lean-ctx.local".into()),
198    ]
199}
200
201fn head_sha(project: &Path, env: &[(String, String)]) -> Result<String, String> {
202    let out = git(
203        &["rev-parse", "--short", "HEAD"],
204        project,
205        Duration::from_secs(10),
206        env,
207    )?
208    .ok_stdout()?;
209    Ok(out.trim().to_string())
210}
211
212fn now_iso(project: &Path, env: &[(String, String)]) -> String {
213    git(
214        &["show", "-s", "--format=%cI", "HEAD"],
215        project,
216        Duration::from_secs(10),
217        env,
218    )
219    .ok()
220    .and_then(|o| o.ok_stdout().ok())
221    .map(|s| s.trim().to_string())
222    .unwrap_or_default()
223}
224
225fn count_changed(project: &Path, env: &[(String, String)], sha: &str) -> usize {
226    // Files in this commit vs its parent; for the root commit, all files.
227    let range = format!("{sha}^..{sha}");
228    let out = git(
229        &["diff", "--name-only", &range],
230        project,
231        Duration::from_secs(15),
232        env,
233    );
234    match out {
235        Ok(o) if o.success => o.stdout.lines().filter(|l| !l.trim().is_empty()).count(),
236        _ => {
237            // Root commit (no parent): count tracked files.
238            git(
239                &["show", "--name-only", "--pretty=format:", sha],
240                project,
241                Duration::from_secs(15),
242                env,
243            )
244            .ok()
245            .and_then(|o| o.ok_stdout().ok())
246            .map_or(0, |s| s.lines().filter(|l| !l.trim().is_empty()).count())
247        }
248    }
249}
250
251fn shadow_git_dir(project: &Path) -> Result<PathBuf, String> {
252    let hash = project_hash(project);
253    Ok(crate::core::data_dir::lean_ctx_data_dir()?
254        .join("shadow")
255        .join(hash)
256        .join("git"))
257}
258
259/// Stable per-project directory name (FNV-1a over the canonical path).
260fn project_hash(project: &Path) -> String {
261    let canonical = std::fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
262    let bytes = canonical.to_string_lossy();
263    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
264    for b in bytes.as_bytes() {
265        hash ^= u64::from(*b);
266        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
267    }
268    format!("{hash:016x}")
269}
270
271fn write_excludes(git_dir: &Path) -> Result<PathBuf, String> {
272    let path = git_dir
273        .parent()
274        .unwrap_or(git_dir)
275        .join("lean-ctx-excludes");
276    let defaults = "\
277# lean-ctx shadow-history excludes (keeps snapshots lean even without a project .gitignore)
278.git/
279target/
280node_modules/
281dist/
282build/
283.venv/
284__pycache__/
285*.lock
286";
287    std::fs::write(&path, defaults).map_err(|e| format!("cannot write excludes: {e}"))?;
288    Ok(path)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn git_ready() -> bool {
296        super::super::git_available()
297    }
298
299    fn temp_project(tag: &str) -> PathBuf {
300        let dir = std::env::temp_dir().join(format!("lc_shadow_{tag}_{}", std::process::id()));
301        let _ = std::fs::remove_dir_all(&dir);
302        std::fs::create_dir_all(&dir).unwrap();
303        dir
304    }
305
306    #[test]
307    fn project_hash_is_stable_and_hex() {
308        let h1 = project_hash(Path::new("/some/project"));
309        let h2 = project_hash(Path::new("/some/project"));
310        assert_eq!(h1, h2);
311        assert_eq!(h1.len(), 16);
312        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
313    }
314
315    #[test]
316    fn snapshot_log_diff_restore_roundtrip() {
317        if !git_ready() {
318            return;
319        }
320        let _lock = crate::core::data_dir::test_env_lock();
321        let data = temp_project("data");
322        crate::test_env::set_var("LEAN_CTX_DATA_DIR", &data);
323        let project = temp_project("proj");
324
325        std::fs::write(project.join("a.txt"), "v1\n").unwrap();
326        let c1 = snapshot(&project, "first").expect("snapshot 1");
327        assert_eq!(c1.files_changed, Some(1));
328
329        // No-change snapshot returns a no-op checkpoint.
330        let c1b = snapshot(&project, "again").expect("snapshot noop");
331        assert_eq!(c1b.files_changed, Some(0));
332
333        std::fs::write(project.join("a.txt"), "v2\n").unwrap();
334        let d = diff(&project, None, None).expect("diff vs HEAD");
335        assert!(d.contains("-v1") && d.contains("+v2"), "diff was: {d}");
336
337        let c2 = snapshot(&project, "second").expect("snapshot 2");
338        assert_ne!(c1.sha, c2.sha);
339
340        let entries = log(&project, 10).expect("log");
341        assert!(entries.len() >= 2, "expected >=2 checkpoints");
342
343        // Restore the first checkpoint and confirm the content reverts.
344        restore(&project, &c1.sha, Some("a.txt")).expect("restore");
345        let restored = std::fs::read_to_string(project.join("a.txt")).unwrap();
346        assert_eq!(restored, "v1\n");
347
348        // The user's project must NOT have gained a .git directory.
349        assert!(
350            !project.join(".git").exists(),
351            "shadow history must not touch the user's project .git"
352        );
353
354        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
355        let _ = std::fs::remove_dir_all(&data);
356        let _ = std::fs::remove_dir_all(&project);
357    }
358}