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:
@@ -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
|
||||
|
||||
@@ -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:<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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -133,6 +133,10 @@ pub struct ConfigToml {
|
||||
/// `registry_url` when running `deepseek skill install`.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
@@ -151,6 +155,33 @@ pub struct SkillsToml {
|
||||
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`
|
||||
/// for documentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -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 <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
|
||||
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),
|
||||
|
||||
@@ -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:"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -436,6 +471,11 @@ pub struct Config {
|
||||
/// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <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,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
|
||||
|
||||
@@ -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<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 {
|
||||
/// Create a new tool call record
|
||||
pub fn new(id: String, name: String, input: serde_json::Value) -> Self {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -370,6 +370,22 @@ pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
|
||||
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],
|
||||
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user