mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Remove ghost snapshots (#19481)
## Summary - Remove `ghost_snapshot` / `GhostCommit` from the Responses API surface and generated SDK/schema artifacts. - Keep legacy config loading compatible, but make undo a no-op that reports the feature is unavailable. - Clean up core history, compaction, telemetry, rollout, and tests to stop carrying ghost snapshot items. ## Testing - Unit tests passed for `codex-protocol`, `codex-core` targeted undo and compaction flows, `codex-rollout`, and `codex-app-server-protocol`. - Regenerated config and app-server schemas plus Python SDK artifacts and verified they match the checked-in outputs.
This commit is contained in:
committed by
GitHub
Unverified
parent
7e8594fc19
commit
4e05f3053c
@@ -114,7 +114,6 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other,
|
||||
) => false,
|
||||
|
||||
@@ -383,7 +383,6 @@ fn build_arc_monitor_message_item(
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => None,
|
||||
}
|
||||
|
||||
@@ -265,12 +265,6 @@ async fn run_compact_task_inner_impl(
|
||||
new_history =
|
||||
insert_initial_context_before_last_real_user_or_summary(new_history, initial_context);
|
||||
}
|
||||
let ghost_snapshots: Vec<ResponseItem> = history_items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
|
||||
.cloned()
|
||||
.collect();
|
||||
new_history.extend(ghost_snapshots);
|
||||
let reference_context_item = match initial_context_injection {
|
||||
InitialContextInjection::DoNotInject => None,
|
||||
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
|
||||
|
||||
@@ -145,14 +145,6 @@ async fn run_remote_compact_task_inner_impl(
|
||||
// compact endpoint. The checkpoint below records it separately from the next sampling request,
|
||||
// whose prompt will repeat current developer/context prefix items.
|
||||
let trace_input_history = history.raw_items().to_vec();
|
||||
// Required to keep `/undo` available after compaction
|
||||
let ghost_snapshots: Vec<ResponseItem> = history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
@@ -204,9 +196,6 @@ async fn run_remote_compact_task_inner_impl(
|
||||
)
|
||||
.await;
|
||||
|
||||
if !ghost_snapshots.is_empty() {
|
||||
new_history.extend(ghost_snapshots);
|
||||
}
|
||||
let reference_context_item = match initial_context_injection {
|
||||
InitialContextInjection::DoNotInject => None,
|
||||
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
|
||||
@@ -290,7 +279,6 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,27 @@ pub use network_proxy_spec::NetworkProxySpec;
|
||||
pub use network_proxy_spec::StartedNetworkProxy;
|
||||
pub(crate) use permissions::resolve_permission_profile;
|
||||
|
||||
pub use codex_git_utils::GhostSnapshotConfig;
|
||||
const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200;
|
||||
const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Compatibility-only config retained so legacy `ghost_snapshot` settings
|
||||
/// continue to load even though snapshots are no longer produced.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GhostSnapshotConfig {
|
||||
pub ignore_large_untracked_files: Option<i64>,
|
||||
pub ignore_large_untracked_dirs: Option<i64>,
|
||||
pub disable_warnings: bool,
|
||||
}
|
||||
|
||||
impl Default for GhostSnapshotConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ignore_large_untracked_files: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_FILES),
|
||||
ignore_large_untracked_dirs: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS),
|
||||
disable_warnings: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of bytes of the documentation that will be embedded. Larger
|
||||
/// files are *silently truncated* to this size so we do not take up too much of
|
||||
@@ -655,7 +675,8 @@ pub struct Config {
|
||||
/// Default: `300000` (5 minutes).
|
||||
pub background_terminal_max_timeout: u64,
|
||||
|
||||
/// Settings for ghost snapshots (used for undo).
|
||||
/// Compatibility-only settings retained for legacy `ghost_snapshot`
|
||||
/// config loading.
|
||||
pub ghost_snapshot: GhostSnapshotConfig,
|
||||
|
||||
/// Settings specific to the task-path-based multi-agent tool surface.
|
||||
|
||||
@@ -103,8 +103,7 @@ impl ContextManager {
|
||||
{
|
||||
for item in items {
|
||||
let item_ref = item.deref();
|
||||
let is_ghost_snapshot = matches!(item_ref, ResponseItem::GhostSnapshot { .. });
|
||||
if !is_api_message(item_ref) && !is_ghost_snapshot {
|
||||
if !is_api_message(item_ref) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -120,8 +119,6 @@ impl ContextManager {
|
||||
pub(crate) fn for_prompt(mut self, input_modalities: &[InputModality]) -> Vec<ResponseItem> {
|
||||
self.normalize_history(input_modalities);
|
||||
self.items
|
||||
.retain(|item| !matches!(item, ResponseItem::GhostSnapshot { .. }));
|
||||
self.items
|
||||
}
|
||||
|
||||
/// Returns raw items in the history.
|
||||
@@ -403,7 +400,6 @@ impl ContextManager {
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Other => item.clone(),
|
||||
}
|
||||
}
|
||||
@@ -492,7 +488,6 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. } => true,
|
||||
ResponseItem::GhostSnapshot { .. } => false,
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
@@ -534,7 +529,6 @@ static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock<BlockingLruCache<[u8; 20], Option
|
||||
|
||||
pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 {
|
||||
match item {
|
||||
ResponseItem::GhostSnapshot { .. } => 0,
|
||||
ResponseItem::Reasoning {
|
||||
encrypted_content: Some(content),
|
||||
..
|
||||
@@ -691,7 +685,6 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
|
||||
ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_git_utils::GhostCommit;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
@@ -594,22 +593,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_history_for_prompt_drops_ghost_commits() {
|
||||
let items = vec![ResponseItem::GhostSnapshot {
|
||||
ghost_commit: GhostCommit::new(
|
||||
"ghost-1".to_string(),
|
||||
/*parent*/ None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
),
|
||||
}];
|
||||
let history = create_history_with_items(items);
|
||||
let modalities = default_input_modalities();
|
||||
let filtered = history.for_prompt(&modalities);
|
||||
assert_eq!(filtered, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_token_count_with_base_instructions_uses_provided_text() {
|
||||
let history = create_history_with_items(vec![assistant_msg("hello from history")]);
|
||||
|
||||
@@ -168,7 +168,6 @@ use crate::compact::collect_user_messages;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::config::resolve_web_search_mode_for_turn;
|
||||
use crate::context_manager::ContextManager;
|
||||
@@ -289,10 +288,7 @@ use crate::state::SessionState;
|
||||
use crate::stream_events_utils::HandleOutputCtx;
|
||||
#[cfg(test)]
|
||||
use crate::stream_events_utils::handle_output_item_done;
|
||||
use crate::tasks::GhostSnapshotTask;
|
||||
use crate::tasks::ReviewTask;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::network_approval::build_blocked_request_observer;
|
||||
use crate::tools::network_approval::build_network_policy_decider;
|
||||
@@ -358,7 +354,6 @@ use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::ToolsConfigParams;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
#[cfg(test)]
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
@@ -2912,34 +2907,6 @@ impl Session {
|
||||
self.send_event(turn_context, event).await;
|
||||
}
|
||||
|
||||
async fn maybe_start_ghost_snapshot(
|
||||
self: &Arc<Self>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
cancellation_token: CancellationToken,
|
||||
) {
|
||||
if !self.enabled(Feature::GhostCommit) {
|
||||
return;
|
||||
}
|
||||
let token = match turn_context.tool_call_gate.subscribe().await {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
warn!("failed to subscribe to ghost snapshot readiness: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("spawning ghost snapshot task");
|
||||
let task = GhostSnapshotTask::new(token);
|
||||
Arc::new(task)
|
||||
.run(
|
||||
Arc::new(SessionTaskContext::new(self.clone())),
|
||||
turn_context.clone(),
|
||||
Vec::new(),
|
||||
cancellation_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Inject additional user input into the currently active turn.
|
||||
///
|
||||
/// Returns the active turn id when accepted.
|
||||
|
||||
@@ -356,8 +356,6 @@ pub(crate) async fn run_turn(
|
||||
track_turn_resolved_config_analytics(&sess, &turn_context, &input).await;
|
||||
|
||||
let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref());
|
||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||
.await;
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
let mut stop_hook_active = false;
|
||||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
|
||||
@@ -1951,7 +1949,6 @@ async fn try_run_sampling_request(
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => false,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use codex_model_provider::SharedModelProvider;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use codex_git_utils::CreateGhostCommitOptions;
|
||||
use codex_git_utils::GhostSnapshotReport;
|
||||
use codex_git_utils::GitToolingError;
|
||||
use codex_git_utils::create_ghost_commit_with_report;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::Token;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) struct GhostSnapshotTask {
|
||||
token: Token,
|
||||
}
|
||||
|
||||
const SNAPSHOT_WARNING_THRESHOLD: Duration = Duration::from_secs(240);
|
||||
|
||||
impl SessionTask for GhostSnapshotTask {
|
||||
fn kind(&self) -> TaskKind {
|
||||
TaskKind::Regular
|
||||
}
|
||||
|
||||
fn span_name(&self) -> &'static str {
|
||||
"session_task.ghost_snapshot"
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Arc<Self>,
|
||||
session: Arc<SessionTaskContext>,
|
||||
ctx: Arc<TurnContext>,
|
||||
_input: Vec<UserInput>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
tokio::task::spawn(async move {
|
||||
let token = self.token;
|
||||
let warnings_enabled = !ctx.ghost_snapshot.disable_warnings;
|
||||
// Channel used to signal when the snapshot work has finished so the
|
||||
// timeout warning task can exit early without sending a warning.
|
||||
let (snapshot_done_tx, snapshot_done_rx) = oneshot::channel::<()>();
|
||||
if warnings_enabled {
|
||||
let ctx_for_warning = ctx.clone();
|
||||
let cancellation_token_for_warning = cancellation_token.clone();
|
||||
let session_for_warning = session.clone();
|
||||
// Fire a generic warning if the snapshot is still running after
|
||||
// three minutes; this helps users discover large untracked files
|
||||
// that might need to be added to .gitignore.
|
||||
tokio::task::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => {
|
||||
session_for_warning.session
|
||||
.send_event(
|
||||
&ctx_for_warning,
|
||||
EventMsg::Warning(WarningEvent {
|
||||
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ = snapshot_done_rx => {}
|
||||
_ = cancellation_token_for_warning.cancelled() => {}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
drop(snapshot_done_rx);
|
||||
}
|
||||
|
||||
let ctx_for_task = ctx.clone();
|
||||
let cancelled = tokio::select! {
|
||||
_ = cancellation_token.cancelled() => true,
|
||||
_ = async {
|
||||
let repo_path = ctx_for_task.cwd.clone();
|
||||
let ghost_snapshot = ctx_for_task.ghost_snapshot.clone();
|
||||
let ghost_snapshot_for_commit = ghost_snapshot.clone();
|
||||
// Required to run in a dedicated blocking pool.
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
let options =
|
||||
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot_for_commit);
|
||||
create_ghost_commit_with_report(&options)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok((ghost_commit, report))) => {
|
||||
info!("ghost snapshot blocking task finished");
|
||||
if warnings_enabled {
|
||||
for message in format_snapshot_warnings(
|
||||
ghost_snapshot.ignore_large_untracked_files,
|
||||
ghost_snapshot.ignore_large_untracked_dirs,
|
||||
&report,
|
||||
) {
|
||||
session
|
||||
.session
|
||||
.send_event(
|
||||
&ctx_for_task,
|
||||
EventMsg::Warning(WarningEvent { message }),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
session
|
||||
.session
|
||||
.record_conversation_items(&ctx, &[ResponseItem::GhostSnapshot {
|
||||
ghost_commit: ghost_commit.clone(),
|
||||
}])
|
||||
.await;
|
||||
info!("ghost commit captured: {}", ghost_commit.id());
|
||||
}
|
||||
Ok(Err(err)) => match err {
|
||||
GitToolingError::NotAGitRepository { .. } => info!(
|
||||
sub_id = ctx_for_task.sub_id.as_str(),
|
||||
"skipping ghost snapshot because current directory is not a Git repository"
|
||||
),
|
||||
_ => {
|
||||
warn!(
|
||||
sub_id = ctx_for_task.sub_id.as_str(),
|
||||
"failed to capture ghost snapshot: {err}"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!(
|
||||
sub_id = ctx_for_task.sub_id.as_str(),
|
||||
"ghost snapshot task panicked: {err}"
|
||||
);
|
||||
let message =
|
||||
format!("Snapshots disabled after ghost snapshot panic: {err}.");
|
||||
session
|
||||
.session
|
||||
.notify_background_event(&ctx_for_task, message)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} => false,
|
||||
};
|
||||
|
||||
let _ = snapshot_done_tx.send(());
|
||||
|
||||
if cancelled {
|
||||
info!("ghost snapshot task cancelled");
|
||||
}
|
||||
|
||||
match ctx.tool_call_gate.mark_ready(token).await {
|
||||
Ok(true) => info!("ghost snapshot gate marked ready"),
|
||||
Ok(false) => warn!("ghost snapshot gate already ready"),
|
||||
Err(err) => warn!("failed to mark ghost snapshot ready: {err}"),
|
||||
}
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl GhostSnapshotTask {
|
||||
pub(crate) fn new(token: Token) -> Self {
|
||||
Self { token }
|
||||
}
|
||||
}
|
||||
|
||||
fn format_snapshot_warnings(
|
||||
ignore_large_untracked_files: Option<i64>,
|
||||
ignore_large_untracked_dirs: Option<i64>,
|
||||
report: &GhostSnapshotReport,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
if let Some(message) = format_large_untracked_warning(ignore_large_untracked_dirs, report) {
|
||||
warnings.push(message);
|
||||
}
|
||||
if let Some(message) =
|
||||
format_ignored_untracked_files_warning(ignore_large_untracked_files, report)
|
||||
{
|
||||
warnings.push(message);
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
fn format_large_untracked_warning(
|
||||
ignore_large_untracked_dirs: Option<i64>,
|
||||
report: &GhostSnapshotReport,
|
||||
) -> Option<String> {
|
||||
if report.large_untracked_dirs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let threshold = ignore_large_untracked_dirs?;
|
||||
const MAX_DIRS: usize = 3;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for dir in report.large_untracked_dirs.iter().take(MAX_DIRS) {
|
||||
parts.push(format!("{} ({} files)", dir.path.display(), dir.file_count));
|
||||
}
|
||||
if report.large_untracked_dirs.len() > MAX_DIRS {
|
||||
let remaining = report.large_untracked_dirs.len() - MAX_DIRS;
|
||||
parts.push(format!("{remaining} more"));
|
||||
}
|
||||
Some(format!(
|
||||
"Repository snapshot ignored large untracked directories (>= {threshold} files): {}. These directories are excluded from snapshots and undo cleanup. Adjust `ghost_snapshot.ignore_large_untracked_dirs` to change this behavior.",
|
||||
parts.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
fn format_ignored_untracked_files_warning(
|
||||
ignore_large_untracked_files: Option<i64>,
|
||||
report: &GhostSnapshotReport,
|
||||
) -> Option<String> {
|
||||
let threshold = ignore_large_untracked_files?;
|
||||
if report.ignored_untracked_files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
const MAX_FILES: usize = 3;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for file in report.ignored_untracked_files.iter().take(MAX_FILES) {
|
||||
parts.push(format!(
|
||||
"{} ({})",
|
||||
file.path.display(),
|
||||
format_bytes(file.byte_size)
|
||||
));
|
||||
}
|
||||
if report.ignored_untracked_files.len() > MAX_FILES {
|
||||
let remaining = report.ignored_untracked_files.len() - MAX_FILES;
|
||||
parts.push(format!("{remaining} more"));
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Repository snapshot ignored untracked files larger than {}: {}. These files are preserved during undo cleanup, but their contents are not captured in the snapshot. Adjust `ghost_snapshot.ignore_large_untracked_files` to change this behavior. To avoid this message in the future, update your `.gitignore`.",
|
||||
format_bytes(threshold),
|
||||
parts.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: i64) -> String {
|
||||
const KIB: i64 = 1024;
|
||||
const MIB: i64 = 1024 * 1024;
|
||||
|
||||
if bytes >= MIB {
|
||||
return format!("{} MiB", bytes / MIB);
|
||||
}
|
||||
if bytes >= KIB {
|
||||
return format!("{} KiB", bytes / KIB);
|
||||
}
|
||||
format!("{bytes} B")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ghost_snapshot_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,34 +0,0 @@
|
||||
use super::*;
|
||||
use codex_git_utils::LargeUntrackedDir;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn large_untracked_warning_includes_threshold() {
|
||||
let report = GhostSnapshotReport {
|
||||
large_untracked_dirs: vec![LargeUntrackedDir {
|
||||
path: PathBuf::from("models"),
|
||||
file_count: 250,
|
||||
}],
|
||||
ignored_untracked_files: Vec::new(),
|
||||
};
|
||||
|
||||
let message = format_large_untracked_warning(Some(200), &report).unwrap();
|
||||
assert!(message.contains(">= 200 files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_untracked_warning_disabled_when_threshold_disabled() {
|
||||
let report = GhostSnapshotReport {
|
||||
large_untracked_dirs: vec![LargeUntrackedDir {
|
||||
path: PathBuf::from("models"),
|
||||
file_count: 250,
|
||||
}],
|
||||
ignored_untracked_files: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
format_large_untracked_warning(/*ignore_large_untracked_dirs*/ None, &report),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
mod compact;
|
||||
mod ghost_snapshot;
|
||||
mod regular;
|
||||
mod review;
|
||||
mod undo;
|
||||
@@ -54,7 +53,6 @@ use codex_protocol::user_input::UserInput;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::models::ContentItem;
|
||||
pub(crate) use compact::CompactTask;
|
||||
pub(crate) use ghost_snapshot::GhostSnapshotTask;
|
||||
pub(crate) use regular::RegularTask;
|
||||
pub(crate) use review::ReviewTask;
|
||||
pub(crate) use undo::UndoTask;
|
||||
|
||||
@@ -4,17 +4,11 @@ use crate::session::turn_context::TurnContext;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use codex_git_utils::RestoreGhostCommitOptions;
|
||||
use codex_git_utils::restore_ghost_commit_with_options;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::UndoCompletedEvent;
|
||||
use codex_protocol::protocol::UndoStartedEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) struct UndoTask;
|
||||
|
||||
@@ -66,62 +60,11 @@ impl SessionTask for UndoTask {
|
||||
return None;
|
||||
}
|
||||
|
||||
let history = sess.clone_history().await;
|
||||
let mut items = history.raw_items().to_vec();
|
||||
let mut completed = UndoCompletedEvent {
|
||||
let completed = UndoCompletedEvent {
|
||||
success: false,
|
||||
message: None,
|
||||
message: Some("Undo is no longer available.".to_string()),
|
||||
};
|
||||
|
||||
let Some((idx, ghost_commit)) =
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(idx, item)| match item {
|
||||
ResponseItem::GhostSnapshot { ghost_commit } => {
|
||||
Some((idx, ghost_commit.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
completed.message = Some("No ghost snapshot available to undo.".to_string());
|
||||
sess.send_event(ctx.as_ref(), EventMsg::UndoCompleted(completed))
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let commit_id = ghost_commit.id().to_string();
|
||||
let repo_path = ctx.cwd.clone();
|
||||
let ghost_snapshot = ctx.ghost_snapshot.clone();
|
||||
let restore_result = tokio::task::spawn_blocking(move || {
|
||||
let options = RestoreGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
|
||||
restore_ghost_commit_with_options(&options, &ghost_commit)
|
||||
})
|
||||
.await;
|
||||
|
||||
match restore_result {
|
||||
Ok(Ok(())) => {
|
||||
items.remove(idx);
|
||||
let reference_context_item = sess.reference_context_item().await;
|
||||
sess.replace_history(items, reference_context_item).await;
|
||||
let short_id: String = commit_id.chars().take(7).collect();
|
||||
info!(commit_id = commit_id, "Undo restored ghost snapshot");
|
||||
completed.success = true;
|
||||
completed.message = Some(format!("Undo restored snapshot {short_id}."));
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
let message = format!("Failed to restore snapshot {commit_id}: {err}");
|
||||
warn!("{message}");
|
||||
completed.message = Some(message);
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("Failed to restore snapshot {commit_id}: {err}");
|
||||
error!("{message}");
|
||||
completed.message = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
sess.send_event(ctx.as_ref(), EventMsg::UndoCompleted(completed))
|
||||
.await;
|
||||
None
|
||||
|
||||
@@ -180,7 +180,6 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. } => true,
|
||||
ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
|
||||
Reference in New Issue
Block a user