Merge branch 'feat/v070-snapshots' (#137 side-git snapshots)

# Conflicts:
#	crates/config/src/lib.rs
#	crates/tui/src/config.rs
This commit is contained in:
Hunter Bown
2026-04-28 00:58:16 -05:00
21 changed files with 1617 additions and 2 deletions
+1
View File
@@ -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 - **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 - **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab
- **Session save/resume** — checkpoint and resume long sessions - **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 - **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows
- **MCP protocol** — connect to Model Context Protocol servers for extended tooling - **MCP protocol** — connect to Model Context Protocol servers for extended tooling
- **Live cost tracking** — per-turn and session-level token usage and cost estimates - **Live cost tracking** — per-turn and session-level token usage and cost estimates
+21
View File
@@ -232,6 +232,27 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# threshold_secs = 30 # threshold_secs = 30
# include_summary = false # include_summary = false
# ─────────────────────────────────────────────────────────────────────────────────
# Workspace Snapshots (#137)
# ─────────────────────────────────────────────────────────────────────────────────
# Each turn the TUI takes a `pre-turn:<seq>` and `post-turn:<seq>` snapshot of
# your workspace into a side-git repo at:
#
# ~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.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) # Hooks (optional)
# ───────────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────────
+31
View File
@@ -133,6 +133,10 @@ pub struct ConfigToml {
/// `registry_url` when running `deepseek skill install`. /// `registry_url` when running `deepseek skill install`.
#[serde(default)] #[serde(default)]
pub skills: Option<SkillsToml>, pub skills: Option<SkillsToml>,
/// Workspace side-git snapshots (#137). The live TUI defaults this to
/// enabled with 7-day retention when absent.
#[serde(default)]
pub snapshots: Option<SnapshotsToml>,
#[serde(flatten)] #[serde(flatten)]
pub extras: BTreeMap<String, toml::Value>, pub extras: BTreeMap<String, toml::Value>,
} }
@@ -151,6 +155,33 @@ pub struct SkillsToml {
pub max_install_size_bytes: Option<u64>, pub max_install_size_bytes: Option<u64>,
} }
/// 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` /// On-disk schema for the `[network]` table (#135). See `config.example.toml`
/// for documentation. /// for documentation.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+8
View File
@@ -12,6 +12,7 @@ mod init;
mod note; mod note;
mod provider; mod provider;
mod queue; mod queue;
mod restore;
mod review; mod review;
mod session; mod session;
mod skills; mod skills;
@@ -337,6 +338,12 @@ pub const COMMANDS: &[CommandInfo] = &[
description: "Run a structured code review on a file, diff, or PR", description: "Run a structured code review on a file, diff, or PR",
usage: "/review <target>", usage: "/review <target>",
}, },
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 // RLM command
CommandInfo { CommandInfo {
name: "rlm", name: "rlm",
@@ -412,6 +419,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"skills" => skills::list_skills(app, arg), "skills" => skills::list_skills(app, arg),
"skill" => skills::run_skill(app, arg), "skill" => skills::run_skill(app, arg),
"review" => review::review(app, arg), "review" => review::review(app, arg),
"restore" => restore::restore(app, arg),
// RLM command // RLM command
"rlm" | "recursive" => rlm(app, arg), "rlm" | "recursive" => rlm(app, arg),
+255
View File
@@ -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 <N>` 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> (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 <N> 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<std::ffi::OsString>,
_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:"));
}
}
+47
View File
@@ -192,6 +192,41 @@ pub struct NotificationsConfig {
pub include_summary: bool, 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. /// One configurable footer item.
/// ///
/// Order in the user's `Vec<StatusItem>` is preserved: items in the left /// Order in the user's `Vec<StatusItem>` is preserved: items in the left
@@ -436,6 +471,11 @@ pub struct Config {
/// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]). /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).
#[serde(default)] #[serde(default)]
pub skills: Option<SkillsConfig>, pub skills: Option<SkillsConfig>,
/// Workspace side-git snapshots (#137). Defaults to enabled with 7-day
/// retention when the table is absent.
#[serde(default)]
pub snapshots: Option<SnapshotsConfig>,
} }
/// `[skills]` table — knobs for the community-skill installer. /// `[skills]` table — knobs for the community-skill installer.
@@ -912,6 +952,12 @@ impl Config {
self.notifications.clone().unwrap_or_default() 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. /// Resolve enabled features from defaults and config entries.
#[must_use] #[must_use]
pub fn features(&self) -> Features { 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), notifications: override_cfg.notifications.or(base.notifications),
network: override_cfg.network.or(base.network), network: override_cfg.network.or(base.network),
skills: override_cfg.skills.or(base.skills), skills: override_cfg.skills.or(base.skills),
snapshots: override_cfg.snapshots.or(base.snapshots),
} }
} }
+24 -1
View File
@@ -61,7 +61,7 @@ use super::events::{Event, TurnOutcomeStatus};
use super::ops::Op; use super::ops::Op;
use super::session::Session; use super::session::Session;
use super::tool_parser; use super::tool_parser;
use super::turn::{TurnContext, TurnToolCall}; use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot};
// === Types === // === Types ===
@@ -110,6 +110,8 @@ pub struct EngineConfig {
/// session-scoped approvals (`/network allow <host>`) persist for the /// session-scoped approvals (`/network allow <host>`) persist for the
/// remainder of the run. /// remainder of the run.
pub network_policy: Option<crate::network_policy::NetworkPolicyDecider>, pub network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
/// Whether to take side-git workspace snapshots before/after each turn.
pub snapshots_enabled: bool,
} }
impl Default for EngineConfig { impl Default for EngineConfig {
@@ -131,6 +133,7 @@ impl Default for EngineConfig {
plan_state: new_shared_plan_state(), plan_state: new_shared_plan_state(),
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
network_policy: None, network_policy: None,
snapshots_enabled: true,
} }
} }
} }
@@ -1436,6 +1439,16 @@ impl Engine {
self.turn_counter = self.turn_counter.saturating_add(1); self.turn_counter = self.turn_counter.saturating_add(1);
self.capacity_controller.mark_turn_start(self.turn_counter); 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 // Emit turn started event
let _ = self let _ = self
.tx_event .tx_event
@@ -1656,6 +1669,16 @@ impl Engine {
}) })
.await; .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 // Checkpoint-restart cycle boundary (issue #124). The turn just
// settled cleanly — no in-flight tools, no streaming, no pending // settled cleanly — no in-flight tools, no streaming, no pending
// approval — so this is the safe phase to swap the context if we've // approval — so this is the safe phase to swap the context if we've
+42
View File
@@ -2,8 +2,20 @@
//! //!
//! A "turn" is one user message and the resulting AI response, //! A "turn" is one user message and the resulting AI response,
//! including any tool calls that occur. //! 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::models::Usage;
use crate::snapshot::SnapshotRepo;
use std::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Context for a single turn (user message + AI response). /// Context for a single turn (user message + AI response).
@@ -116,6 +128,36 @@ fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> {
} }
} }
/// Take a `pre-turn:<seq>` 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<String> {
snapshot_with_label(workspace, &format!("pre-turn:{turn_seq}"))
}
/// Take a `post-turn:<seq>` workspace snapshot. Same failure model as
/// [`pre_turn_snapshot`].
pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option<String> {
snapshot_with_label(workspace, &format!("post-turn:{turn_seq}"))
}
fn snapshot_with_label(workspace: &Path, label: &str) -> Option<String> {
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 { impl TurnToolCall {
/// Create a new tool call record /// Create a new tool call record
pub fn new(id: String, name: String, input: serde_json::Value) -> Self { pub fn new(id: String, name: String, input: serde_json::Value) -> Self {
+10
View File
@@ -47,6 +47,7 @@ mod sandbox;
mod session_manager; mod session_manager;
mod settings; mod settings;
mod skills; mod skills;
mod snapshot;
mod task_manager; mod task_manager;
#[cfg(test)] #[cfg(test)]
mod test_support; mod test_support;
@@ -2787,6 +2788,14 @@ async fn run_interactive(
logging::warn(format!("Failed to install system skills: {e}")); 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( tui::run_tui(
config, config,
tui::TuiOptions { tui::TuiOptions {
@@ -2950,6 +2959,7 @@ async fn run_exec_agent(
plan_state: new_shared_plan_state(), plan_state: new_shared_plan_state(),
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
network_policy, network_policy,
snapshots_enabled: config.snapshots_config().enabled,
}; };
let engine_handle = spawn_engine(engine_config, config); let engine_handle = spawn_engine(engine_config, config);
+1
View File
@@ -1509,6 +1509,7 @@ impl RuntimeThreadManager {
plan_state: new_shared_plan_state(), plan_state: new_shared_plan_state(),
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
network_policy, network_policy,
snapshots_enabled: self.config.snapshots_config().enabled,
}; };
let engine = spawn_engine(engine_cfg, &self.config); let engine = spawn_engine(engine_cfg, &self.config);
+16
View File
@@ -370,6 +370,22 @@ pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
Ok(home.join(".deepseek").join("sessions")) 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 /// Create a new `SavedSession` from conversation state
pub fn create_saved_session( pub fn create_saved_session(
messages: &[Message], messages: &[Message],
+42
View File
@@ -0,0 +1,42 @@
//! Workspace snapshots — pre/post-turn safety net.
//!
//! Each turn the engine takes a `pre-turn:<seq>` snapshot of the user's
//! workspace into a side git repo at
//! `~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git`, then a
//! matching `post-turn:<seq>` 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};
+139
View File
@@ -0,0 +1,139 @@
//! Path resolution for the per-workspace snapshot side-repos.
//!
//! Snapshots live in `~/.deepseek/snapshots/<project_hash>/<worktree_hash>/`.
//! 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/<project_hash>/<worktree_hash>/`. 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/<name>` 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>) -> 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<PathBuf> {
let dir = snapshot_dir_for(workspace);
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// Strip a trailing `.worktrees/<name>` 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");
}
}
+91
View File
@@ -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<usize> {
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<std::ffi::OsString>,
_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);
}
}
+664
View File
@@ -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/<project_hash>/<worktree_hash>/.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<Self> {
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 <commit>`.
/// `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<SnapshotId> {
// 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 <sha> -- :/` 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(&current_paths, &target_paths)?;
Ok(())
}
fn tree_paths(&self, treeish: &str) -> io::Result<HashSet<PathBuf>> {
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<PathBuf>,
target_paths: &HashSet<PathBuf>,
) -> 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<Vec<Snapshot>> {
// `git log -<n>` is the short form of `--max-count=<n>`; 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<String> = 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::<i64>().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<usize> {
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<Output> {
Command::new("git")
.arg("--git-dir")
.arg(git_dir)
.arg("--work-tree")
.arg(work_tree)
.args(args)
.output()
}
fn io_other(msg: impl Into<String>) -> io::Error {
io::Error::other(msg.into())
}
fn parse_nul_paths(bytes: &[u8]) -> HashSet<PathBuf> {
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<std::ffi::OsString>,
_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());
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod plan;
pub mod project; pub mod project;
pub mod recall_archive; pub mod recall_archive;
pub mod registry; pub mod registry;
pub mod revert_turn;
pub mod review; pub mod review;
pub mod rlm; pub mod rlm;
pub mod search; pub mod search;
+12 -1
View File
@@ -381,6 +381,16 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(ApplyPatchTool)) 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 /// Include the RLM tool (`rlm`). Runs the full recursive language-model
/// loop on a long input (file or inline content); the long input never /// loop on a long input (file or inline content); the long input never
/// enters the calling model's context window. The Python REPL exposes /// enters the calling model's context window. The Python REPL exposes
@@ -458,7 +468,8 @@ impl ToolRegistryBuilder {
.with_diagnostics_tool() .with_diagnostics_tool()
.with_project_tools() .with_project_tools()
.with_test_runner_tool() .with_test_runner_tool()
.with_validation_tools(); .with_validation_tools()
.with_revert_turn_tool();
if allow_shell { if allow_shell {
builder.with_shell_tools() builder.with_shell_tools()
+205
View File
@@ -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<ToolCapability> {
vec![
ToolCapability::WritesFiles,
ToolCapability::RequiresApproval,
]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
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<String, String> {
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<std::ffi::OsString>,
_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"));
}
}
+1
View File
@@ -336,6 +336,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
network_policy: config.network.clone().map(|toml_cfg| { network_policy: config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
}), }),
snapshots_enabled: config.snapshots_config().enabled,
} }
} }
+2
View File
@@ -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` 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 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 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/<project_hash>/<worktree_hash>/.git`; `/restore N` and `revert_turn` restore file state without changing conversation history or the user's `.git`
### Tool Execution ### Tool Execution
@@ -249,5 +250,6 @@ command = "echo 'Running tool: $TOOL_NAME'"
- `~/.deepseek/skills/` - User skills directory - `~/.deepseek/skills/` - User skills directory
- `~/.deepseek/sessions/` - Session history - `~/.deepseek/sessions/` - Session history
- `~/.deepseek/sessions/checkpoints/` - Crash checkpoint + offline queue persistence - `~/.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/tasks/` - Background task records, queue, timelines, artifacts
- `~/.deepseek/audit.log` - Append-only audit events for credential + approval/elevation actions - `~/.deepseek/audit.log` - Append-only audit events for credential + approval/elevation actions
+4
View File
@@ -170,6 +170,10 @@ If you are upgrading from older releases:
- `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`. - `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. - `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`. - `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/<project_hash>/<worktree_hash>/.git` and never use the workspace's own `.git` directory
- `retry.*` (optional): retry/backoff settings for API requests: - `retry.*` (optional): retry/backoff settings for API requests:
- `[retry].enabled` (bool, default `true`) - `[retry].enabled` (bool, default `true`)
- `[retry].max_retries` (int, default `3`) - `[retry].max_retries` (int, default `3`)