fix(snapshot): harden side-git restore wiring

This commit is contained in:
Hunter Bown
2026-04-28 00:46:24 -05:00
parent 0781b7c203
commit 3bc54b0bc0
13 changed files with 314 additions and 57 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
- **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
+29
View File
@@ -128,10 +128,39 @@ pub struct ConfigToml {
/// to a permissive default that mirrors pre-v0.7.0 behavior.
#[serde(default)]
pub network: Option<NetworkPolicyToml>,
/// 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)]
pub extras: BTreeMap<String, toml::Value>,
}
#[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)]
+47
View File
@@ -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<StatusItem>` is preserved: items in the left
@@ -429,6 +464,11 @@ pub struct Config {
/// to a permissive default that mirrors pre-v0.7.0 behavior.
#[serde(default)]
pub network: Option<NetworkPolicyToml>,
/// Workspace side-git snapshots (#137). Defaults to enabled with 7-day
/// retention when the table is absent.
#[serde(default)]
pub snapshots: Option<SnapshotsConfig>,
}
/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live
@@ -875,6 +915,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 {
@@ -1384,6 +1430,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
features: merge_features(base.features, override_cfg.features),
notifications: override_cfg.notifications.or(base.notifications),
network: override_cfg.network.or(base.network),
snapshots: override_cfg.snapshots.or(base.snapshots),
}
}
+19 -13
View File
@@ -110,6 +110,8 @@ pub struct EngineConfig {
/// session-scoped approvals (`/network allow <host>`) persist for the
/// remainder of the run.
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 {
@@ -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,14 +1439,15 @@ 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. Spawn on
// the blocking pool so the agent loop never waits on the side-git
// commit; failure is non-fatal (the helper logs at WARN).
let pre_workspace = self.session.workspace.clone();
let pre_seq = self.turn_counter;
tokio::task::spawn_blocking(move || {
let _ = pre_turn_snapshot(&pre_workspace, pre_seq);
});
// 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
@@ -1667,11 +1671,13 @@ impl Engine {
// Post-turn snapshot. Same non-blocking, non-fatal contract as
// the pre-turn hook above.
let post_workspace = self.session.workspace.clone();
let post_seq = self.turn_counter;
tokio::task::spawn_blocking(move || {
let _ = post_turn_snapshot(&post_workspace, post_seq);
});
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
+5 -1
View File
@@ -2791,7 +2791,10 @@ async fn run_interactive(
// 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.
session_manager::prune_workspace_snapshots_at_boot(&workspace);
let snapshots = config.snapshots_config();
if snapshots.enabled {
session_manager::prune_workspace_snapshots(&workspace, snapshots.max_age());
}
tui::run_tui(
config,
@@ -2956,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);
+1
View File
@@ -1390,6 +1390,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);
-10
View File
@@ -370,16 +370,6 @@ pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
Ok(home.join(".deepseek").join("sessions"))
}
/// Boot-time hook: prune workspace snapshots older than the configured
/// retention window for `workspace`. Failure is logged but never fatal —
/// the session boot must not block on a flaky disk or missing `git`.
///
/// Called once per `run_interactive` invocation. The default retention
/// (7 days) is governed by `crate::snapshot::DEFAULT_MAX_AGE`.
pub fn prune_workspace_snapshots_at_boot(workspace: &Path) {
prune_workspace_snapshots(workspace, crate::snapshot::DEFAULT_MAX_AGE);
}
/// Prune snapshots older than `max_age` for `workspace`.
///
/// Always non-fatal. Returns silently — callers don't need the count
+8 -8
View File
@@ -5,8 +5,6 @@
//! project independently — `git worktree list` users won't get cross-talk
//! between feature branches.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};
@@ -72,13 +70,15 @@ fn strip_worktree_suffix(path: &Path) -> PathBuf {
path.to_path_buf()
}
/// Hex-encoded `DefaultHasher` digest. Sufficient for directory naming
/// (collision risk is negligible for the small set of paths we care
/// about, and we'd rather not pull in `sha2` for a 16-byte tag).
/// 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 hasher = DefaultHasher::new();
path.hash(&mut hasher);
format!("{:016x}", hasher.finish())
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)]
+195 -23
View File
@@ -12,8 +12,9 @@
//! repo, the command fails fast instead of falling back to "current
//! directory".
use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -110,10 +111,10 @@ impl SnapshotRepo {
/// Take a snapshot of the current working tree.
///
/// Internally: `git add -A` then `git commit --allow-empty -m <label>`.
/// `git add -A` honours the user's workspace `.gitignore` because we
/// keep the side repo's `core.excludesFile` empty and let git read
/// the workspace's own `.gitignore` files when staging.
/// 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> {
@@ -128,36 +129,58 @@ impl SnapshotRepo {
)));
}
// `--allow-empty` so back-to-back snapshots with no changes
// still produce a marker commit (otherwise `/restore N` indices
// would skip turns where nothing changed).
let commit = run_git(
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,
&[
"commit",
"--allow-empty",
"--no-verify",
"--no-gpg-sign",
"-m",
label,
],
&["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 failed: {}",
"git commit-tree failed: {}",
String::from_utf8_lossy(&commit.stderr).trim()
)));
}
let sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
let head = run_git(&self.git_dir, &self.work_tree, &["rev-parse", "HEAD"])?;
if !head.status.success() {
let update = run_git(
&self.git_dir,
&self.work_tree,
&["update-ref", "HEAD", &sha],
)?;
if !update.status.success() {
return Err(io_other(format!(
"git rev-parse HEAD failed: {}",
String::from_utf8_lossy(&head.stderr).trim()
"git update-ref HEAD failed: {}",
String::from_utf8_lossy(&update.stderr).trim()
)));
}
let sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
Ok(SnapshotId(sha))
}
@@ -167,6 +190,8 @@ impl SnapshotRepo {
/// 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,
@@ -178,9 +203,60 @@ impl SnapshotRepo {
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`
@@ -331,6 +407,21 @@ 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::*;
@@ -416,6 +507,87 @@ mod tests {
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();
+2 -2
View File
@@ -468,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()
@@ -505,7 +506,6 @@ impl ToolRegistryBuilder {
.with_review_tool(client.clone(), model.clone())
.with_rlm_tool(client, model)
.with_recall_archive_tool()
.with_revert_turn_tool()
.with_subagent_tools(manager, runtime)
}
+1
View File
@@ -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,
}
}
+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`
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/<project_hash>/<worktree_hash>/.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
+4
View File
@@ -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/<project_hash>/<worktree_hash>/.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`)