Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1022 lines
43 KiB
Rust
1022 lines
43 KiB
Rust
//! Turn-boundary fan-out for the workspace.
|
|
//!
|
|
//! [`WorkspaceHandle::on_turn_boundary`] is the single internal entry point for
|
|
//! turn/prompt boundaries.
|
|
//!
|
|
//! A rewind checkpoint is keyed by `prompt_index` and bundles per-domain state
|
|
//! (filesystem [`RewindPoint`], optional hunk delta, optional git HEAD/index);
|
|
//! restore reverts all enabled domains together.
|
|
use crate::handle::WorkspaceHandle;
|
|
use crate::session::WorkspaceSession;
|
|
use crate::session::file_state::{FileRewindResponse, RewindPoint, rewind_files};
|
|
use crate::session::git;
|
|
use kigi_hunk_tracker::{HunkId, HunkTrackerSnapshot, HunkTurnDelta};
|
|
use kigi_tool_protocol::turn_hook::TurnHookOutcome;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{HashMap, HashSet};
|
|
/// A turn/prompt boundary routed through [`WorkspaceHandle::on_turn_boundary`].
|
|
///
|
|
/// `prompt_index` selects the origin and keeps the two effect sets disjoint:
|
|
/// - `None` — a turn hook (`on_before_turn`/`on_after_turn`).
|
|
/// - `Some(idx)` — a rewind RPC arm (`begin_prompt`/`end_prompt`).
|
|
pub(crate) enum TurnBoundary {
|
|
/// Turn start.
|
|
Start {
|
|
prompt_index: Option<usize>,
|
|
turn_number: u64,
|
|
},
|
|
/// Turn end.
|
|
End {
|
|
prompt_index: Option<usize>,
|
|
turn_number: u64,
|
|
duration_ms: u64,
|
|
outcome: TurnHookOutcome,
|
|
},
|
|
}
|
|
impl TurnBoundary {
|
|
pub(crate) fn turn_start(turn_number: u64) -> Self {
|
|
Self::Start {
|
|
prompt_index: None,
|
|
turn_number,
|
|
}
|
|
}
|
|
/// Turn-hook end (from `on_after_turn`): activity bookkeeping (+ optional
|
|
/// rewind finalize under `workspace_rewind_all_outcomes`).
|
|
pub(crate) fn turn_end(turn_number: u64, duration_ms: u64, outcome: TurnHookOutcome) -> Self {
|
|
Self::End {
|
|
prompt_index: None,
|
|
turn_number,
|
|
duration_ms,
|
|
outcome,
|
|
}
|
|
}
|
|
/// Rewind begin (from the `begin_prompt` RPC arm): FS-rewind only.
|
|
///
|
|
/// The RPC carries no turn metadata; `turn ≈ prompt`, so `turn_number`
|
|
/// mirrors `prompt_index` and the dispatcher ignores it on this path.
|
|
pub(crate) fn rewind_begin(prompt_index: usize) -> Self {
|
|
Self::Start {
|
|
prompt_index: Some(prompt_index),
|
|
turn_number: prompt_index as u64,
|
|
}
|
|
}
|
|
/// Rewind finalize (from the `end_prompt` RPC arm): FS-rewind only.
|
|
///
|
|
/// As with [`Self::rewind_begin`], the turn-hook fields are inert on this
|
|
/// path and are never read by the dispatcher.
|
|
pub(crate) fn rewind_finalize(prompt_index: usize) -> Self {
|
|
Self::End {
|
|
prompt_index: Some(prompt_index),
|
|
turn_number: prompt_index as u64,
|
|
duration_ms: 0,
|
|
outcome: TurnHookOutcome::Completed,
|
|
}
|
|
}
|
|
}
|
|
/// A per-prompt rewind checkpoint: the FS rewind point bundled with the
|
|
/// incremental hunk-tracker delta for the same `prompt_index`. Assembled on
|
|
/// demand by [`WorkspaceSession::get_checkpoint`]; serialized to disk by the
|
|
/// [`CheckpointStore`](crate::session::checkpoint_store::CheckpointStore).
|
|
///
|
|
/// Optional domain fields use `#[serde(default)]` so the schema stays additive:
|
|
/// a blob written before a later field existed still deserializes (field `None`).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RewindCheckpoint {
|
|
/// The prompt this checkpoint belongs to.
|
|
pub prompt_index: usize,
|
|
/// Filesystem before/after snapshots for the prompt.
|
|
pub fs: RewindPoint,
|
|
/// Incremental hunk delta. `None` when capture was off or the turn touched
|
|
/// no tracked files.
|
|
#[serde(default)]
|
|
pub hunks: Option<HunkTurnDelta>,
|
|
}
|
|
/// Resolve `workspace_rewind_hunks` from `KIGI_WORKSPACE_REWIND_HUNKS` (default off).
|
|
pub(crate) fn rewind_hunks_enabled() -> bool {
|
|
kigi_config::env_bool("KIGI_WORKSPACE_REWIND_HUNKS").unwrap_or(false)
|
|
}
|
|
/// Resolve `workspace_rewind_durable` from `KIGI_WORKSPACE_REWIND_DURABLE`
|
|
/// (default off). Off ⇒ the legacy in-memory-only path with no disk I/O.
|
|
pub(crate) fn rewind_durable_enabled() -> bool {
|
|
kigi_config::env_bool("KIGI_WORKSPACE_REWIND_DURABLE").unwrap_or(false)
|
|
}
|
|
impl WorkspaceSession {
|
|
/// Capture the hunk delta for `prompt_index` into the in-memory store.
|
|
/// Last-write-wins, so repeated finalizes (e.g. `ForceContinue`) are
|
|
/// idempotent. Empty deltas are skipped to avoid a stale `turn_index` entry.
|
|
/// Returns `true` when a delta was stored, so callers count a hunk capture.
|
|
pub(crate) async fn capture_hunk_delta(&self, prompt_index: usize) -> bool {
|
|
let Some(delta) = self.hunk_tracker.snapshot_turn_delta(prompt_index).await else {
|
|
return false;
|
|
};
|
|
if delta.file_states.is_empty() {
|
|
return false;
|
|
}
|
|
self.hunk_checkpoints
|
|
.lock()
|
|
.await
|
|
.insert(prompt_index, delta);
|
|
true
|
|
}
|
|
/// Record the metrics shared by both finalize paths: FS capture + finalize
|
|
/// counters and FS capture duration, plus the git-domain capture when git
|
|
/// state was recorded at begin. Git is captured at begin (outcome unknown),
|
|
/// so it's *counted* here where `outcome` is known. Hunk is recorded by the
|
|
/// caller on whichever finalize path captured it.
|
|
async fn record_finalize_metrics(
|
|
&self,
|
|
prompt_index: usize,
|
|
outcome: TurnHookOutcome,
|
|
fs_capture_seconds: f64,
|
|
) {
|
|
crate::handle::record_fs_finalize(outcome, fs_capture_seconds);
|
|
if git::git_rewind_enabled() && self.git_checkpoints().contains(prompt_index).await {
|
|
crate::handle::record_rewind_capture(crate::handle::RewindDomain::Git, outcome);
|
|
}
|
|
}
|
|
/// Re-seed the hunk tracker to the **start** of `target_prompt_index` by
|
|
/// composing stored deltas for prompts `< target` (ascending; last write per
|
|
/// path wins) and dropping deltas `>= target`, mirroring the FS rewind.
|
|
///
|
|
/// No-op when the store is empty, or holds only deltas `>= target` with
|
|
/// `target > 0` (flag enabled mid-session; start-of-target can't be rebuilt,
|
|
/// so uncaptured live hunks aren't wiped). `target == 0` re-seeds to empty.
|
|
/// Composed `turn_index` ids are pruned to those surviving in the snapshots.
|
|
pub(crate) async fn restore_hunk_checkpoints(&self, target_prompt_index: usize) {
|
|
let mut store = self.hunk_checkpoints.lock().await;
|
|
if store.is_empty() {
|
|
return;
|
|
}
|
|
let mut prompts: Vec<usize> = store
|
|
.keys()
|
|
.copied()
|
|
.filter(|&idx| idx < target_prompt_index)
|
|
.collect();
|
|
if prompts.is_empty() && target_prompt_index > 0 {
|
|
return;
|
|
}
|
|
prompts.sort_unstable();
|
|
let mut file_states: HashMap<std::path::PathBuf, kigi_hunk_tracker::FileHunkStateSnapshot> =
|
|
HashMap::new();
|
|
let mut turn_index: HashMap<usize, HashSet<HunkId>> = HashMap::new();
|
|
for idx in prompts {
|
|
let delta = &store[&idx];
|
|
for (path, snap) in &delta.file_states {
|
|
file_states.insert(path.clone(), snap.clone());
|
|
}
|
|
turn_index.insert(idx, delta.hunk_ids.clone());
|
|
}
|
|
let present: HashSet<HunkId> = file_states
|
|
.values()
|
|
.flat_map(|state| state.hunks.iter().map(|h| h.id.clone()))
|
|
.collect();
|
|
turn_index.retain(|_, ids| {
|
|
ids.retain(|id| present.contains(id));
|
|
!ids.is_empty()
|
|
});
|
|
let session_stats = self.hunk_tracker.get_session_summary().await.stats;
|
|
self.hunk_tracker.restore_state(HunkTrackerSnapshot {
|
|
file_states,
|
|
turn_index,
|
|
session_stats,
|
|
});
|
|
store.retain(|&idx, _| idx < target_prompt_index);
|
|
}
|
|
/// Assemble the [`RewindCheckpoint`] for `prompt_index` from the live FS
|
|
/// rewind point and stored hunk delta. `None` when no FS rewind point exists.
|
|
pub async fn get_checkpoint(&self, prompt_index: usize) -> Option<RewindCheckpoint> {
|
|
let fs = self
|
|
.file_state_tracker
|
|
.get_rewind_point(prompt_index)
|
|
.await?;
|
|
let hunks = self
|
|
.hunk_checkpoints
|
|
.lock()
|
|
.await
|
|
.get(&prompt_index)
|
|
.cloned();
|
|
Some(RewindCheckpoint {
|
|
prompt_index,
|
|
fs,
|
|
hunks,
|
|
})
|
|
}
|
|
/// Assemble the checkpoint for `prompt_index` and write it through the durable
|
|
/// [`CheckpointStore`](crate::session::checkpoint_store::CheckpointStore).
|
|
/// Last-write-wins (idempotent across repeated finalizes); no-op when no
|
|
/// checkpoint exists. Mirror only — restore stays in-process.
|
|
pub(crate) async fn persist_checkpoint(&self, prompt_index: usize) {
|
|
let Some(checkpoint) = self.get_checkpoint(prompt_index).await else {
|
|
return;
|
|
};
|
|
self.checkpoint_store.persist(checkpoint).await;
|
|
}
|
|
/// Drop persisted checkpoints `>= target_prompt_index` (cache + disk),
|
|
/// mirroring the FS/hunk truncation so the on-disk mirror stays consistent.
|
|
pub(crate) async fn truncate_checkpoint_store(&self, target_prompt_index: usize) {
|
|
self.checkpoint_store
|
|
.truncate_from(target_prompt_index)
|
|
.await;
|
|
}
|
|
}
|
|
impl WorkspaceHandle {
|
|
/// Single fan-out for turn/prompt boundaries.
|
|
///
|
|
/// Keyed on `prompt_index`: turn hooks (`None`) drive activity; rewind RPC
|
|
/// arms (`Some`) drive rewind capture (FS, plus git/hunks when their flags
|
|
/// are on). `workspace_rewind_all_outcomes` also finalizes the open FS
|
|
/// checkpoint on non-`Completed` turn-ends (gap #2).
|
|
pub(crate) async fn on_turn_boundary(&self, session_id: &str, boundary: TurnBoundary) {
|
|
match boundary {
|
|
TurnBoundary::Start {
|
|
prompt_index: Some(idx),
|
|
..
|
|
} => {
|
|
if let Some(session) = self.session(session_id) {
|
|
session.file_state_tracker().begin_prompt(idx).await;
|
|
if git::git_rewind_enabled() {
|
|
let started = std::time::Instant::now();
|
|
if let Some(state) = git::capture_git_state(session.cwd()).await {
|
|
session.git_checkpoints().record(idx, state).await;
|
|
crate::handle::observe_rewind_capture_duration(
|
|
crate::handle::RewindDomain::Git,
|
|
started.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TurnBoundary::Start {
|
|
prompt_index: None,
|
|
turn_number,
|
|
} => {
|
|
self.shared
|
|
.activity_tracker
|
|
.turn_started(session_id, turn_number);
|
|
}
|
|
TurnBoundary::End {
|
|
prompt_index: Some(idx),
|
|
outcome,
|
|
..
|
|
} => {
|
|
if let Some(session) = self.session(session_id) {
|
|
let tracker = session.file_state_tracker();
|
|
let finalized = tracker.current_prompt_index().await == Some(idx);
|
|
let fs_started = std::time::Instant::now();
|
|
tracker.end_prompt(session.async_fs(), idx).await;
|
|
let fs_elapsed = fs_started.elapsed().as_secs_f64();
|
|
let mut hunk_captured = false;
|
|
let mut hunk_capture_secs = 0.0_f64;
|
|
if rewind_hunks_enabled() {
|
|
let hunk_started = std::time::Instant::now();
|
|
hunk_captured = session.capture_hunk_delta(idx).await;
|
|
hunk_capture_secs = hunk_started.elapsed().as_secs_f64();
|
|
}
|
|
if rewind_durable_enabled() {
|
|
session.persist_checkpoint(idx).await;
|
|
}
|
|
if finalized {
|
|
session
|
|
.record_finalize_metrics(idx, outcome, fs_elapsed)
|
|
.await;
|
|
if hunk_captured {
|
|
crate::handle::observe_rewind_capture_duration(
|
|
crate::handle::RewindDomain::Hunk,
|
|
hunk_capture_secs,
|
|
);
|
|
crate::handle::record_rewind_capture(
|
|
crate::handle::RewindDomain::Hunk,
|
|
outcome,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TurnBoundary::End {
|
|
prompt_index: None,
|
|
turn_number,
|
|
duration_ms,
|
|
outcome,
|
|
} => {
|
|
self.shared
|
|
.activity_tracker
|
|
.turn_completed(session_id, turn_number, duration_ms);
|
|
if self.shared.workspace_rewind_all_outcomes
|
|
&& outcome != TurnHookOutcome::Completed
|
|
&& let Some(session) = self.session(session_id)
|
|
{
|
|
let tracker = session.file_state_tracker();
|
|
if let Some(idx) = tracker.current_prompt_index().await {
|
|
let fs_started = std::time::Instant::now();
|
|
tracker.end_prompt(session.async_fs(), idx).await;
|
|
let fs_elapsed = fs_started.elapsed().as_secs_f64();
|
|
let mut hunk_captured = false;
|
|
let mut hunk_capture_secs = 0.0_f64;
|
|
if rewind_hunks_enabled() {
|
|
let hunk_started = std::time::Instant::now();
|
|
hunk_captured = session.capture_hunk_delta(idx).await;
|
|
hunk_capture_secs = hunk_started.elapsed().as_secs_f64();
|
|
}
|
|
if rewind_durable_enabled() {
|
|
session.persist_checkpoint(idx).await;
|
|
}
|
|
session
|
|
.record_finalize_metrics(idx, outcome, fs_elapsed)
|
|
.await;
|
|
if hunk_captured {
|
|
crate::handle::observe_rewind_capture_duration(
|
|
crate::handle::RewindDomain::Hunk,
|
|
hunk_capture_secs,
|
|
);
|
|
crate::handle::record_rewind_capture(
|
|
crate::handle::RewindDomain::Hunk,
|
|
outcome,
|
|
);
|
|
}
|
|
crate::handle::record_non_completed_finalize_canary(outcome);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// User-initiated restore (`workspace.rewind_to` RPC): restore every enabled
|
|
/// domain to before `target_prompt_index`. Ordering matters: git's soft
|
|
/// restore (stash + `reset --soft` + unstage, behind `workspace_rewind_git`)
|
|
/// runs first so its stash-or-abort guard sees live state; then the FS is
|
|
/// reverted; then — only on FS success — git paths are re-staged against the
|
|
/// reverted tree and git/hunk checkpoints `>= target` are dropped. A failed
|
|
/// `rewind_files` keeps all domains for retry. Missing session yields a failed
|
|
/// response, not a panic.
|
|
pub(crate) async fn rewind_to(
|
|
&self,
|
|
session_id: &str,
|
|
target_prompt_index: usize,
|
|
) -> FileRewindResponse {
|
|
let Some(session) = self.session(session_id) else {
|
|
return FileRewindResponse {
|
|
success: false,
|
|
target_prompt_index,
|
|
reverted_files: Vec::new(),
|
|
clean_files: Vec::new(),
|
|
conflicts: Vec::new(),
|
|
error: Some(format!("workspace session not found: {session_id}")),
|
|
};
|
|
};
|
|
let git_restore = self
|
|
.restore_git_checkpoint(session_id, target_prompt_index)
|
|
.await;
|
|
if let Some((git_outcome, _)) = git_restore.as_ref() {
|
|
if !git_outcome.restored {
|
|
crate::handle::record_rewind_restore(crate::handle::RewindDomain::Git, false);
|
|
tracing::warn!(
|
|
session_id, target_prompt_index, reason = ? git_outcome
|
|
.aborted_reason, stash_ref = ? git_outcome.stash_ref,
|
|
"rewind_to: git domain not restored; filesystem still reverted (partial rewind)"
|
|
);
|
|
} else if let Some(stash_ref) = &git_outcome.stash_ref {
|
|
tracing::info!(
|
|
session_id, stash_ref = % stash_ref,
|
|
"rewind_to: git domain restored; pre-rewind changes saved to a stash"
|
|
);
|
|
}
|
|
}
|
|
let response = rewind_files(
|
|
session.file_state_tracker(),
|
|
session.async_fs(),
|
|
target_prompt_index,
|
|
)
|
|
.await;
|
|
crate::handle::record_rewind_restore(crate::handle::RewindDomain::Fs, response.success);
|
|
if response.success {
|
|
if let Some((git_outcome, git_state)) = git_restore.as_ref()
|
|
&& git_outcome.restored
|
|
{
|
|
let restaged = git::restage_git_paths(session.cwd(), git_state, session_id).await;
|
|
crate::handle::record_rewind_restore(
|
|
crate::handle::RewindDomain::Git,
|
|
git_outcome.index_reset && restaged,
|
|
);
|
|
session
|
|
.git_checkpoints()
|
|
.truncate_from(target_prompt_index)
|
|
.await;
|
|
}
|
|
if git::git_rewind_enabled() && git_restore.is_none() {
|
|
tracing::warn!(
|
|
session_id,
|
|
target_prompt_index,
|
|
"rewind_to: git domain not rewound (no git checkpoint at or before \
|
|
target); HEAD left untouched and may not match the reverted tree \
|
|
(partial rewind); git checkpoints retained to stay coherent with HEAD"
|
|
);
|
|
}
|
|
if rewind_hunks_enabled() {
|
|
session.restore_hunk_checkpoints(target_prompt_index).await;
|
|
crate::handle::record_rewind_restore(crate::handle::RewindDomain::Hunk, true);
|
|
}
|
|
}
|
|
if response.success && rewind_durable_enabled() {
|
|
session.truncate_checkpoint_store(target_prompt_index).await;
|
|
}
|
|
response
|
|
}
|
|
/// Soft-restore the git domain at `target_prompt_index` (phase 1 only: stash +
|
|
/// `reset --soft` + unstage; turn-local commits preserved). Returns the
|
|
/// [`GitRestoreOutcome`](git::GitRestoreOutcome) and captured
|
|
/// [`GitStateRef`](git::GitStateRef) so the caller can, after the FS revert
|
|
/// succeeds, re-stage paths and drop checkpoints `>= target` — deferred so a
|
|
/// failed FS revert retains them for retry.
|
|
///
|
|
/// Checkpoint selection prefers the state captured exactly at the target, but
|
|
/// falls back to the nearest earlier checkpoint (greatest index `<= target`)
|
|
/// when none was captured at the target itself — e.g. git-rewind was enabled
|
|
/// mid-session so the target predates capture, or capture was skipped for that
|
|
/// prompt. This lands HEAD on the closest known-good git state instead of
|
|
/// leaving it post-turn. `None` when disabled, no session, or no git state
|
|
/// captured at or before the target (caller then degrades explicitly).
|
|
pub(crate) async fn restore_git_checkpoint(
|
|
&self,
|
|
session_id: &str,
|
|
target_prompt_index: usize,
|
|
) -> Option<(git::GitRestoreOutcome, git::GitStateRef)> {
|
|
if !git::git_rewind_enabled() {
|
|
return None;
|
|
}
|
|
let session = self.session(session_id)?;
|
|
let store = session.git_checkpoints();
|
|
let (checkpoint_index, state) = match store.get_at_or_before(target_prompt_index).await {
|
|
Some(found) => found,
|
|
None => {
|
|
tracing::debug!(
|
|
session_id,
|
|
target_prompt_index,
|
|
"restore_git_checkpoint: no git state captured at or before target; skipping"
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
if checkpoint_index != target_prompt_index {
|
|
tracing::info!(
|
|
session_id,
|
|
target_prompt_index,
|
|
checkpoint_index,
|
|
"restore_git_checkpoint: no checkpoint at target; soft-restoring to the \
|
|
nearest earlier git checkpoint (closest known-good HEAD)"
|
|
);
|
|
}
|
|
let outcome = git::soft_restore_git_state(session.cwd(), &state, session_id).await;
|
|
Some((outcome, state))
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::handle::tests::{make_handle, make_handle_with_rewind_all_outcomes};
|
|
#[tokio::test]
|
|
async fn start_with_prompt_index_begins_and_end_finalizes_fs_rewind() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::rewind_begin(3))
|
|
.await;
|
|
let tracker = session.file_state_tracker();
|
|
assert_eq!(tracker.current_prompt_index().await, Some(3));
|
|
assert!(
|
|
tracker.get_rewind_point(3).await.is_some(),
|
|
"begin should create a rewind point for the prompt"
|
|
);
|
|
tracker
|
|
.add_before_snapshot_for_prompt(3, &cwd.join("a.txt"), &cwd, Some("v1".to_owned()))
|
|
.await;
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::rewind_finalize(3))
|
|
.await;
|
|
assert_eq!(
|
|
tracker.current_prompt_index().await,
|
|
None,
|
|
"finalize (end_prompt) clears the current prompt index"
|
|
);
|
|
let point = tracker
|
|
.get_rewind_point(3)
|
|
.await
|
|
.expect("rewind point still present after finalize");
|
|
assert!(
|
|
!point.after_snapshots.is_empty(),
|
|
"finalize should capture after-snapshots for touched files"
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn turn_hook_start_does_not_touch_fs_rewind() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::turn_start(7))
|
|
.await;
|
|
let tracker = session.file_state_tracker();
|
|
assert!(
|
|
tracker.get_rewind_points().await.is_empty(),
|
|
"turn-hook boundary must not create rewind points"
|
|
);
|
|
assert_eq!(tracker.current_prompt_index().await, None);
|
|
}
|
|
/// Open a checkpoint and record one touched file so finalize has an after-snapshot.
|
|
async fn open_checkpoint_with_touched_file(handle: &WorkspaceHandle, prompt_index: usize) {
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::rewind_begin(prompt_index))
|
|
.await;
|
|
session
|
|
.file_state_tracker()
|
|
.add_before_snapshot_for_prompt(
|
|
prompt_index,
|
|
&cwd.join("a.txt"),
|
|
&cwd,
|
|
Some("v1".to_owned()),
|
|
)
|
|
.await;
|
|
}
|
|
#[tokio::test]
|
|
async fn non_completed_turn_end_finalizes_open_fs_rewind_when_flag_on() {
|
|
let handle = make_handle_with_rewind_all_outcomes(true);
|
|
open_checkpoint_with_touched_file(&handle, 5).await;
|
|
let session = handle.session("main").expect("main session exists");
|
|
let tracker = session.file_state_tracker();
|
|
assert_eq!(tracker.current_prompt_index().await, Some(5));
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::turn_end(5, 0, TurnHookOutcome::Error))
|
|
.await;
|
|
assert_eq!(
|
|
tracker.current_prompt_index().await,
|
|
None,
|
|
"non-Completed turn-end should finalize (clear) the open prompt"
|
|
);
|
|
let point = tracker
|
|
.get_rewind_point(5)
|
|
.await
|
|
.expect("rewind point present");
|
|
assert!(
|
|
!point.after_snapshots.is_empty(),
|
|
"finalize should capture after-snapshots for touched files"
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn non_completed_turn_end_does_not_finalize_when_flag_off() {
|
|
let handle = make_handle();
|
|
open_checkpoint_with_touched_file(&handle, 5).await;
|
|
let session = handle.session("main").expect("main session exists");
|
|
let tracker = session.file_state_tracker();
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::turn_end(5, 0, TurnHookOutcome::Error))
|
|
.await;
|
|
assert_eq!(
|
|
tracker.current_prompt_index().await,
|
|
Some(5),
|
|
"with the flag off the turn-hook path must leave the checkpoint open"
|
|
);
|
|
let point = tracker
|
|
.get_rewind_point(5)
|
|
.await
|
|
.expect("rewind point present");
|
|
assert!(
|
|
point.after_snapshots.is_empty(),
|
|
"no after-snapshots should be captured when the flag is off"
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn completed_turn_end_hook_does_not_finalize_even_when_flag_on() {
|
|
let handle = make_handle_with_rewind_all_outcomes(true);
|
|
open_checkpoint_with_touched_file(&handle, 5).await;
|
|
let session = handle.session("main").expect("main session exists");
|
|
let tracker = session.file_state_tracker();
|
|
handle
|
|
.on_turn_boundary(
|
|
"main",
|
|
TurnBoundary::turn_end(5, 0, TurnHookOutcome::Completed),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
tracker.current_prompt_index().await,
|
|
Some(5),
|
|
"Completed turn-ends are finalized by the RPC path, not the hook path"
|
|
);
|
|
}
|
|
/// The non-Completed canary advances when a non-`Completed` turn-end finalizes
|
|
/// an open FS checkpoint (flag on). Counters are monotonic, so `after > before` is robust.
|
|
#[tokio::test]
|
|
async fn canary_counts_non_completed_finalize() {
|
|
let label = crate::handle::rewind_outcome_label(TurnHookOutcome::Error);
|
|
let before = crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL
|
|
.with_label_values(&[label])
|
|
.get();
|
|
let handle = make_handle_with_rewind_all_outcomes(true);
|
|
open_checkpoint_with_touched_file(&handle, 11).await;
|
|
handle
|
|
.on_turn_boundary(
|
|
"main",
|
|
TurnBoundary::turn_end(11, 0, TurnHookOutcome::Error),
|
|
)
|
|
.await;
|
|
let after = crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL
|
|
.with_label_values(&[label])
|
|
.get();
|
|
assert!(
|
|
after > before,
|
|
"canary must advance on a non-Completed finalize (before={before}, after={after})"
|
|
);
|
|
}
|
|
/// A `Completed` turn-end never feeds the canary: the `completed` label must
|
|
/// stay zero, guarding the `outcome != Completed` gate.
|
|
#[tokio::test]
|
|
async fn canary_never_labeled_completed() {
|
|
let handle = make_handle_with_rewind_all_outcomes(true);
|
|
open_checkpoint_with_touched_file(&handle, 12).await;
|
|
handle
|
|
.on_turn_boundary(
|
|
"main",
|
|
TurnBoundary::turn_end(12, 0, TurnHookOutcome::Completed),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL
|
|
.with_label_values(&[crate::handle::rewind_outcome_label(
|
|
TurnHookOutcome::Completed
|
|
)])
|
|
.get(),
|
|
0,
|
|
"Completed turns must never increment the non-Completed finalize canary"
|
|
);
|
|
}
|
|
/// Capture two turns, rewind: earlier turn survives; target-and-later are
|
|
/// dropped and truncated.
|
|
#[tokio::test]
|
|
async fn capture_then_restore_round_trips_turn_delta() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
let hunks = session.hunk_tracker();
|
|
hunks.record_agent_write(cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None);
|
|
hunks.record_agent_write(cwd.join("b.rs"), "fn b() {}\n".to_owned(), 1, None);
|
|
session.capture_hunk_delta(0).await;
|
|
session.capture_hunk_delta(1).await;
|
|
assert!(!hunks.get_turn_hunks(0).await.is_empty());
|
|
assert!(!hunks.get_turn_hunks(1).await.is_empty());
|
|
session.restore_hunk_checkpoints(1).await;
|
|
assert!(
|
|
hunks.get_turn_hunks(1).await.is_empty(),
|
|
"rewind must drop the rewound turn's hunks"
|
|
);
|
|
assert!(
|
|
!hunks.get_turn_hunks(0).await.is_empty(),
|
|
"rewind must keep turns before the target"
|
|
);
|
|
let tracked = hunks.get_all_tracked_paths().await;
|
|
assert!(
|
|
tracked.contains(&cwd.join("a.rs")),
|
|
"kept turn's file remains"
|
|
);
|
|
assert!(
|
|
!tracked.contains(&cwd.join("b.rs")),
|
|
"rewound turn's file is gone"
|
|
);
|
|
let store = session.hunk_checkpoints.lock().await;
|
|
assert!(store.contains_key(&0), "kept delta remains in the store");
|
|
assert!(!store.contains_key(&1), "rewound delta is dropped");
|
|
}
|
|
/// `get_checkpoint` assembles both domains: FS rewind point + stored hunk delta.
|
|
#[tokio::test]
|
|
async fn get_checkpoint_bundles_fs_point_and_hunk_delta() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
let tracker = session.file_state_tracker();
|
|
tracker.begin_prompt(0).await;
|
|
tracker
|
|
.add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned()))
|
|
.await;
|
|
session.hunk_tracker().record_agent_write(
|
|
cwd.join("a.rs"),
|
|
"fn a() {}\n".to_owned(),
|
|
0,
|
|
None,
|
|
);
|
|
session.capture_hunk_delta(0).await;
|
|
let checkpoint = session.get_checkpoint(0).await.expect("checkpoint exists");
|
|
assert_eq!(checkpoint.prompt_index, 0);
|
|
assert!(
|
|
!checkpoint.fs.file_snapshots.is_empty(),
|
|
"fs side carries the before-snapshot"
|
|
);
|
|
let delta = checkpoint.hunks.expect("hunk delta captured");
|
|
assert!(delta.file_states.contains_key(&cwd.join("a.rs")));
|
|
assert!(session.get_checkpoint(5).await.is_none());
|
|
}
|
|
/// Flag off (default): finalize never captures a hunk delta.
|
|
#[tokio::test]
|
|
async fn turn_end_with_flag_off_skips_hunk_capture() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
session.hunk_tracker().record_agent_write(
|
|
cwd.join("a.rs"),
|
|
"fn a() {}\n".to_owned(),
|
|
0,
|
|
None,
|
|
);
|
|
assert!(
|
|
!rewind_hunks_enabled(),
|
|
"flag must default off for the legacy path"
|
|
);
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::rewind_finalize(0))
|
|
.await;
|
|
assert!(
|
|
session.hunk_checkpoints.lock().await.is_empty(),
|
|
"flag-off finalize must not store hunk deltas (legacy default)"
|
|
);
|
|
}
|
|
/// Mid-session enable with no deltas before a non-zero target: restore must
|
|
/// not wipe live hunks.
|
|
#[tokio::test]
|
|
async fn restore_with_no_deltas_before_target_does_not_wipe() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
let hunks = session.hunk_tracker();
|
|
hunks.record_agent_write(cwd.join("early.rs"), "fn e() {}\n".to_owned(), 1, None);
|
|
hunks.record_agent_write(cwd.join("late.rs"), "fn l() {}\n".to_owned(), 5, None);
|
|
session.capture_hunk_delta(5).await;
|
|
session.restore_hunk_checkpoints(3).await;
|
|
let tracked = hunks.get_all_tracked_paths().await;
|
|
assert!(
|
|
tracked.contains(&cwd.join("early.rs")),
|
|
"uncaptured live hunks must not be wiped when nothing is captured before the target"
|
|
);
|
|
assert!(
|
|
session.hunk_checkpoints.lock().await.contains_key(&5),
|
|
"the store is left untouched when restore cannot reconstruct"
|
|
);
|
|
}
|
|
/// Rewind to 0: re-seed to the empty start-of-session state.
|
|
#[tokio::test]
|
|
async fn restore_to_zero_clears_all_hunk_state() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
let hunks = session.hunk_tracker();
|
|
hunks.record_agent_write(cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None);
|
|
session.capture_hunk_delta(0).await;
|
|
assert!(!hunks.get_all_tracked_paths().await.is_empty());
|
|
session.restore_hunk_checkpoints(0).await;
|
|
assert!(
|
|
hunks.get_all_tracked_paths().await.is_empty(),
|
|
"rewind to 0 reconstructs the empty start-of-session hunk state"
|
|
);
|
|
assert!(
|
|
session.hunk_checkpoints.lock().await.is_empty(),
|
|
"rewind to 0 truncates the entire delta store"
|
|
);
|
|
}
|
|
/// `workspace_rewind_git` default (OFF): capture must not touch git.
|
|
#[tokio::test]
|
|
async fn git_capture_is_noop_when_flag_disabled_by_default() {
|
|
assert!(
|
|
!git::git_rewind_enabled(),
|
|
"workspace_rewind_git must default OFF"
|
|
);
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::rewind_begin(2))
|
|
.await;
|
|
assert!(
|
|
session.git_checkpoints().get(2).await.is_none(),
|
|
"git state must not be captured while workspace_rewind_git is disabled"
|
|
);
|
|
assert!(
|
|
session
|
|
.file_state_tracker()
|
|
.get_rewind_point(2)
|
|
.await
|
|
.is_some(),
|
|
"FS-rewind capture must be unaffected by the git flag"
|
|
);
|
|
}
|
|
/// A fully-populated checkpoint (FS + hunk delta) round-trips through JSON:
|
|
/// `Value` is stable across encode→decode→encode and both payloads survive.
|
|
/// `Hunk.selected` is `#[serde(skip)]` (transient), so it decodes to default.
|
|
#[tokio::test]
|
|
async fn rewind_checkpoint_round_trips_through_json() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
let tracker = session.file_state_tracker();
|
|
tracker.begin_prompt(0).await;
|
|
tracker
|
|
.add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned()))
|
|
.await;
|
|
tracker.end_prompt(session.async_fs(), 0).await;
|
|
session.hunk_tracker().record_agent_write(
|
|
cwd.join("a.rs"),
|
|
"fn a() {}\n".to_owned(),
|
|
0,
|
|
None,
|
|
);
|
|
session.capture_hunk_delta(0).await;
|
|
let original = session.get_checkpoint(0).await.expect("checkpoint exists");
|
|
assert!(
|
|
original.hunks.is_some(),
|
|
"precondition: hunk delta captured, so serde covers all populated domains"
|
|
);
|
|
let encoded = serde_json::to_value(&original).expect("serialize");
|
|
let decoded: RewindCheckpoint =
|
|
serde_json::from_value(encoded.clone()).expect("deserialize");
|
|
let re_encoded = serde_json::to_value(&decoded).expect("re-serialize");
|
|
assert_eq!(
|
|
encoded, re_encoded,
|
|
"RewindCheckpoint must round-trip through JSON unchanged"
|
|
);
|
|
assert_eq!(decoded.prompt_index, 0);
|
|
assert_eq!(
|
|
decoded.fs.file_snapshots.len(),
|
|
1,
|
|
"fs before-snapshot survives the round-trip"
|
|
);
|
|
let delta = decoded.hunks.expect("hunk delta survives round-trip");
|
|
assert_eq!(delta.prompt_index, 0);
|
|
let snapshot = delta
|
|
.file_states
|
|
.get(&cwd.join("a.rs"))
|
|
.expect("hunk delta's touched file survives");
|
|
let hunk = snapshot.hunks.first().expect("the file's hunk survives");
|
|
assert!(
|
|
hunk.new_text.contains("fn a()"),
|
|
"hunk new_text payload survives the round-trip, got {:?}",
|
|
hunk.new_text
|
|
);
|
|
assert!(!hunk.selected, "transient `selected` is not persisted");
|
|
}
|
|
/// Optional domain fields are additive: a checkpoint round-trips with
|
|
/// `hunks: None`, and JSON omitting the optional field still deserializes.
|
|
#[tokio::test]
|
|
async fn checkpoint_serde_tolerates_missing_optional_fields() {
|
|
let cp = RewindCheckpoint {
|
|
prompt_index: 2,
|
|
fs: RewindPoint::new(2),
|
|
hunks: None,
|
|
};
|
|
let encoded = serde_json::to_value(&cp).expect("serialize");
|
|
let decoded: RewindCheckpoint = serde_json::from_value(encoded).expect("deserialize");
|
|
assert_eq!(decoded.prompt_index, 2);
|
|
assert!(decoded.hunks.is_none());
|
|
let json = r#"{
|
|
"prompt_index": 2,
|
|
"fs": {
|
|
"prompt_index": 2,
|
|
"created_at": "2024-01-01T00:00:00Z",
|
|
"file_snapshots": {},
|
|
"after_snapshots": {}
|
|
}
|
|
}"#;
|
|
let from_legacy: RewindCheckpoint = serde_json::from_str(json).expect("deserialize legacy");
|
|
assert_eq!(from_legacy.prompt_index, 2);
|
|
assert!(
|
|
from_legacy.hunks.is_none(),
|
|
"a missing optional field defaults to None"
|
|
);
|
|
}
|
|
/// Durable flag off (default): finalize does no disk I/O — the on-disk store
|
|
/// directory is never created.
|
|
#[tokio::test]
|
|
async fn turn_end_with_durable_flag_off_writes_nothing_to_disk() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
assert!(
|
|
!rewind_durable_enabled(),
|
|
"durable flag must default off for the legacy path"
|
|
);
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::rewind_begin(0))
|
|
.await;
|
|
session
|
|
.file_state_tracker()
|
|
.add_before_snapshot_for_prompt(0, &cwd.join("a.txt"), &cwd, Some("v".to_owned()))
|
|
.await;
|
|
handle
|
|
.on_turn_boundary("main", TurnBoundary::rewind_finalize(0))
|
|
.await;
|
|
let store_root = cwd.join(".kigi").join("rewind-checkpoints");
|
|
assert!(
|
|
!store_root.exists(),
|
|
"flag-off finalize must not touch disk (legacy default)"
|
|
);
|
|
}
|
|
/// `persist_checkpoint` writes the assembled checkpoint through the session's
|
|
/// durable store (called directly to bypass the flag); retrievable with both domains.
|
|
#[tokio::test]
|
|
async fn persist_checkpoint_serializes_through_session_store() {
|
|
let handle = make_handle();
|
|
let session = handle.session("main").expect("main session exists");
|
|
let cwd = session.cwd().to_path_buf();
|
|
let tracker = session.file_state_tracker();
|
|
tracker.begin_prompt(0).await;
|
|
tracker
|
|
.add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned()))
|
|
.await;
|
|
tracker.end_prompt(session.async_fs(), 0).await;
|
|
session.hunk_tracker().record_agent_write(
|
|
cwd.join("a.rs"),
|
|
"fn a() {}\n".to_owned(),
|
|
0,
|
|
None,
|
|
);
|
|
session.capture_hunk_delta(0).await;
|
|
session.persist_checkpoint(0).await;
|
|
let store_root = cwd.join(".kigi").join("rewind-checkpoints");
|
|
assert!(store_root.exists(), "persist creates the durable store dir");
|
|
let stored = session
|
|
.checkpoint_store
|
|
.get(0)
|
|
.await
|
|
.expect("checkpoint persisted");
|
|
assert_eq!(stored.prompt_index, 0);
|
|
assert!(
|
|
stored.hunks.is_some(),
|
|
"hunk delta is bundled into the blob"
|
|
);
|
|
assert_eq!(stored.fs.file_snapshots.len(), 1);
|
|
}
|
|
/// Full rewind lifecycle against a disabled (`noop()`) hunk tracker — the
|
|
/// operating mode when the user sets `hunk_tracker_mode = off`. Capture
|
|
/// must report "nothing stored" and restore must be a clean no-op, while
|
|
/// the FS-rewind half keeps working (hunks simply absent from the blob).
|
|
#[tokio::test]
|
|
async fn rewind_lifecycle_is_clean_noop_with_disabled_hunk_tracker() {
|
|
let handle = make_handle();
|
|
handle.drop_session("main", "main").expect("drop main");
|
|
let session = handle
|
|
.create_session_with_tracker_and_viewer_ctx(
|
|
"main",
|
|
handle.root_cwd().unwrap(),
|
|
kigi_hunk_tracker::HunkTrackerHandle::noop(),
|
|
None,
|
|
crate::capability::CapabilityMode::All,
|
|
None,
|
|
false,
|
|
)
|
|
.expect("create session with noop tracker");
|
|
let cwd = session.cwd().to_path_buf();
|
|
let file = cwd.join("a.rs");
|
|
let tracker = session.file_state_tracker();
|
|
tracker.begin_prompt(0).await;
|
|
tracker
|
|
.add_before_snapshot_for_prompt(0, &file, &cwd, Some("v0\n".to_owned()))
|
|
.await;
|
|
session
|
|
.async_fs()
|
|
.write_file(&file, b"v1\n")
|
|
.await
|
|
.expect("write v1");
|
|
tracker.end_prompt(session.async_fs(), 0).await;
|
|
session
|
|
.hunk_tracker()
|
|
.record_agent_write(file.clone(), "v1\n".to_owned(), 0, None);
|
|
assert!(
|
|
!session.capture_hunk_delta(0).await,
|
|
"capture_hunk_delta must return false under a disabled hunk tracker"
|
|
);
|
|
session.restore_hunk_checkpoints(0).await;
|
|
session.persist_checkpoint(0).await;
|
|
let stored = session
|
|
.checkpoint_store
|
|
.get(0)
|
|
.await
|
|
.expect("FS checkpoint persisted even with the tracker disabled");
|
|
assert_eq!(stored.prompt_index, 0);
|
|
assert!(
|
|
stored.hunks.is_none(),
|
|
"no hunk delta is bundled when the tracker is disabled"
|
|
);
|
|
assert_eq!(stored.fs.file_snapshots.len(), 1);
|
|
let resp = handle.rewind_to("main", 0).await;
|
|
assert!(resp.success, "FS rewind must succeed: {:?}", resp.error);
|
|
assert!(
|
|
resp.reverted_files.iter().any(|f| f.ends_with("a.rs")),
|
|
"a.rs must be among the reverted files: {:?}",
|
|
resp.reverted_files
|
|
);
|
|
let restored = session
|
|
.async_fs()
|
|
.try_read_to_string(&file)
|
|
.await
|
|
.expect("read a.rs");
|
|
assert_eq!(
|
|
restored.as_deref(),
|
|
Some("v0\n"),
|
|
"file content must revert to its pre-prompt state"
|
|
);
|
|
}
|
|
}
|