diff --git a/README.md b/README.md index 055082dc..59a90710 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives Dee - **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved). Decomposition-first system prompts teach the model to `todo_write`, `update_plan`, and spawn sub-agents before acting - **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab - **Session save/resume** — checkpoint and resume long sessions +- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` - **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows - **MCP protocol** — connect to Model Context Protocol servers for extended tooling - **Live cost tracking** — per-turn and session-level token usage and cost estimates diff --git a/config.example.toml b/config.example.toml index 0d2e719e..8eb4c692 100644 --- a/config.example.toml +++ b/config.example.toml @@ -232,6 +232,27 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # threshold_secs = 30 # include_summary = false +# ───────────────────────────────────────────────────────────────────────────────── +# Workspace Snapshots (#137) +# ───────────────────────────────────────────────────────────────────────────────── +# Each turn the TUI takes a `pre-turn:` and `post-turn:` snapshot of +# your workspace into a side-git repo at: +# +# ~/.deepseek/snapshots///.git +# +# Your own `.git` is never touched — `--git-dir` and `--work-tree` are always +# set together when shelling out to git. Use `/restore N` (slash command) or +# the `revert_turn` tool to roll the working tree back. Conversation history +# is unaffected. +# +# Disk footprint: ~1-2 GB worst case for a 100 MB workspace × 12 turns/day, +# typically far less thanks to git's content-addressed storage. The session +# boot prunes anything older than `max_age_days` (default 7). +# +# [snapshots] +# enabled = true # Snapshot workspace pre/post each turn for /restore +# max_age_days = 7 # Older snapshots pruned at session start + # ───────────────────────────────────────────────────────────────────────────────── # Hooks (optional) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 96a5fcee..c1e3f515 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -133,6 +133,10 @@ pub struct ConfigToml { /// `registry_url` when running `deepseek skill install`. #[serde(default)] pub skills: Option, + /// Workspace side-git snapshots (#137). The live TUI defaults this to + /// enabled with 7-day retention when absent. + #[serde(default)] + pub snapshots: Option, #[serde(flatten)] pub extras: BTreeMap, } @@ -151,6 +155,33 @@ pub struct SkillsToml { pub max_install_size_bytes: Option, } +/// On-disk schema for the `[snapshots]` table (#137). See +/// `config.example.toml` for documentation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotsToml { + #[serde(default = "default_snapshots_enabled")] + pub enabled: bool, + #[serde(default = "default_snapshot_max_age_days")] + pub max_age_days: u64, +} + +fn default_snapshots_enabled() -> bool { + true +} + +fn default_snapshot_max_age_days() -> u64 { + 7 +} + +impl Default for SnapshotsToml { + fn default() -> Self { + Self { + enabled: default_snapshots_enabled(), + max_age_days: default_snapshot_max_age_days(), + } + } +} + /// On-disk schema for the `[network]` table (#135). See `config.example.toml` /// for documentation. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index a9a00ae3..c834a3da 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -12,6 +12,7 @@ mod init; mod note; mod provider; mod queue; +mod restore; mod review; mod session; mod skills; @@ -337,6 +338,12 @@ pub const COMMANDS: &[CommandInfo] = &[ description: "Run a structured code review on a file, diff, or PR", usage: "/review ", }, + CommandInfo { + name: "restore", + aliases: &[], + description: "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.", + usage: "/restore [N]", + }, // RLM command CommandInfo { name: "rlm", @@ -412,6 +419,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "skills" => skills::list_skills(app, arg), "skill" => skills::run_skill(app, arg), "review" => review::review(app, arg), + "restore" => restore::restore(app, arg), // RLM command "rlm" | "recursive" => rlm(app, arg), diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/restore.rs new file mode 100644 index 00000000..2395dd43 --- /dev/null +++ b/crates/tui/src/commands/restore.rs @@ -0,0 +1,255 @@ +//! `/restore` slash command — roll back the workspace to a prior snapshot. +//! +//! `/restore` (no arg) lists the most recent snapshots so the user can +//! see what's available. `/restore ` restores the *N*th-most-recent +//! snapshot, where `N=1` is the newest. In non-YOLO mode we refuse to +//! mutate files unless the user has explicitly trusted the workspace +//! (`/trust on` or YOLO) — the user can always view the list, just not +//! one-shot revert without a safety net. + +use super::CommandResult; +use crate::snapshot::SnapshotRepo; +use crate::tui::app::App; + +const LIST_LIMIT: usize = 10; + +/// Entry point for `/restore [N]`. +pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { + let workspace = app.workspace.clone(); + let repo = match SnapshotRepo::open_or_init(&workspace) { + Ok(r) => r, + Err(e) => { + return CommandResult::error(format!( + "Snapshot repo unavailable for {}: {e}", + workspace.display(), + )); + } + }; + + let snapshots = match repo.list(LIST_LIMIT) { + Ok(s) => s, + Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + }; + + if snapshots.is_empty() { + return CommandResult::message( + "No snapshots yet. Send a message to create the first pre-turn snapshot.", + ); + } + + let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else { + return CommandResult::message(format_listing(&snapshots)); + }; + + let n: usize = match arg.parse() { + Ok(n) if n >= 1 => n, + _ => { + return CommandResult::error(format!( + "Usage: /restore (N is 1-based; got '{arg}')", + )); + } + }; + + if n > snapshots.len() { + return CommandResult::error(format!( + "Only {} snapshot(s) available; asked for #{n}.", + snapshots.len(), + )); + } + + // Non-YOLO sessions get a confirmation gate. We don't have a true + // modal-confirmation path inside slash commands today, so the gate + // is "require trust mode" — `/trust on` or YOLO. Users in plain + // Agent mode get a clear message explaining how to proceed. + if !(app.yolo || app.trust_mode) { + return CommandResult::message(format!( + "Refusing to restore snapshot #{n} ('{}') outside trusted mode.\n\ + Run `/trust on` or `/yolo` first, then re-run `/restore {n}`.", + snapshots[n - 1].label, + )); + } + + let target = &snapshots[n - 1]; + if let Err(e) = repo.restore(&target.id) { + return CommandResult::error(format!("Restore failed: {e}")); + } + + CommandResult::message(format!( + "Restored snapshot #{n} ('{}', {}). Workspace files have been reverted; conversation history is unchanged.", + target.label, + short_sha(target.id.as_str()), + )) +} + +fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String { + let mut out = String::from("Recent snapshots (newest first; pass /restore to revert):\n"); + for (i, s) in snapshots.iter().enumerate() { + out.push_str(&format!( + " #{:<2} {} {}\n", + i + 1, + short_sha(s.id.as_str()), + s.label, + )); + } + out +} + +fn short_sha(sha: &str) -> &str { + &sha[..sha.len().min(8)] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::test_support::lock_test_env; + use crate::tui::app::TuiOptions; + use std::sync::MutexGuard; + use tempfile::TempDir; + + fn make_app(tmp: &TempDir, yolo: bool) -> App { + let workspace = tmp.path().to_path_buf(); + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmp.path().join("skills"), + memory_path: tmp.path().join("memory.md"), + notes_path: tmp.path().join("notes.txt"), + mcp_config_path: tmp.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo, + resume_session_id: None, + }; + App::new(options, &Config::default()) + } + + /// Pins HOME to a tempdir for the duration of the test under the + /// crate-wide env mutex. + struct ScopedHome { + prev: Option, + _guard: MutexGuard<'static, ()>, + } + impl Drop for ScopedHome { + fn drop(&mut self) { + // SAFETY: process-wide lock still held. + unsafe { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } + } + fn scoped_home(tmp: &TempDir) -> ScopedHome { + let guard = lock_test_env(); + let prev = std::env::var_os("HOME"); + // SAFETY: serialised by the global env lock. + unsafe { + std::env::set_var("HOME", tmp.path()); + } + ScopedHome { + prev, + _guard: guard, + } + } + + #[test] + fn restore_with_no_snapshots_shows_empty_message() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let result = restore(&mut app, None); + let msg = result.message.expect("expected message"); + assert!(msg.contains("No snapshots")); + } + + #[test] + fn restore_lists_when_no_arg_provided() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap(); + repo.snapshot("pre-turn:1").unwrap(); + std::fs::write(app.workspace.join("a.txt"), b"v2").unwrap(); + repo.snapshot("post-turn:1").unwrap(); + + let result = restore(&mut app, None); + let msg = result.message.expect("expected message"); + assert!(msg.contains("post-turn:1")); + assert!(msg.contains("pre-turn:1")); + assert!(msg.contains("#1")); + assert!(msg.contains("#2")); + } + + #[test] + fn restore_in_yolo_reverts_workspace() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + let f = app.workspace.join("a.txt"); + + std::fs::write(&f, b"original").unwrap(); + repo.snapshot("pre-turn:1").unwrap(); + std::fs::write(&f, b"clobbered").unwrap(); + repo.snapshot("post-turn:1").unwrap(); + + let result = restore(&mut app, Some("2")); + assert!(result.message.unwrap().contains("Restored")); + let after = std::fs::read_to_string(&f).unwrap(); + assert_eq!(after, "original"); + } + + #[test] + fn restore_outside_trust_mode_refuses() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, false); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap(); + repo.snapshot("pre-turn:1").unwrap(); + + let result = restore(&mut app, Some("1")); + let msg = result.message.expect("expected message"); + assert!(msg.contains("Refusing")); + assert!(msg.contains("/trust on")); + } + + #[test] + fn restore_invalid_index_returns_error() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap(); + repo.snapshot("pre-turn:1").unwrap(); + + let result = restore(&mut app, Some("99")); + let msg = result.message.expect("expected message"); + assert!(msg.contains("Only 1 snapshot")); + } + + #[test] + fn restore_zero_index_returns_error() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + // Need at least one snapshot so we exercise the parse-index + // branch instead of the "no snapshots" early return. + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap(); + repo.snapshot("pre-turn:1").unwrap(); + + let result = restore(&mut app, Some("0")); + let msg = result.message.expect("expected message"); + assert!(msg.contains("Usage:")); + } +} diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 72124bd0..6b1f53e2 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -192,6 +192,41 @@ pub struct NotificationsConfig { pub include_summary: bool, } +fn default_snapshots_enabled() -> bool { + true +} + +fn default_snapshot_max_age_days() -> u64 { + crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60) +} + +/// Workspace side-git snapshot configuration (#137). +#[derive(Debug, Clone, Deserialize)] +pub struct SnapshotsConfig { + /// Snapshot the workspace before and after each interactive agent turn. + #[serde(default = "default_snapshots_enabled")] + pub enabled: bool, + /// Prune side-git snapshots older than this many days at session boot. + #[serde(default = "default_snapshot_max_age_days")] + pub max_age_days: u64, +} + +impl Default for SnapshotsConfig { + fn default() -> Self { + Self { + enabled: default_snapshots_enabled(), + max_age_days: default_snapshot_max_age_days(), + } + } +} + +impl SnapshotsConfig { + #[must_use] + pub fn max_age(&self) -> std::time::Duration { + std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60)) + } +} + /// One configurable footer item. /// /// Order in the user's `Vec` is preserved: items in the left @@ -436,6 +471,11 @@ pub struct Config { /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]). #[serde(default)] pub skills: Option, + + /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day + /// retention when the table is absent. + #[serde(default)] + pub snapshots: Option, } /// `[skills]` table — knobs for the community-skill installer. @@ -912,6 +952,12 @@ impl Config { self.notifications.clone().unwrap_or_default() } + /// Resolve workspace side-git snapshot settings with defaults applied. + #[must_use] + pub fn snapshots_config(&self) -> SnapshotsConfig { + self.snapshots.clone().unwrap_or_default() + } + /// Resolve enabled features from defaults and config entries. #[must_use] pub fn features(&self) -> Features { @@ -1422,6 +1468,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { notifications: override_cfg.notifications.or(base.notifications), network: override_cfg.network.or(base.network), skills: override_cfg.skills.or(base.skills), + snapshots: override_cfg.snapshots.or(base.snapshots), } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6175a467..d010c138 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -61,7 +61,7 @@ use super::events::{Event, TurnOutcomeStatus}; use super::ops::Op; use super::session::Session; use super::tool_parser; -use super::turn::{TurnContext, TurnToolCall}; +use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot}; // === Types === @@ -110,6 +110,8 @@ pub struct EngineConfig { /// session-scoped approvals (`/network allow `) persist for the /// remainder of the run. pub network_policy: Option, + /// Whether to take side-git workspace snapshots before/after each turn. + pub snapshots_enabled: bool, } impl Default for EngineConfig { @@ -131,6 +133,7 @@ impl Default for EngineConfig { plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy: None, + snapshots_enabled: true, } } } @@ -1436,6 +1439,16 @@ impl Engine { self.turn_counter = self.turn_counter.saturating_add(1); self.capacity_controller.mark_turn_start(self.turn_counter); + // Snapshot the workspace BEFORE we touch a single tool. Run the git + // work on the blocking pool so the async runtime stays responsive; + // failure is non-fatal (the helper logs at WARN). + if self.config.snapshots_enabled { + let pre_workspace = self.session.workspace.clone(); + let pre_seq = self.turn_counter; + let _ = tokio::task::spawn_blocking(move || pre_turn_snapshot(&pre_workspace, pre_seq)) + .await; + } + // Emit turn started event let _ = self .tx_event @@ -1656,6 +1669,16 @@ impl Engine { }) .await; + // Post-turn snapshot. Same non-blocking, non-fatal contract as + // the pre-turn hook above. + if self.config.snapshots_enabled { + let post_workspace = self.session.workspace.clone(); + let post_seq = self.turn_counter; + let _ = + tokio::task::spawn_blocking(move || post_turn_snapshot(&post_workspace, post_seq)) + .await; + } + // Checkpoint-restart cycle boundary (issue #124). The turn just // settled cleanly — no in-flight tools, no streaming, no pending // approval — so this is the safe phase to swap the context if we've diff --git a/crates/tui/src/core/turn.rs b/crates/tui/src/core/turn.rs index ea187710..bc0706e2 100644 --- a/crates/tui/src/core/turn.rs +++ b/crates/tui/src/core/turn.rs @@ -2,8 +2,20 @@ //! //! A "turn" is one user message and the resulting AI response, //! including any tool calls that occur. +//! +//! ## Snapshot lifecycle hooks +//! +//! [`pre_turn_snapshot`] and [`post_turn_snapshot`] book-end a turn by +//! taking a workspace-level snapshot into a side git repo (see +//! `crate::snapshot`). They are intentionally non-blocking and +//! non-fatal: any IO error is logged at WARN and swallowed so a busted +//! filesystem or missing `git` binary never derails the agent loop. +//! `/restore N` and the `revert_turn` tool both consume these +//! snapshots. use crate::models::Usage; +use crate::snapshot::SnapshotRepo; +use std::path::Path; use std::time::{Duration, Instant}; /// Context for a single turn (user message + AI response). @@ -116,6 +128,36 @@ fn add_optional_usage(total: Option, delta: Option) -> Option { } } +/// Take a `pre-turn:` workspace snapshot. +/// +/// Returns the snapshot SHA on success, `None` on any error. Errors are +/// logged at WARN; the turn loop must not block on this. +pub fn pre_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option { + snapshot_with_label(workspace, &format!("pre-turn:{turn_seq}")) +} + +/// Take a `post-turn:` workspace snapshot. Same failure model as +/// [`pre_turn_snapshot`]. +pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option { + snapshot_with_label(workspace, &format!("post-turn:{turn_seq}")) +} + +fn snapshot_with_label(workspace: &Path, label: &str) -> Option { + match SnapshotRepo::open_or_init(workspace) { + Ok(repo) => match repo.snapshot(label) { + Ok(id) => Some(id.0), + Err(e) => { + tracing::warn!(target: "snapshot", "snapshot '{label}' failed: {e}"); + None + } + }, + Err(e) => { + tracing::warn!(target: "snapshot", "snapshot repo init failed: {e}"); + None + } + } +} + impl TurnToolCall { /// Create a new tool call record pub fn new(id: String, name: String, input: serde_json::Value) -> Self { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 88316221..e947489b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -47,6 +47,7 @@ mod sandbox; mod session_manager; mod settings; mod skills; +mod snapshot; mod task_manager; #[cfg(test)] mod test_support; @@ -2787,6 +2788,14 @@ async fn run_interactive( logging::warn(format!("Failed to install system skills: {e}")); } + // Prune stale workspace snapshots from prior sessions (7-day default). + // Non-fatal: a flaky disk, missing `git`, or read-only home should + // never block the TUI from starting. + let snapshots = config.snapshots_config(); + if snapshots.enabled { + session_manager::prune_workspace_snapshots(&workspace, snapshots.max_age()); + } + tui::run_tui( config, tui::TuiOptions { @@ -2950,6 +2959,7 @@ async fn run_exec_agent( plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy, + snapshots_enabled: config.snapshots_config().enabled, }; let engine_handle = spawn_engine(engine_config, config); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 9680c751..b6ef186a 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1509,6 +1509,7 @@ impl RuntimeThreadManager { plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy, + snapshots_enabled: self.config.snapshots_config().enabled, }; let engine = spawn_engine(engine_cfg, &self.config); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 81c8b408..f853c052 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -370,6 +370,22 @@ pub fn default_sessions_dir() -> std::io::Result { Ok(home.join(".deepseek").join("sessions")) } +/// Prune snapshots older than `max_age` for `workspace`. +/// +/// Always non-fatal. Returns silently — callers don't need the count +/// (the underlying repo logs at WARN if anything blew up). +pub fn prune_workspace_snapshots(workspace: &Path, max_age: std::time::Duration) { + match crate::snapshot::prune_older_than(workspace, max_age) { + Ok(0) => {} + Ok(n) => { + tracing::debug!(target: "snapshot", "boot prune removed {n} snapshot(s)"); + } + Err(e) => { + tracing::warn!(target: "snapshot", "boot prune failed: {e}"); + } + } +} + /// Create a new `SavedSession` from conversation state pub fn create_saved_session( messages: &[Message], diff --git a/crates/tui/src/snapshot/mod.rs b/crates/tui/src/snapshot/mod.rs new file mode 100644 index 00000000..82b0ab61 --- /dev/null +++ b/crates/tui/src/snapshot/mod.rs @@ -0,0 +1,42 @@ +//! Workspace snapshots — pre/post-turn safety net. +//! +//! Each turn the engine takes a `pre-turn:` snapshot of the user's +//! workspace into a side git repo at +//! `~/.deepseek/snapshots///.git`, then a +//! matching `post-turn:` snapshot when the turn finishes. Users +//! can roll back via `/restore N` (slash command) or, when the model +//! recognises an "undo my last edit" intent, the `revert_turn` tool. +//! +//! ## Why a side repo? +//! +//! - The user's own `.git` is never touched. `--git-dir` and +//! `--work-tree` are *always* set together when we shell out to git; +//! that single invariant is what keeps snapshots and the user's repo +//! completely independent. +//! - Workspaces without git still get snapshots. +//! - `git`'s own deduplication (object packfiles) keeps the disk +//! footprint tractable — typical 100 MB workspace × 12 turns ≈ 1.2 GB +//! uncompressed but git's content-addressed storage usually brings +//! that down 10-30×. We mitigate further with: +//! - 7-day default retention (`session_manager` prunes at session +//! start via [`prune::prune_older_than`]). +//! - `gc.auto = 0` on the side repo (we don't want background gcs +//! firing mid-turn) plus an explicit `git gc --prune=now` after +//! prune. +//! +//! ## Failure model +//! +//! Pre/post-turn snapshot calls are **non-fatal**. If `git` is missing, +//! the disk is full, or the workspace is on a read-only filesystem, the +//! turn proceeds and the engine logs a warning. The snapshot is a +//! safety net, not a correctness gate. + +pub mod paths; +pub mod prune; +pub mod repo; + +#[allow(unused_imports)] +pub use paths::{snapshot_dir_for, snapshot_git_dir}; +pub use prune::{DEFAULT_MAX_AGE, prune_older_than}; +#[allow(unused_imports)] +pub use repo::{Snapshot, SnapshotId, SnapshotRepo}; diff --git a/crates/tui/src/snapshot/paths.rs b/crates/tui/src/snapshot/paths.rs new file mode 100644 index 00000000..90d70091 --- /dev/null +++ b/crates/tui/src/snapshot/paths.rs @@ -0,0 +1,139 @@ +//! Path resolution for the per-workspace snapshot side-repos. +//! +//! Snapshots live in `~/.deepseek/snapshots///`. +//! The two-level hash split lets us snapshot multiple worktrees of the same +//! project independently — `git worktree list` users won't get cross-talk +//! between feature branches. + +use std::io; +use std::path::{Path, PathBuf}; + +/// Compute the snapshot directory for a given workspace path. +/// +/// Returns `~/.deepseek/snapshots///`. The +/// caller is responsible for creating it on disk; we purposefully don't +/// touch the filesystem here so this is cheap to call repeatedly. +/// +/// The `project_hash` is derived from the canonicalized workspace path +/// after stripping any `.worktrees/` suffix — multiple worktrees +/// of the same repo share the same `project_hash` so users can browse +/// snapshots cross-worktree if they want, but the `worktree_hash` keeps +/// commits isolated by default. +pub fn snapshot_dir_for(workspace: &Path) -> PathBuf { + snapshot_dir_with_home(workspace, dirs::home_dir()) +} + +/// Same as [`snapshot_dir_for`] but with an injectable home directory. +/// Used by tests so we never touch the user's real `~/.deepseek/`. +pub fn snapshot_dir_with_home(workspace: &Path, home: Option) -> PathBuf { + let home = home.unwrap_or_else(|| PathBuf::from(".")); + let canonical = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + let project_root = strip_worktree_suffix(&canonical); + let project_hash = stable_hex(&project_root); + let worktree_hash = stable_hex(&canonical); + home.join(".deepseek") + .join("snapshots") + .join(project_hash) + .join(worktree_hash) +} + +/// Resolve the `.git` directory inside the snapshot dir. +pub fn snapshot_git_dir(workspace: &Path) -> PathBuf { + snapshot_dir_for(workspace).join(".git") +} + +/// Ensure the snapshot dir exists on disk and return its path. +pub fn ensure_snapshot_dir(workspace: &Path) -> io::Result { + let dir = snapshot_dir_for(workspace); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Strip a trailing `.worktrees/` segment so all worktrees of the +/// same checkout share a `project_hash`. If the path doesn't look like a +/// worktree it's returned unchanged. +fn strip_worktree_suffix(path: &Path) -> PathBuf { + let mut components: Vec<_> = path.components().collect(); + if components.len() >= 2 + && let Some(parent) = components.get(components.len() - 2) + && parent.as_os_str() == ".worktrees" + { + components.truncate(components.len() - 2); + let mut p = PathBuf::new(); + for c in components { + p.push(c.as_os_str()); + } + return p; + } + path.to_path_buf() +} + +/// Hex-encoded deterministic FNV-1a digest. This is only a directory tag, not +/// a security boundary, but it must remain stable across process launches. +fn stable_hex(path: &Path) -> String { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in path.to_string_lossy().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn snapshot_dir_layout_two_levels_under_deepseek() { + let tmp = tempdir().expect("tempdir"); + let dir = snapshot_dir_with_home(tmp.path(), Some(tmp.path().to_path_buf())); + let mut iter = dir.strip_prefix(tmp.path()).unwrap().components(); + assert_eq!(iter.next().unwrap().as_os_str(), ".deepseek"); + assert_eq!(iter.next().unwrap().as_os_str(), "snapshots"); + assert!(iter.next().is_some()); // project_hash + assert!(iter.next().is_some()); // worktree_hash + assert!(iter.next().is_none()); + } + + #[test] + fn worktree_suffix_stripped_for_project_hash() { + let tmp = tempdir().expect("tempdir"); + let main_path = tmp.path().join("repo"); + let wt_path = tmp.path().join("repo").join(".worktrees").join("featX"); + std::fs::create_dir_all(&main_path).unwrap(); + std::fs::create_dir_all(&wt_path).unwrap(); + + let main_dir = snapshot_dir_with_home(&main_path, Some(tmp.path().to_path_buf())); + let wt_dir = snapshot_dir_with_home(&wt_path, Some(tmp.path().to_path_buf())); + + // Same project_hash (parent component before the worktree-specific tail). + let main_components: Vec<_> = main_dir.components().collect(); + let wt_components: Vec<_> = wt_dir.components().collect(); + assert_eq!( + main_components[main_components.len() - 2], + wt_components[wt_components.len() - 2], + "worktrees should share project_hash", + ); + // But different worktree_hash (the tail). + assert_ne!(main_components.last(), wt_components.last()); + } + + #[test] + fn ensure_snapshot_dir_creates_path() { + let tmp = tempdir().expect("tempdir"); + // Use scoped HOME so we don't pollute the real one. + let dir = snapshot_dir_with_home(tmp.path(), Some(tmp.path().to_path_buf())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(dir.exists()); + } + + #[test] + fn snapshot_git_dir_appends_dot_git() { + let tmp = tempdir().expect("tempdir"); + let git_dir = snapshot_git_dir(tmp.path()); + assert_eq!(git_dir.file_name().unwrap(), ".git"); + } +} diff --git a/crates/tui/src/snapshot/prune.rs b/crates/tui/src/snapshot/prune.rs new file mode 100644 index 00000000..3ad67893 --- /dev/null +++ b/crates/tui/src/snapshot/prune.rs @@ -0,0 +1,91 @@ +//! Boot-time snapshot pruning. +//! +//! Called from `session_manager` once per session start. Failure is +//! never fatal — old snapshots taking disk space is annoying but not +//! correctness-breaking, so we log and move on. + +use std::io; +use std::path::Path; +use std::time::Duration; + +use super::paths::snapshot_git_dir; +use super::repo::SnapshotRepo; + +/// Default snapshot retention window: 7 days. +pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +/// Prune snapshots older than `max_age` for the given workspace. +/// +/// If no snapshot repo exists yet (first run) this is a cheap no-op. +/// Returns the number of snapshots removed. +pub fn prune_older_than(workspace: &Path, max_age: Duration) -> io::Result { + let git_dir = snapshot_git_dir(workspace); + if !git_dir.exists() { + return Ok(0); + } + let repo = SnapshotRepo::open_or_init(workspace)?; + repo.prune_older_than(max_age) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::lock_test_env; + use std::sync::MutexGuard; + use tempfile::tempdir; + + /// Same guard shape as in `repo::tests` — pins HOME for the lifetime + /// of one test under the process-wide env mutex. + struct ScopedHome { + prev: Option, + _guard: MutexGuard<'static, ()>, + } + impl Drop for ScopedHome { + fn drop(&mut self) { + // SAFETY: process-wide lock still held. + unsafe { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } + } + fn scoped_home(home: &std::path::Path) -> ScopedHome { + let guard = lock_test_env(); + let prev = std::env::var_os("HOME"); + // SAFETY: serialised by the global env lock. + unsafe { + std::env::set_var("HOME", home); + } + ScopedHome { + prev, + _guard: guard, + } + } + + #[test] + fn prune_no_repo_returns_zero() { + let tmp = tempdir().unwrap(); + let _home = scoped_home(tmp.path()); + let removed = prune_older_than(tmp.path(), DEFAULT_MAX_AGE).unwrap(); + assert_eq!(removed, 0); + } + + #[test] + fn prune_with_existing_repo_zero_age_clears_all() { + let tmp = tempdir().unwrap(); + let _home = scoped_home(tmp.path()); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); + std::fs::write(workspace.join("f.txt"), "x").unwrap(); + repo.snapshot("turn:0").unwrap(); + + // Same-second flake guard: see `repo::tests`. + std::thread::sleep(Duration::from_millis(1100)); + + let removed = prune_older_than(&workspace, Duration::from_secs(0)).unwrap(); + assert!(removed >= 1); + } +} diff --git a/crates/tui/src/snapshot/repo.rs b/crates/tui/src/snapshot/repo.rs new file mode 100644 index 00000000..be3707c7 --- /dev/null +++ b/crates/tui/src/snapshot/repo.rs @@ -0,0 +1,664 @@ +//! Side-git repository wrapper for workspace snapshots. +//! +//! `SnapshotRepo` shells out to the system `git` binary (we deliberately +//! avoid `git2` to dodge its LGPL surface). The two paths that matter: +//! +//! - `git_dir` → `~/.deepseek/snapshots///.git` +//! - `work_tree` → the user's actual workspace +//! +//! Every git invocation passes both `--git-dir` AND `--work-tree`. That is +//! the single biggest safety mechanism: it guarantees we never accidentally +//! mutate the user's own `.git` directory. If git can't find the side +//! repo, the command fails fast instead of falling back to "current +//! directory". + +use std::collections::HashSet; +use std::io; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use super::paths::{ensure_snapshot_dir, snapshot_git_dir}; + +/// Identifier for a snapshot — currently the underlying git commit SHA. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotId(pub String); + +impl SnapshotId { + /// Borrow the SHA as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A single snapshot record (one row in `git log`). +#[derive(Debug, Clone)] +pub struct Snapshot { + /// Commit SHA inside the side repo. + pub id: SnapshotId, + /// Subject line — the label passed to [`SnapshotRepo::snapshot`]. + pub label: String, + /// Author timestamp (Unix seconds). + pub timestamp: i64, +} + +/// Wrapper around the per-workspace side-git repo. +pub struct SnapshotRepo { + git_dir: PathBuf, + work_tree: PathBuf, +} + +impl SnapshotRepo { + /// Open or initialize the snapshot repo for `workspace`. + /// + /// On first use this: + /// 1. Creates the `~/.deepseek/snapshots/<…>/.git` dir. + /// 2. Runs `git init --bare=false --quiet`. + /// 3. Sets a fixed `user.name` / `user.email` so commits don't pick up + /// the user's global git identity (we don't want our snapshots to + /// look like they came from the user). + pub fn open_or_init(workspace: &Path) -> io::Result { + let work_tree = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + + let _ = ensure_snapshot_dir(&work_tree)?; + let git_dir = snapshot_git_dir(&work_tree); + + let needs_init = !git_dir.exists(); + if needs_init { + let parent = git_dir.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "snapshot dir has no parent") + })?; + std::fs::create_dir_all(parent)?; + // `git init` here uses the parent directory as the work tree + // and stores metadata in `.git`. We then continue to use + // explicit `--git-dir` / `--work-tree` flags for every other + // command so behaviour is invariant of cwd. + let init = Command::new("git") + .arg("init") + .arg("--quiet") + .arg(parent) + .output() + .map_err(|e| io_other(format!("failed to spawn git init: {e}")))?; + if !init.status.success() { + return Err(io_other(format!( + "git init failed: {}", + String::from_utf8_lossy(&init.stderr).trim() + ))); + } + + // Pin a stable identity so snapshot commits are recognisable + // and don't bleed into the user's git config. + let _ = run_git( + &git_dir, + &work_tree, + &["config", "user.name", "deepseek-snapshots"], + ); + let _ = run_git( + &git_dir, + &work_tree, + &["config", "user.email", "snapshots@deepseek-tui.local"], + ); + // Don't auto-gc on every commit; we manage pruning ourselves. + let _ = run_git(&git_dir, &work_tree, &["config", "gc.auto", "0"]); + // Ignore CRLF rewriting — we want byte-for-byte fidelity. + let _ = run_git(&git_dir, &work_tree, &["config", "core.autocrlf", "false"]); + } + + Ok(Self { git_dir, work_tree }) + } + + /// Take a snapshot of the current working tree. + /// + /// Internally: `git add -A`, `git write-tree`, `git commit-tree`, then + /// `git update-ref HEAD `. + /// `git add -A` honours the user's workspace ignore rules while staging + /// into the side repo's index. + /// + /// Returns the snapshot's commit SHA. + pub fn snapshot(&self, label: &str) -> io::Result { + // Stage every tracked + untracked path the workspace exposes. + // `--all` here means `add` + `update` + `remove` — the same set + // `git status` would show. + let add = run_git(&self.git_dir, &self.work_tree, &["add", "-A"])?; + if !add.status.success() { + return Err(io_other(format!( + "git add -A failed: {}", + String::from_utf8_lossy(&add.stderr).trim() + ))); + } + + let tree = run_git(&self.git_dir, &self.work_tree, &["write-tree"])?; + if !tree.status.success() { + return Err(io_other(format!( + "git write-tree failed: {}", + String::from_utf8_lossy(&tree.stderr).trim() + ))); + } + let tree = String::from_utf8_lossy(&tree.stdout).trim().to_string(); + + let parent = run_git( + &self.git_dir, + &self.work_tree, + &["rev-parse", "--verify", "HEAD"], + )?; + let parent = parent + .status + .success() + .then(|| String::from_utf8_lossy(&parent.stdout).trim().to_string()) + .filter(|s| !s.is_empty()); + + let mut args = vec!["commit-tree".to_string(), tree]; + if let Some(parent) = parent { + args.push("-p".to_string()); + args.push(parent); + } + args.push("-m".to_string()); + args.push(label.to_string()); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + + // `commit-tree` creates marker commits even when the tree matches its + // parent, and it does not run user/global commit hooks. + let commit = run_git(&self.git_dir, &self.work_tree, &arg_refs)?; + if !commit.status.success() { + return Err(io_other(format!( + "git commit-tree failed: {}", + String::from_utf8_lossy(&commit.stderr).trim() + ))); + } + let sha = String::from_utf8_lossy(&commit.stdout).trim().to_string(); + + let update = run_git( + &self.git_dir, + &self.work_tree, + &["update-ref", "HEAD", &sha], + )?; + if !update.status.success() { + return Err(io_other(format!( + "git update-ref HEAD failed: {}", + String::from_utf8_lossy(&update.stderr).trim() + ))); + } + + Ok(SnapshotId(sha)) + } + + /// Restore the workspace to the state at `id`. + /// + /// Uses `git checkout -- :/` which checks out every path in the + /// snapshot tree relative to the workspace root. We do NOT touch the + /// user's own `.git` — snapshots only contain working-tree files. + pub fn restore(&self, id: &SnapshotId) -> io::Result<()> { + let current_paths = self.tree_paths("HEAD")?; + let target_paths = self.tree_paths(id.as_str())?; + let checkout = run_git( + &self.git_dir, + &self.work_tree, + &["checkout", id.as_str(), "--", ":/"], + )?; + if !checkout.status.success() { + return Err(io_other(format!( + "git checkout failed: {}", + String::from_utf8_lossy(&checkout.stderr).trim() + ))); + } + self.remove_paths_missing_from_target(¤t_paths, &target_paths)?; + Ok(()) + } + + fn tree_paths(&self, treeish: &str) -> io::Result> { + let ls = run_git( + &self.git_dir, + &self.work_tree, + &["ls-tree", "-r", "-z", "--name-only", treeish], + )?; + if !ls.status.success() { + return Err(io_other(format!( + "git ls-tree failed: {}", + String::from_utf8_lossy(&ls.stderr).trim() + ))); + } + Ok(parse_nul_paths(&ls.stdout)) + } + + fn remove_paths_missing_from_target( + &self, + current_paths: &HashSet, + target_paths: &HashSet, + ) -> io::Result<()> { + for rel in current_paths.difference(target_paths) { + if !is_safe_relative_path(rel) { + continue; + } + let path = self.work_tree.join(rel); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_dir() { + let _ = std::fs::remove_dir(&path); + } else { + std::fs::remove_file(&path)?; + } + self.prune_empty_parent_dirs(path.parent()); + } + Ok(()) + } + + fn prune_empty_parent_dirs(&self, mut dir: Option<&Path>) { + while let Some(path) = dir { + if path == self.work_tree { + break; + } + if std::fs::remove_dir(path).is_err() { + break; + } + dir = path.parent(); + } + } + + /// List up to `limit` most-recent snapshots, newest first. + pub fn list(&self, limit: usize) -> io::Result> { + // `git log -` is the short form of `--max-count=`; if `limit` + // is `usize::MAX` (caller asked for "everything") we pass an empty + // count so git defaults to no upper bound. + let mut args: Vec = vec!["log".to_string()]; + if limit < usize::MAX { + args.push(format!("--max-count={limit}")); + } + args.push("--pretty=format:%H%x09%at%x09%s".to_string()); + args.push("--no-color".to_string()); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let log = run_git(&self.git_dir, &self.work_tree, &arg_refs)?; + if !log.status.success() { + // No commits yet → empty list. + return Ok(Vec::new()); + } + let stdout = String::from_utf8_lossy(&log.stdout); + let mut out = Vec::new(); + for line in stdout.lines() { + let mut parts = line.splitn(3, '\t'); + let sha = parts.next().unwrap_or("").to_string(); + let ts = parts + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let subject = parts.next().unwrap_or("").to_string(); + if sha.is_empty() { + continue; + } + out.push(Snapshot { + id: SnapshotId(sha), + label: subject, + timestamp: ts, + }); + } + Ok(out) + } + + /// Drop snapshots older than `max_age`, returning the count removed. + /// + /// Strategy: identify keepable commits (younger than the cutoff), + /// reset HEAD to the oldest survivor, then `git reflog expire` + + /// `git gc --prune=now` to actually reclaim space. Cheap and avoids + /// rewriting history when nothing has aged out. + pub fn prune_older_than(&self, max_age: Duration) -> io::Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| io_other(format!("clock error: {e}")))? + .as_secs() as i64; + let cutoff = now - max_age.as_secs() as i64; + + let snapshots = self.list(usize::MAX)?; + if snapshots.is_empty() { + return Ok(0); + } + + // Snapshots are newest-first. Find the index of the first one + // at-or-older than the cutoff — every entry from that index + // onward is a candidate for removal. We use `<=` so a 0-second + // retention drops same-second commits (otherwise tests calling + // `prune_older_than(Duration::ZERO)` immediately after creating + // a snapshot would never prune anything). + let cut_index = snapshots.iter().position(|s| s.timestamp <= cutoff); + let Some(cut) = cut_index else { + return Ok(0); + }; + let removed = snapshots.len() - cut; + if removed == 0 { + return Ok(0); + } + + if cut == 0 { + // Every snapshot is older than the cutoff — wipe the repo + // entirely so the next snapshot starts a fresh history. + // Removing `.git/refs/heads/*` is enough to orphan the old + // commits, then gc reclaims them. + let refs_dir = self.git_dir.join("refs").join("heads"); + if refs_dir.exists() { + for entry in std::fs::read_dir(&refs_dir)? { + let path = entry?.path(); + if path.is_file() { + let _ = std::fs::remove_file(&path); + } + } + } + // Also drop HEAD's packed refs so `git log` returns nothing. + let packed = self.git_dir.join("packed-refs"); + if packed.exists() { + let _ = std::fs::remove_file(&packed); + } + } else { + // Reset HEAD to the youngest commit older-than-cutoff's + // *predecessor* — i.e. the oldest surviving snapshot. + let survivor = &snapshots[cut - 1]; + let reset = run_git( + &self.git_dir, + &self.work_tree, + &["update-ref", "HEAD", survivor.id.as_str()], + )?; + if !reset.status.success() { + return Err(io_other(format!( + "git update-ref failed: {}", + String::from_utf8_lossy(&reset.stderr).trim() + ))); + } + } + + // Reclaim space. + let _ = run_git( + &self.git_dir, + &self.work_tree, + &["reflog", "expire", "--expire=now", "--all"], + ); + let _ = run_git( + &self.git_dir, + &self.work_tree, + &["gc", "--prune=now", "--quiet"], + ); + + Ok(removed) + } + + /// Return the side-repo's `.git` directory (for diagnostics). + #[allow(dead_code)] + pub fn git_dir(&self) -> &Path { + &self.git_dir + } + + /// Return the work tree path (for diagnostics). + #[allow(dead_code)] + pub fn work_tree(&self) -> &Path { + &self.work_tree + } +} + +fn run_git(git_dir: &Path, work_tree: &Path, args: &[&str]) -> io::Result { + Command::new("git") + .arg("--git-dir") + .arg(git_dir) + .arg("--work-tree") + .arg(work_tree) + .args(args) + .output() +} + +fn io_other(msg: impl Into) -> io::Error { + io::Error::other(msg.into()) +} + +fn parse_nul_paths(bytes: &[u8]) -> HashSet { + bytes + .split(|b| *b == 0) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| PathBuf::from(String::from_utf8_lossy(chunk).into_owned())) + .collect() +} + +fn is_safe_relative_path(path: &Path) -> bool { + !path.as_os_str().is_empty() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::lock_test_env; + use std::sync::MutexGuard; + use tempfile::tempdir; + + /// Holds HOME pinned to a tempdir for the lifetime of a test. Also + /// owns the process-wide env-var mutex so tests across modules + /// don't trample each other's `HOME`. + pub(super) struct ScopedHome { + prev: Option, + _guard: MutexGuard<'static, ()>, + } + impl Drop for ScopedHome { + fn drop(&mut self) { + // SAFETY: process-wide lock still held. + unsafe { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } + } + pub(super) fn scoped_home(home: &Path) -> ScopedHome { + let guard = lock_test_env(); + let prev = std::env::var_os("HOME"); + // SAFETY: serialised by the global env lock. + unsafe { + std::env::set_var("HOME", home); + } + ScopedHome { + prev, + _guard: guard, + } + } + + /// Build a side-repo whose snapshot dir lives under the same + /// tempdir we're using for `HOME` — so the inner `dirs::home_dir()` + /// lookup stays inside our sandbox. Returns the guard alongside so + /// the caller can keep HOME pinned for the rest of the test. + fn make_repo(tmp: &Path) -> (SnapshotRepo, ScopedHome) { + let workspace = tmp.join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let guard = scoped_home(tmp); + let repo = SnapshotRepo::open_or_init(&workspace).expect("open_or_init"); + (repo, guard) + } + + #[test] + fn snapshot_creates_commit_in_side_repo_only() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + std::fs::write(repo.work_tree().join("a.txt"), b"alpha").unwrap(); + + let id = repo.snapshot("pre-turn:1").expect("snapshot"); + assert_eq!(id.as_str().len(), 40); + + let list = repo.list(10).expect("list"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].label, "pre-turn:1"); + + // The user's workspace must NOT have a real `.git` because we + // never created one in their workspace — only in the side dir. + assert!(!repo.work_tree().join(".git").exists()); + } + + #[test] + fn restore_reverts_workspace_files() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + let f = repo.work_tree().join("file.txt"); + + std::fs::write(&f, b"original").unwrap(); + let id = repo.snapshot("pre-turn:1").expect("snapshot"); + + std::fs::write(&f, b"clobbered").unwrap(); + repo.snapshot("post-turn:1").expect("snapshot 2"); + + repo.restore(&id).expect("restore"); + let after = std::fs::read_to_string(&f).unwrap(); + assert_eq!(after, "original"); + } + + #[test] + fn restore_removes_files_added_after_target_snapshot() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + let original = repo.work_tree().join("original.txt"); + let added = repo.work_tree().join("added.txt"); + + std::fs::write(&original, b"original").unwrap(); + let id = repo.snapshot("pre-turn:1").expect("snapshot"); + + std::fs::write(&added, b"new file").unwrap(); + repo.snapshot("post-turn:1").expect("snapshot 2"); + + repo.restore(&id).expect("restore"); + assert!(original.exists()); + assert!(!added.exists(), "restore must remove tracked added files"); + } + + #[test] + fn snapshot_and_restore_do_not_move_user_git_head() { + let tmp = tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + Command::new("git") + .arg("-C") + .arg(&workspace) + .arg("init") + .arg("--quiet") + .status() + .unwrap(); + std::fs::write(workspace.join("tracked.txt"), b"committed").unwrap(); + Command::new("git") + .arg("-C") + .arg(&workspace) + .arg("add") + .arg("tracked.txt") + .status() + .unwrap(); + Command::new("git") + .arg("-C") + .arg(&workspace) + .arg("-c") + .arg("user.name=user") + .arg("-c") + .arg("user.email=user@example.test") + .arg("commit") + .arg("--quiet") + .arg("-m") + .arg("init") + .status() + .unwrap(); + let user_head_before = Command::new("git") + .arg("-C") + .arg(&workspace) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap() + .stdout; + + let _home = scoped_home(tmp.path()); + let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); + std::fs::write(workspace.join("tracked.txt"), b"dirty-before").unwrap(); + let id = repo.snapshot("pre-turn:1").unwrap(); + std::fs::write(workspace.join("tracked.txt"), b"dirty-after").unwrap(); + repo.snapshot("post-turn:1").unwrap(); + repo.restore(&id).unwrap(); + + let user_head_after = Command::new("git") + .arg("-C") + .arg(&workspace) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap() + .stdout; + assert_eq!(user_head_after, user_head_before); + assert_eq!( + std::fs::read_to_string(workspace.join("tracked.txt")).unwrap(), + "dirty-before" + ); + } + + #[test] + fn list_respects_limit() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + for i in 0..5 { + std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + } + let three = repo.list(3).unwrap(); + assert_eq!(three.len(), 3); + // Newest first. + assert_eq!(three[0].label, "turn:4"); + } + + #[test] + fn prune_drops_snapshots_older_than_threshold() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap(); + repo.snapshot("turn:0").unwrap(); + + // Wait one second so the snapshot's commit timestamp is strictly + // in the past relative to the prune call's "now" — otherwise + // same-second comparisons make the assertion flaky. + std::thread::sleep(Duration::from_millis(1100)); + + let removed = repo.prune_older_than(Duration::from_secs(0)).unwrap(); + assert!(removed >= 1, "expected at least 1 pruned, got {removed}"); + + // After pruning everything, the next snapshot should start a + // fresh history. + std::fs::write(repo.work_tree().join("f.txt"), "v1").unwrap(); + repo.snapshot("turn:1").unwrap(); + let list = repo.list(10).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].label, "turn:1"); + } + + #[test] + fn snapshot_respects_workspace_gitignore() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + std::fs::write(repo.work_tree().join(".gitignore"), "ignored.txt\n").unwrap(); + std::fs::write(repo.work_tree().join("ignored.txt"), b"secret").unwrap(); + std::fs::write(repo.work_tree().join("kept.txt"), b"public").unwrap(); + + let id = repo.snapshot("pre-turn:1").expect("snapshot"); + + // `git ls-tree` against the snapshot's commit shouldn't list ignored.txt. + let ls = run_git( + repo.git_dir(), + repo.work_tree(), + &["ls-tree", "-r", "--name-only", id.as_str()], + ) + .expect("ls-tree"); + let names = String::from_utf8_lossy(&ls.stdout); + assert!(names.contains("kept.txt"), "kept.txt missing: {names}"); + assert!( + !names.contains("ignored.txt"), + "ignored.txt should not be in snapshot: {names}", + ); + } + + #[test] + fn open_or_init_is_idempotent() { + let tmp = tempdir().unwrap(); + let (_r, _h) = make_repo(tmp.path()); + // Second open should not panic and should reuse the existing + // `.git`. We re-open via the public API rather than make_repo to + // avoid double-acquiring HOME (the guard would deadlock). + drop((_r, _h)); + let (_r2, _h2) = make_repo(tmp.path()); + } +} diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index e080f0a5..075963d8 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -15,6 +15,7 @@ pub mod plan; pub mod project; pub mod recall_archive; pub mod registry; +pub mod revert_turn; pub mod review; pub mod rlm; pub mod search; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index d6427348..da5ead51 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -381,6 +381,16 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(ApplyPatchTool)) } + /// Include the `revert_turn` tool. Approval-gated since it mutates + /// the workspace; the model uses it when the user asks to "undo my + /// last edit". Backed by the per-workspace snapshot side-repo + /// (`crate::snapshot`). + #[must_use] + pub fn with_revert_turn_tool(self) -> Self { + use super::revert_turn::RevertTurnTool; + self.with_tool(Arc::new(RevertTurnTool)) + } + /// Include the RLM tool (`rlm`). Runs the full recursive language-model /// loop on a long input (file or inline content); the long input never /// enters the calling model's context window. The Python REPL exposes @@ -458,7 +468,8 @@ impl ToolRegistryBuilder { .with_diagnostics_tool() .with_project_tools() .with_test_runner_tool() - .with_validation_tools(); + .with_validation_tools() + .with_revert_turn_tool(); if allow_shell { builder.with_shell_tools() diff --git a/crates/tui/src/tools/revert_turn.rs b/crates/tui/src/tools/revert_turn.rs new file mode 100644 index 00000000..9234d1cd --- /dev/null +++ b/crates/tui/src/tools/revert_turn.rs @@ -0,0 +1,205 @@ +//! `revert_turn` — agent-callable tool that rewinds the workspace to a +//! prior pre-turn snapshot. +//! +//! The model invokes this when the user says something like "undo the +//! last edit" or "roll back". It mirrors `/restore` but speaks JSON and +//! takes a turn-offset (default 1 = previous turn) instead of a list +//! index, so the model doesn't have to count entries. +//! +//! Approval is `Required` because this mutates the workspace. + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, +}; +use crate::snapshot::SnapshotRepo; + +/// Default offset: revert the most-recent turn (i.e. the last `pre-turn:*` +/// snapshot in history). +const DEFAULT_OFFSET: u64 = 1; +/// Hard cap so the model can't ask to roll back to the dawn of time. +const MAX_OFFSET: u64 = 50; + +pub struct RevertTurnTool; + +#[async_trait] +impl ToolSpec for RevertTurnTool { + fn name(&self) -> &str { + "revert_turn" + } + + fn description(&self) -> &str { + "Roll back the workspace files to the snapshot taken before a recent turn. \ + Use when the user explicitly asks to undo, revert, or roll back the most recent edits. \ + `turn_offset` is 1-based: 1 reverts the most recent turn, 2 reverts the previous one, \ + and so on (max 50). Conversation history is NOT modified — only working-tree files are \ + restored from the side-git snapshot repo." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "turn_offset": { + "type": "integer", + "minimum": 1, + "maximum": MAX_OFFSET, + "description": "How many turns back to revert (default 1)." + } + }, + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::WritesFiles, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let offset = optional_u64(&input, "turn_offset", DEFAULT_OFFSET); + if offset == 0 || offset > MAX_OFFSET { + return Err(ToolError::invalid_input(format!( + "turn_offset must be between 1 and {MAX_OFFSET}; got {offset}", + ))); + } + + let workspace = context.workspace.clone(); + let label = format!("revert_turn(offset={offset})"); + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = SnapshotRepo::open_or_init(&workspace) + .map_err(|e| format!("Snapshot repo init failed: {e}"))?; + // Find pre-turn:* snapshots only — those mark the start of + // each turn, which is the right rollback target. We pull a + // generous list and filter so the model's `turn_offset` is + // counted in turns, not raw snapshots. + let snapshots = repo + .list((MAX_OFFSET as usize).saturating_mul(2) + 16) + .map_err(|e| format!("Snapshot list failed: {e}"))?; + let pre_turns: Vec<_> = snapshots + .into_iter() + .filter(|s| s.label.starts_with("pre-turn:")) + .collect(); + let target = pre_turns + .get((offset - 1) as usize) + .ok_or_else(|| { + format!( + "Only {} pre-turn snapshot(s) exist; turn_offset={offset} is out of range.", + pre_turns.len(), + ) + })? + .clone(); + repo.restore(&target.id) + .map_err(|e| format!("Restore failed: {e}"))?; + Ok(format!( + "{label}: restored '{}' ({}). Workspace files reverted; conversation unchanged.", + target.label, + short_sha(target.id.as_str()), + )) + }) + .await + .map_err(|e| ToolError::execution_failed(format!("revert_turn join failed: {e}")))?; + + match result { + Ok(msg) => Ok(ToolResult::success(msg)), + Err(e) => Ok(ToolResult::error(e)), + } + } +} + +fn short_sha(sha: &str) -> &str { + &sha[..sha.len().min(8)] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::lock_test_env; + use std::sync::MutexGuard; + use tempfile::tempdir; + + /// Pins HOME to a tempdir for the duration of the test under the + /// process-wide env mutex (`crate::test_support::lock_test_env`). + struct HomeGuard { + prev: Option, + _lock: MutexGuard<'static, ()>, + } + impl Drop for HomeGuard { + fn drop(&mut self) { + // SAFETY: process-wide lock still held. + unsafe { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } + } + fn scoped_home(home: &std::path::Path) -> HomeGuard { + let lock = lock_test_env(); + let prev = std::env::var_os("HOME"); + // SAFETY: serialised by the global env lock. + unsafe { + std::env::set_var("HOME", home); + } + HomeGuard { prev, _lock: lock } + } + + #[tokio::test] + async fn revert_turn_default_offset_restores_pre_turn_one() { + let tmp = tempdir().unwrap(); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + let _guard = scoped_home(tmp.path()); + + // Setup: create pre-turn:1, post-turn:1 with file modifications. + let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); + std::fs::write(workspace.join("a.txt"), b"original").unwrap(); + repo.snapshot("pre-turn:1").unwrap(); + std::fs::write(workspace.join("a.txt"), b"modified").unwrap(); + repo.snapshot("post-turn:1").unwrap(); + + let tool = RevertTurnTool; + let ctx = ToolContext::new(workspace.clone()); + let r = tool.execute(json!({}), &ctx).await.expect("execute"); + assert!(r.success, "expected success: {r:?}"); + + let content = std::fs::read_to_string(workspace.join("a.txt")).unwrap(); + assert_eq!(content, "original"); + } + + #[tokio::test] + async fn revert_turn_invalid_offset_rejected() { + let tmp = tempdir().unwrap(); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + let _guard = scoped_home(tmp.path()); + + let tool = RevertTurnTool; + let ctx = ToolContext::new(workspace); + let r = tool.execute(json!({"turn_offset": 0}), &ctx).await; + assert!(r.is_err()); + } + + #[tokio::test] + async fn revert_turn_no_snapshots_returns_error_result() { + let tmp = tempdir().unwrap(); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + let _guard = scoped_home(tmp.path()); + + let tool = RevertTurnTool; + let ctx = ToolContext::new(workspace); + let r = tool.execute(json!({}), &ctx).await.expect("execute"); + assert!(!r.success); + assert!(r.content.contains("out of range")); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index aa5b1417..235f8c1f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -336,6 +336,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { network_policy: config.network.clone().map(|toml_cfg| { crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) }), + snapshots_enabled: config.snapshots_config().enabled, } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8495b5d..cde02e9a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -165,6 +165,7 @@ drives turns through Chat Completions. 3. While degraded/offline, new prompts are queued in-memory and mirrored to `~/.deepseek/sessions/checkpoints/offline_queue.json` 4. Queue edits (`/queue ...`) are persisted continuously so drafts and queued prompts survive restarts 5. Successful turn completion clears the active checkpoint and writes a durable session snapshot +6. Agent/Yolo turns also take pre/post-turn side-git workspace snapshots under `~/.deepseek/snapshots///.git`; `/restore N` and `revert_turn` restore file state without changing conversation history or the user's `.git` ### Tool Execution @@ -249,5 +250,6 @@ command = "echo 'Running tool: $TOOL_NAME'" - `~/.deepseek/skills/` - User skills directory - `~/.deepseek/sessions/` - Session history - `~/.deepseek/sessions/checkpoints/` - Crash checkpoint + offline queue persistence +- `~/.deepseek/snapshots/` - Side-git pre/post-turn workspace snapshots for `/restore` and `revert_turn` - `~/.deepseek/tasks/` - Background task records, queue, timelines, artifacts - `~/.deepseek/audit.log` - Append-only audit events for credential + approval/elevation actions diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cefa4cbf..d673d029 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -170,6 +170,10 @@ If you are upgrading from older releases: - `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`. - `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the `note` tool. - `memory_path` (string, optional): defaults to `~/.deepseek/memory.md`. +- `snapshots.*` (optional): side-git workspace snapshots for file rollback: + - `[snapshots].enabled` (bool, default `true`) + - `[snapshots].max_age_days` (int, default `7`) + - snapshots live under `~/.deepseek/snapshots///.git` and never use the workspace's own `.git` directory - `retry.*` (optional): retry/backoff settings for API requests: - `[retry].enabled` (bool, default `true`) - `[retry].max_retries` (int, default `3`)