From 4e05f3053c840fc77321bfab0aef65ec50448a9e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 27 Apr 2026 18:48:57 -0700 Subject: [PATCH] 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. --- codex-rs/Cargo.lock | 1 - .../schema/json/ClientRequest.json | 52 - .../codex_app_server_protocol.schemas.json | 52 - .../codex_app_server_protocol.v2.schemas.json | 52 - .../RawResponseItemCompletedNotification.json | 52 - .../schema/json/v2/ThreadResumeParams.json | 52 - .../schema/typescript/GhostCommit.ts | 8 - .../schema/typescript/ResponseItem.ts | 3 +- .../schema/typescript/index.ts | 1 - codex-rs/config/src/config_toml.rs | 10 +- codex-rs/core/config.schema.json | 8 +- codex-rs/core/src/agent/control.rs | 1 - codex-rs/core/src/arc_monitor.rs | 1 - codex-rs/core/src/compact.rs | 6 - codex-rs/core/src/compact_remote.rs | 12 - codex-rs/core/src/config/mod.rs | 25 +- codex-rs/core/src/context_manager/history.rs | 9 +- .../core/src/context_manager/history_tests.rs | 17 - codex-rs/core/src/session/mod.rs | 33 - codex-rs/core/src/session/turn.rs | 3 - codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/src/tasks/ghost_snapshot.rs | 252 --- .../core/src/tasks/ghost_snapshot_tests.rs | 34 - codex-rs/core/src/tasks/mod.rs | 2 - codex-rs/core/src/tasks/undo.rs | 61 +- codex-rs/core/src/turn_timing.rs | 1 - .../core/tests/suite/compact_resume_fork.rs | 31 +- codex-rs/core/tests/suite/undo.rs | 528 +---- codex-rs/features/src/lib.rs | 8 +- codex-rs/features/src/tests.rs | 22 + codex-rs/git-utils/Cargo.toml | 1 - codex-rs/git-utils/README.md | 29 +- codex-rs/git-utils/src/ghost_commits.rs | 1786 ----------------- codex-rs/git-utils/src/lib.rs | 14 - codex-rs/git-utils/src/operations.rs | 95 - codex-rs/otel/src/events/session_telemetry.rs | 1 - codex-rs/protocol/src/models.rs | 83 +- codex-rs/protocol/src/protocol.rs | 5 +- codex-rs/rollout/src/policy.rs | 2 - codex-rs/rollout/src/recorder.rs | 29 +- codex-rs/rollout/src/recorder_tests.rs | 158 ++ ...t__tests__experimental_features_popup.snap | 6 +- .../chatwidget/tests/popups_and_settings.rs | 12 +- 43 files changed, 305 insertions(+), 3254 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/GhostCommit.ts delete mode 100644 codex-rs/core/src/tasks/ghost_snapshot.rs delete mode 100644 codex-rs/core/src/tasks/ghost_snapshot_tests.rs delete mode 100644 codex-rs/git-utils/src/ghost_commits.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cb359a45c..a00873ee4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2735,7 +2735,6 @@ name = "codex-git-utils" version = "0.0.0" dependencies = [ "anyhow", - "assert_matches", "chrono", "codex-file-system", "codex-protocol", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f8e0c0b22..079a8a2d7 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1400,38 +1400,6 @@ }, "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -2676,26 +2644,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 696a734d1..7e3496747 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9183,38 +9183,6 @@ "title": "GetAccountResponse", "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "GitInfo": { "properties": { "branch": { @@ -13123,26 +13091,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/v2/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index cb2e86700..406795ff5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5813,38 +5813,6 @@ "title": "GetAccountResponse", "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "GitInfo": { "properties": { "branch": { @@ -9797,26 +9765,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index d877286d8..92117cf36 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -143,38 +143,6 @@ } ] }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -744,26 +712,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index aaa2a439c..63d0345da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -402,38 +402,6 @@ } ] }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -1140,26 +1108,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/typescript/GhostCommit.ts b/codex-rs/app-server-protocol/schema/typescript/GhostCommit.ts deleted file mode 100644 index d7b927492..000000000 --- a/codex-rs/app-server-protocol/schema/typescript/GhostCommit.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Details of a ghost commit created from a repository state. - */ -export type GhostCommit = { id: string, parent: string | null, preexisting_untracked_files: Array, preexisting_untracked_dirs: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index eed78b1fc..382c89db7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -3,7 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; -import type { GhostCommit } from "./GhostCommit"; import type { LocalShellAction } from "./LocalShellAction"; import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; @@ -15,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array, - /// Settings for ghost snapshots (used for undo). + /// Compatibility-only settings retained so legacy `ghost_snapshot` + /// config still loads. #[serde(default)] pub ghost_snapshot: Option, @@ -629,14 +630,13 @@ impl From for Tools { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct GhostSnapshotToml { - /// Exclude untracked files larger than this many bytes from ghost snapshots. + /// Legacy no-op setting retained for compatibility. #[serde(alias = "ignore_untracked_files_over_bytes")] pub ignore_large_untracked_files: Option, - /// Ignore untracked directories that contain this many files or more. - /// (Still emits a warning unless warnings are disabled.) + /// Legacy no-op setting retained for compatibility. #[serde(alias = "large_untracked_dir_warning_threshold")] pub ignore_large_untracked_dirs: Option, - /// Disable all ghost snapshot warning events. + /// Legacy no-op setting retained for compatibility. pub disable_warnings: Option, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 788ff5f95..bdccafcb5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -766,16 +766,16 @@ "additionalProperties": false, "properties": { "disable_warnings": { - "description": "Disable all ghost snapshot warning events.", + "description": "Legacy no-op setting retained for compatibility.", "type": "boolean" }, "ignore_large_untracked_dirs": { - "description": "Ignore untracked directories that contain this many files or more. (Still emits a warning unless warnings are disabled.)", + "description": "Legacy no-op setting retained for compatibility.", "format": "int64", "type": "integer" }, "ignore_large_untracked_files": { - "description": "Exclude untracked files larger than this many bytes from ghost snapshots.", + "description": "Legacy no-op setting retained for compatibility.", "format": "int64", "type": "integer" } @@ -2824,7 +2824,7 @@ } ], "default": null, - "description": "Settings for ghost snapshots (used for undo)." + "description": "Compatibility-only settings retained so legacy `ghost_snapshot` config still loads." }, "hide_agent_reasoning": { "description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.", diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index daf1acf8f..433e3b8c9 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -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, diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index 08b746517..c7f12e102 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -383,7 +383,6 @@ fn build_arc_monitor_message_item( | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } | ResponseItem::Other => None, } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ed7a95b96..1ebc307e3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -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 = 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()), diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 0623ceb3b..d8adb2077 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -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 = 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, } } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9b7d23b04..f4a73dd25 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + pub ignore_large_untracked_dirs: Option, + 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. diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 7e66a5b70..415a60198 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -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 { 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 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, } } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index ad67deb54..74f4d29bf 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -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")]); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1ebe2b4fc..c01363f55 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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, - turn_context: Arc, - 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. diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 41195490b..ec5705adf 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -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 = 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, }; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 24777f62e..a8244303d 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -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; diff --git a/codex-rs/core/src/tasks/ghost_snapshot.rs b/codex-rs/core/src/tasks/ghost_snapshot.rs deleted file mode 100644 index b9125d104..000000000 --- a/codex-rs/core/src/tasks/ghost_snapshot.rs +++ /dev/null @@ -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, - session: Arc, - ctx: Arc, - _input: Vec, - cancellation_token: CancellationToken, - ) -> Option { - 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, - ignore_large_untracked_dirs: Option, - report: &GhostSnapshotReport, -) -> Vec { - 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, - report: &GhostSnapshotReport, -) -> Option { - if report.large_untracked_dirs.is_empty() { - return None; - } - let threshold = ignore_large_untracked_dirs?; - const MAX_DIRS: usize = 3; - let mut parts: Vec = 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, - report: &GhostSnapshotReport, -) -> Option { - let threshold = ignore_large_untracked_files?; - if report.ignored_untracked_files.is_empty() { - return None; - } - - const MAX_FILES: usize = 3; - let mut parts: Vec = 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; diff --git a/codex-rs/core/src/tasks/ghost_snapshot_tests.rs b/codex-rs/core/src/tasks/ghost_snapshot_tests.rs deleted file mode 100644 index c901137e8..000000000 --- a/codex-rs/core/src/tasks/ghost_snapshot_tests.rs +++ /dev/null @@ -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 - ); -} diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 2541f59f0..15b688742 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -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; diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index bb5f50362..2abd0d268 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -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 diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index f66a066c4..214d6cafd 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -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 { .. } diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 125229873..6e6dcd40f 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -60,29 +60,6 @@ fn json_fragment(text: &str) -> String { .to_string() } -fn filter_out_ghost_snapshot_entries(items: &[Value]) -> Vec { - items - .iter() - .filter(|item| !is_ghost_snapshot_message(item)) - .cloned() - .collect() -} - -fn is_ghost_snapshot_message(item: &Value) -> bool { - if item.get("type").and_then(Value::as_str) != Some("message") { - return false; - } - if item.get("role").and_then(Value::as_str) != Some("user") { - return false; - } - item.get("content") - .and_then(Value::as_array) - .and_then(|content| content.first()) - .and_then(|entry| entry.get("text")) - .and_then(Value::as_str) - .is_some_and(|text| text.trim_start().starts_with("")) -} - fn normalize_line_endings_str(text: &str) -> String { if text.contains('\r') { text.replace("\r\n", "\n").replace('\r', "\n") @@ -366,15 +343,13 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() let resume_input_array = input_after_resume .as_array() .expect("input after resume should be an array"); - let compact_filtered = filter_out_ghost_snapshot_entries(compact_input_array); - let resume_filtered = filter_out_ghost_snapshot_entries(resume_input_array); assert!( - compact_filtered.len() <= resume_filtered.len(), + compact_input_array.len() <= resume_input_array.len(), "after-resume input should have at least as many items as after-compact" ); assert_eq!( - compact_filtered.as_slice(), - &resume_filtered[..compact_filtered.len()] + compact_input_array.as_slice(), + &resume_input_array[..compact_input_array.len()] ); let first_request_user_texts = json_message_input_texts(&requests[0], "user"); let first_turn_user_index = first_request_user_texts diff --git a/codex-rs/core/tests/suite/undo.rs b/codex-rs/core/tests/suite/undo.rs index 86ff1af31..afddb59c6 100644 --- a/codex-rs/core/tests/suite/undo.rs +++ b/codex-rs/core/tests/suite/undo.rs @@ -1,114 +1,19 @@ #![cfg(not(target_os = "windows"))] -use std::fs; -use std::path::Path; -use std::process::Command; use std::sync::Arc; -use anyhow::Context; use anyhow::Result; -use anyhow::bail; use codex_core::CodexThread; -use codex_features::Feature; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::UndoCompletedEvent; -use core_test_support::responses::ev_apply_patch_function_call; -use core_test_support::responses::ev_assistant_message; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_response_created; -use core_test_support::responses::mount_sse_sequence; -use core_test_support::responses::sse; -use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; -#[allow(clippy::expect_used)] async fn undo_harness() -> Result { - let builder = test_codex().with_model("gpt-5.4").with_config(|config| { - config.include_apply_patch_tool = true; - config - .features - .enable(Feature::GhostCommit) - .expect("test config should allow feature update"); - }); - TestCodexHarness::with_builder(builder).await -} - -fn git(path: &Path, args: &[&str]) -> Result<()> { - let status = Command::new("git") - .args(args) - .current_dir(path) - .status() - .with_context(|| format!("failed to run git {args:?}"))?; - if status.success() { - return Ok(()); - } - let exit_status = status; - bail!("git {args:?} exited with {exit_status}"); -} - -fn git_output(path: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .args(args) - .current_dir(path) - .output() - .with_context(|| format!("failed to run git {args:?}"))?; - if !output.status.success() { - let exit_status = output.status; - bail!("git {args:?} exited with {exit_status}"); - } - String::from_utf8(output.stdout).context("stdout was not valid utf8") -} - -fn init_git_repo(path: &Path) -> Result<()> { - // Use a consistent initial branch and config across environments to avoid - // CI variance (default-branch hints, line ending differences, etc.). - git(path, &["init", "--initial-branch=main"])?; - git(path, &["config", "core.autocrlf", "false"])?; - git(path, &["config", "user.name", "Codex Tests"])?; - git(path, &["config", "user.email", "codex-tests@example.com"])?; - - // Create README.txt - let readme_path = path.join("README.txt"); - fs::write(&readme_path, "Test repository initialized by Codex.\n")?; - - // Stage and commit - git(path, &["add", "README.txt"])?; - git(path, &["commit", "-m", "Add README.txt"])?; - - Ok(()) -} - -fn apply_patch_responses(call_id: &str, patch: &str, assistant_msg: &str) -> Vec { - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, patch), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", assistant_msg), - ev_completed("resp-2"), - ]), - ] -} - -async fn run_apply_patch_turn( - harness: &TestCodexHarness, - prompt: &str, - call_id: &str, - patch: &str, - assistant_msg: &str, -) -> Result<()> { - mount_sse_sequence( - harness.server(), - apply_patch_responses(call_id, patch, assistant_msg), - ) - .await; - harness.submit(prompt).await + TestCodexHarness::with_builder(test_codex().with_model("gpt-5.4")).await } async fn invoke_undo(codex: &Arc) -> Result { @@ -121,432 +26,17 @@ async fn invoke_undo(codex: &Arc) -> Result { Ok(event) } -async fn expect_successful_undo(codex: &Arc) -> Result { - let event = invoke_undo(codex).await?; - assert!( - event.success, - "expected undo to succeed but failed with message {:?}", - event.message - ); - Ok(event) -} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_reports_feature_removal() -> Result<()> { + let harness = undo_harness().await?; + let codex = Arc::clone(&harness.test().codex); -async fn expect_failed_undo(codex: &Arc) -> Result { - let event = invoke_undo(codex).await?; - assert!( - !event.success, - "expected undo to fail but succeeded with message {:?}", - event.message - ); + let event = invoke_undo(&codex).await?; + + assert!(!event.success, "expected undo to fail"); assert_eq!( event.message.as_deref(), - Some("No ghost snapshot available to undo.") - ); - Ok(event) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_removes_new_file_created_during_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let call_id = "undo-create-file"; - let patch = "*** Begin Patch\n*** Add File: new_file.txt\n+from turn\n*** End Patch"; - run_apply_patch_turn(&harness, "create file", call_id, patch, "ok").await?; - - let new_path = harness.path("new_file.txt"); - assert_eq!(fs::read_to_string(&new_path)?, "from turn\n"); - - let codex = Arc::clone(&harness.test().codex); - let completed = expect_successful_undo(&codex).await?; - assert!(completed.success, "undo failed: {:?}", completed.message); - - assert!(!new_path.exists()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_restores_tracked_file_edit() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let tracked = harness.path("tracked.txt"); - fs::write(&tracked, "before\n")?; - git(harness.cwd(), &["add", "tracked.txt"])?; - git(harness.cwd(), &["commit", "-m", "track file"])?; - - let patch = "*** Begin Patch\n*** Update File: tracked.txt\n@@\n-before\n+after\n*** End Patch"; - run_apply_patch_turn( - &harness, - "update tracked file", - "undo-tracked-edit", - patch, - "done", - ) - .await?; - println!( - "apply_patch output: {}", - harness.function_call_stdout("undo-tracked-edit").await - ); - - assert_eq!(fs::read_to_string(&tracked)?, "after\n"); - - let codex = Arc::clone(&harness.test().codex); - let completed = expect_successful_undo(&codex).await?; - assert!(completed.success, "undo failed: {:?}", completed.message); - - assert_eq!(fs::read_to_string(&tracked)?, "before\n"); - let status = git_output(harness.cwd(), &["status", "--short"])?; - assert_eq!(status, ""); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_restores_untracked_file_edit() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - git(harness.cwd(), &["commit", "--allow-empty", "-m", "init"])?; - - let notes = harness.path("notes.txt"); - fs::write(¬es, "original\n")?; - let status_before = git_output(harness.cwd(), &["status", "--short", "--ignored"])?; - assert!(status_before.contains("?? notes.txt")); - - let patch = - "*** Begin Patch\n*** Update File: notes.txt\n@@\n-original\n+modified\n*** End Patch"; - run_apply_patch_turn( - &harness, - "edit untracked", - "undo-untracked-edit", - patch, - "done", - ) - .await?; - - assert_eq!(fs::read_to_string(¬es)?, "modified\n"); - - let codex = Arc::clone(&harness.test().codex); - let completed = expect_successful_undo(&codex).await?; - assert!(completed.success, "undo failed: {:?}", completed.message); - - assert_eq!(fs::read_to_string(¬es)?, "original\n"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_reverts_only_latest_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let call_id_one = "undo-turn-one"; - let add_patch = "*** Begin Patch\n*** Add File: story.txt\n+first version\n*** End Patch"; - run_apply_patch_turn(&harness, "create story", call_id_one, add_patch, "done").await?; - let story = harness.path("story.txt"); - assert_eq!(fs::read_to_string(&story)?, "first version\n"); - - let call_id_two = "undo-turn-two"; - let update_patch = "*** Begin Patch\n*** Update File: story.txt\n@@\n-first version\n+second version\n*** End Patch"; - run_apply_patch_turn(&harness, "revise story", call_id_two, update_patch, "done").await?; - assert_eq!(fs::read_to_string(&story)?, "second version\n"); - - let codex = Arc::clone(&harness.test().codex); - let completed = expect_successful_undo(&codex).await?; - assert!(completed.success, "undo failed: {:?}", completed.message); - - assert_eq!(fs::read_to_string(&story)?, "first version\n"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_does_not_touch_unrelated_files() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let tracked_constant = harness.path("stable.txt"); - fs::write(&tracked_constant, "stable\n")?; - let target = harness.path("target.txt"); - fs::write(&target, "start\n")?; - let gitignore = harness.path(".gitignore"); - fs::write(&gitignore, "ignored-stable.log\n")?; - git( - harness.cwd(), - &["add", "stable.txt", "target.txt", ".gitignore"], - )?; - git(harness.cwd(), &["commit", "-m", "seed tracked"])?; - - let preexisting_untracked = harness.path("scratch.txt"); - fs::write(&preexisting_untracked, "scratch before\n")?; - let ignored = harness.path("ignored-stable.log"); - fs::write(&ignored, "ignored before\n")?; - - let full_patch = "*** Begin Patch\n*** Update File: target.txt\n@@\n-start\n+edited\n*** Add File: temp.txt\n+ephemeral\n*** End Patch"; - run_apply_patch_turn( - &harness, - "modify target", - "undo-unrelated", - full_patch, - "done", - ) - .await?; - let temp = harness.path("temp.txt"); - assert_eq!(fs::read_to_string(&target)?, "edited\n"); - assert_eq!(fs::read_to_string(&temp)?, "ephemeral\n"); - - let codex = Arc::clone(&harness.test().codex); - let completed = expect_successful_undo(&codex).await?; - assert!(completed.success, "undo failed: {:?}", completed.message); - - assert_eq!(fs::read_to_string(&tracked_constant)?, "stable\n"); - assert_eq!(fs::read_to_string(&target)?, "start\n"); - assert_eq!( - fs::read_to_string(&preexisting_untracked)?, - "scratch before\n" - ); - assert_eq!(fs::read_to_string(&ignored)?, "ignored before\n"); - assert!(!temp.exists()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_sequential_turns_consumes_snapshots() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let story = harness.path("story.txt"); - fs::write(&story, "initial\n")?; - git(harness.cwd(), &["add", "story.txt"])?; - git(harness.cwd(), &["commit", "-m", "seed story"])?; - - run_apply_patch_turn( - &harness, - "first change", - "seq-turn-1", - "*** Begin Patch\n*** Update File: story.txt\n@@\n-initial\n+turn one\n*** End Patch", - "ok", - ) - .await?; - assert_eq!(fs::read_to_string(&story)?, "turn one\n"); - - run_apply_patch_turn( - &harness, - "second change", - "seq-turn-2", - "*** Begin Patch\n*** Update File: story.txt\n@@\n-turn one\n+turn two\n*** End Patch", - "ok", - ) - .await?; - assert_eq!(fs::read_to_string(&story)?, "turn two\n"); - - run_apply_patch_turn( - &harness, - "third change", - "seq-turn-3", - "*** Begin Patch\n*** Update File: story.txt\n@@\n-turn two\n+turn three\n*** End Patch", - "ok", - ) - .await?; - assert_eq!(fs::read_to_string(&story)?, "turn three\n"); - - let codex = Arc::clone(&harness.test().codex); - expect_successful_undo(&codex).await?; - assert_eq!(fs::read_to_string(&story)?, "turn two\n"); - - expect_successful_undo(&codex).await?; - assert_eq!(fs::read_to_string(&story)?, "turn one\n"); - - expect_successful_undo(&codex).await?; - assert_eq!(fs::read_to_string(&story)?, "initial\n"); - - expect_failed_undo(&codex).await?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_without_snapshot_reports_failure() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - let codex = Arc::clone(&harness.test().codex); - - expect_failed_undo(&codex).await?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_restores_moves_and_renames() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let source = harness.path("rename_me.txt"); - fs::write(&source, "original\n")?; - git(harness.cwd(), &["add", "rename_me.txt"])?; - git(harness.cwd(), &["commit", "-m", "add rename target"])?; - - let patch = "*** Begin Patch\n*** Update File: rename_me.txt\n*** Move to: relocated/renamed.txt\n@@\n-original\n+renamed content\n*** End Patch"; - run_apply_patch_turn(&harness, "rename file", "undo-rename", patch, "done").await?; - - let destination = harness.path("relocated/renamed.txt"); - assert!(!source.exists()); - assert_eq!(fs::read_to_string(&destination)?, "renamed content\n"); - - let codex = Arc::clone(&harness.test().codex); - expect_successful_undo(&codex).await?; - - assert_eq!(fs::read_to_string(&source)?, "original\n"); - assert!(!destination.exists()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_does_not_touch_ignored_directory_contents() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let gitignore = harness.path(".gitignore"); - fs::write(&gitignore, "logs/\n")?; - git(harness.cwd(), &["add", ".gitignore"])?; - git(harness.cwd(), &["commit", "-m", "ignore logs directory"])?; - - let logs_dir = harness.path("logs"); - fs::create_dir_all(&logs_dir)?; - let preserved = logs_dir.join("persistent.log"); - fs::write(&preserved, "keep me\n")?; - - run_apply_patch_turn( - &harness, - "write log", - "undo-log", - "*** Begin Patch\n*** Add File: logs/session.log\n+ephemeral log\n*** End Patch", - "ok", - ) - .await?; - - let new_log = logs_dir.join("session.log"); - assert_eq!(fs::read_to_string(&new_log)?, "ephemeral log\n"); - - let codex = Arc::clone(&harness.test().codex); - expect_successful_undo(&codex).await?; - - assert!(new_log.exists()); - assert_eq!(fs::read_to_string(&preserved)?, "keep me\n"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_overwrites_manual_edits_after_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - let tracked = harness.path("tracked.txt"); - fs::write(&tracked, "baseline\n")?; - git(harness.cwd(), &["add", "tracked.txt"])?; - git(harness.cwd(), &["commit", "-m", "baseline tracked"])?; - - run_apply_patch_turn( - &harness, - "modify tracked", - "undo-manual-overwrite", - "*** Begin Patch\n*** Update File: tracked.txt\n@@\n-baseline\n+turn change\n*** End Patch", - "ok", - ) - .await?; - assert_eq!(fs::read_to_string(&tracked)?, "turn change\n"); - - fs::write(&tracked, "manual edit\n")?; - assert_eq!(fs::read_to_string(&tracked)?, "manual edit\n"); - - let codex = Arc::clone(&harness.test().codex); - expect_successful_undo(&codex).await?; - - assert_eq!(fs::read_to_string(&tracked)?, "baseline\n"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn undo_preserves_unrelated_staged_changes() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = undo_harness().await?; - init_git_repo(harness.cwd())?; - - // create a file for user to mess with - let user_file = harness.path("user_file.txt"); - fs::write(&user_file, "user content v1\n")?; - git(harness.cwd(), &["add", "user_file.txt"])?; - git(harness.cwd(), &["commit", "-m", "add user file"])?; - - // AI turn: modifies a DIFFERENT file (creating ghost commit of baseline) - let ai_file = harness.path("ai_file.txt"); - fs::write(&ai_file, "ai content v1\n")?; - git(harness.cwd(), &["add", "ai_file.txt"])?; - git(harness.cwd(), &["commit", "-m", "add ai file"])?; // baseline - - let patch = "*** Begin Patch\n*** Update File: ai_file.txt\n@@\n-ai content v1\n+ai content v2\n*** End Patch"; - run_apply_patch_turn(&harness, "modify ai file", "undo-staging-test", patch, "ok").await?; - assert_eq!(fs::read_to_string(&ai_file)?, "ai content v2\n"); - - // NOW: User modifies user_file AND stages it - fs::write(&user_file, "user content v2 (staged)\n")?; - git(harness.cwd(), &["add", "user_file.txt"])?; - - // Verify status before undo - let status_before = git_output(harness.cwd(), &["status", "--porcelain"])?; - assert!(status_before.contains("M user_file.txt")); // M in index - - // UNDO - let codex = Arc::clone(&harness.test().codex); - // checks that undo succeeded - expect_successful_undo(&codex).await?; - - // AI file should be reverted - assert_eq!(fs::read_to_string(&ai_file)?, "ai content v1\n"); - - // User file should STILL be staged with v2 - let status_after = git_output(harness.cwd(), &["status", "--porcelain"])?; - - // We expect 'M' in the first column (index modified). - // The second column will likely be 'M' because the worktree was reverted to v1 while index has v2. - // So "MM user_file.txt" is expected. - if !status_after.contains("MM user_file.txt") && !status_after.contains("M user_file.txt") { - bail!("Status should contain staged change (M in first col), but was: '{status_after}'"); - } - - // Disk content is reverted to v1 (snapshot state) - assert_eq!(fs::read_to_string(&user_file)?, "user content v1\n"); - - // But we can get v2 back from index - git(harness.cwd(), &["checkout", "user_file.txt"])?; - assert_eq!( - fs::read_to_string(&user_file)?, - "user content v2 (staged)\n" + Some("Undo is no longer available.") ); Ok(()) diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 0550201db..f20ca6577 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -71,7 +71,8 @@ impl Stage { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Feature { // Stable. - /// Create a ghost commit at each turn. + /// Removed compatibility flag retained as a no-op so old configs can + /// still parse `undo`. GhostCommit, /// Enable the default shell tool. ShellTool, @@ -392,6 +393,9 @@ impl Features { "tui_app_server" => { continue; } + "undo" => { + continue; + } "js_repl" => { continue; } @@ -620,7 +624,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::GhostCommit, key: "undo", - stage: Stage::Stable, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index b8ddf5350..6b1d638ff 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -54,6 +54,12 @@ fn use_linux_sandbox_bwrap_is_removed_and_disabled_by_default() { assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false); } +#[test] +fn undo_is_removed_and_disabled_by_default() { + assert_eq!(Feature::GhostCommit.stage(), Stage::Removed); + assert_eq!(Feature::GhostCommit.default_enabled(), false); +} + #[test] fn image_detail_original_is_removed_and_disabled_by_default() { assert_eq!(Feature::ImageDetailOriginal.stage(), Stage::Removed); @@ -349,6 +355,22 @@ fn from_sources_ignores_removed_image_detail_original_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_undo_feature_key() { + let features_toml = FeaturesToml::from(BTreeMap::from([("undo".to_string(), true)])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn from_sources_ignores_removed_js_repl_feature_keys() { let features_toml = FeaturesToml::from(BTreeMap::from([ diff --git a/codex-rs/git-utils/Cargo.toml b/codex-rs/git-utils/Cargo.toml index 3da8a1114..38616d46a 100644 --- a/codex-rs/git-utils/Cargo.toml +++ b/codex-rs/git-utils/Cargo.toml @@ -32,5 +32,4 @@ ts-rs = { workspace = true, features = [ walkdir = { workspace = true } [dev-dependencies] -assert_matches = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/git-utils/README.md b/codex-rs/git-utils/README.md index 30a209e3b..4220ced19 100644 --- a/codex-rs/git-utils/README.md +++ b/codex-rs/git-utils/README.md @@ -1,21 +1,17 @@ # codex-git-utils -Helpers for interacting with git, including patch application and worktree -snapshot utilities. The crate also exposes a lightweight baseline API for -internal directories that use git only as a resettable diff mechanism: -`ensure_git_baseline_repository` preserves a usable `root/.git` baseline or -creates one when it is missing or unusable, `reset_git_repository` replaces -`root/.git` with a fresh one-commit baseline, and `diff_since_latest_init` -returns structured file changes plus a unified diff from that baseline to the -current directory contents. +Helpers for interacting with git, including patch application. The crate also +exposes a lightweight baseline API for internal directories that use git only +as a resettable diff mechanism: `ensure_git_baseline_repository` preserves a +usable `root/.git` baseline or creates one when it is missing or unusable, +`reset_git_repository` replaces `root/.git` with a fresh one-commit baseline, +and `diff_since_latest_init` returns structured file changes plus a unified +diff from that baseline to the current directory contents. ```rust,no_run use std::path::Path; -use codex_git_utils::{ - apply_git_patch, create_ghost_commit, restore_ghost_commit, ApplyGitRequest, - CreateGhostCommitOptions, -}; +use codex_git_utils::{apply_git_patch, ApplyGitRequest}; let repo = Path::new("/path/to/repo"); @@ -27,13 +23,4 @@ let request = ApplyGitRequest { preflight: false, }; let result = apply_git_patch(&request)?; - -// Capture the current working tree as an unreferenced commit. -let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; - -// Later, undo back to that state. -restore_ghost_commit(repo, &ghost)?; ``` - -Pass a custom message with `.message("…")` or force-include ignored files with -`.force_include(["ignored.log".into()])`. diff --git a/codex-rs/git-utils/src/ghost_commits.rs b/codex-rs/git-utils/src/ghost_commits.rs deleted file mode 100644 index c35cd44b4..000000000 --- a/codex-rs/git-utils/src/ghost_commits.rs +++ /dev/null @@ -1,1786 +0,0 @@ -use std::collections::BTreeMap; -use std::collections::HashSet; -use std::ffi::OsString; -use std::fs; -use std::io; -use std::path::Component; -use std::path::Path; -use std::path::PathBuf; - -use tempfile::Builder; - -use crate::GhostCommit; -use crate::GitToolingError; -use crate::operations::apply_repo_prefix_to_force_include; -use crate::operations::ensure_git_repository; -use crate::operations::normalize_relative_path; -use crate::operations::repo_subdir; -use crate::operations::resolve_head; -use crate::operations::resolve_repository_root; -use crate::operations::run_git_for_status; -use crate::operations::run_git_for_stdout; -use crate::operations::run_git_for_stdout_all; - -/// Default commit message used for ghost commits when none is provided. -const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot"; -/// Default threshold for ignoring large untracked directories. -const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; -/// Default threshold (10 MiB) for excluding large untracked files from ghost snapshots. -const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; -/// Directories that should always be ignored when capturing ghost snapshots, -/// even if they are not listed in .gitignore. -/// -/// These are typically large dependency or build trees that are not useful -/// for undo and can cause snapshots to grow without bound. -const DEFAULT_IGNORED_DIR_NAMES: &[&str] = &[ - "node_modules", - ".venv", - "venv", - "env", - ".env", - "dist", - "build", - ".pytest_cache", - ".mypy_cache", - ".cache", - ".tox", - "__pycache__", -]; - -/// Options to control ghost commit creation. -pub struct CreateGhostCommitOptions<'a> { - pub repo_path: &'a Path, - pub message: Option<&'a str>, - pub force_include: Vec, - pub ghost_snapshot: GhostSnapshotConfig, -} - -/// Options to control ghost commit restoration. -pub struct RestoreGhostCommitOptions<'a> { - pub repo_path: &'a Path, - pub ghost_snapshot: GhostSnapshotConfig, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GhostSnapshotConfig { - pub ignore_large_untracked_files: Option, - pub ignore_large_untracked_dirs: Option, - 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, - } - } -} - -/// Summary produced alongside a ghost snapshot. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct GhostSnapshotReport { - pub large_untracked_dirs: Vec, - pub ignored_untracked_files: Vec, -} - -/// Directory containing a large amount of untracked content. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LargeUntrackedDir { - pub path: PathBuf, - pub file_count: i64, -} - -/// Untracked file excluded from the snapshot because of its size. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IgnoredUntrackedFile { - pub path: PathBuf, - pub byte_size: i64, -} - -impl<'a> CreateGhostCommitOptions<'a> { - /// Creates options scoped to the provided repository path. - pub fn new(repo_path: &'a Path) -> Self { - Self { - repo_path, - message: None, - force_include: Vec::new(), - ghost_snapshot: GhostSnapshotConfig::default(), - } - } - - /// Sets a custom commit message for the ghost commit. - pub fn message(mut self, message: &'a str) -> Self { - self.message = Some(message); - self - } - - pub fn ghost_snapshot(mut self, ghost_snapshot: GhostSnapshotConfig) -> Self { - self.ghost_snapshot = ghost_snapshot; - self - } - - /// Exclude untracked files larger than `bytes` from the snapshot commit. - /// - /// These files are still treated as untracked for preservation purposes (i.e. they will not be - /// deleted by undo), but they will not be captured in the snapshot tree. - pub fn ignore_large_untracked_files(mut self, bytes: i64) -> Self { - if bytes > 0 { - self.ghost_snapshot.ignore_large_untracked_files = Some(bytes); - } else { - self.ghost_snapshot.ignore_large_untracked_files = None; - } - self - } - - /// Supplies the entire force-include path list at once. - pub fn force_include(mut self, paths: I) -> Self - where - I: IntoIterator, - { - self.force_include = paths.into_iter().collect(); - self - } - - /// Adds a single path to the force-include list. - pub fn push_force_include

(mut self, path: P) -> Self - where - P: Into, - { - self.force_include.push(path.into()); - self - } -} - -impl<'a> RestoreGhostCommitOptions<'a> { - /// Creates restore options scoped to the provided repository path. - pub fn new(repo_path: &'a Path) -> Self { - Self { - repo_path, - ghost_snapshot: GhostSnapshotConfig::default(), - } - } - - pub fn ghost_snapshot(mut self, ghost_snapshot: GhostSnapshotConfig) -> Self { - self.ghost_snapshot = ghost_snapshot; - self - } - - /// Exclude untracked files larger than `bytes` from undo cleanup. - /// - /// These files are treated as "always preserve" to avoid deleting large local artifacts. - pub fn ignore_large_untracked_files(mut self, bytes: i64) -> Self { - if bytes > 0 { - self.ghost_snapshot.ignore_large_untracked_files = Some(bytes); - } else { - self.ghost_snapshot.ignore_large_untracked_files = None; - } - self - } - - /// Ignore untracked directories that contain at least `file_count` untracked files. - pub fn ignore_large_untracked_dirs(mut self, file_count: i64) -> Self { - if file_count > 0 { - self.ghost_snapshot.ignore_large_untracked_dirs = Some(file_count); - } else { - self.ghost_snapshot.ignore_large_untracked_dirs = None; - } - self - } -} - -fn detect_large_untracked_dirs( - files: &[PathBuf], - dirs: &[PathBuf], - threshold: Option, -) -> Vec { - let Some(threshold) = threshold else { - return Vec::new(); - }; - if threshold <= 0 { - return Vec::new(); - } - - let mut counts: BTreeMap = BTreeMap::new(); - - let mut sorted_dirs: Vec<&PathBuf> = dirs.iter().collect(); - sorted_dirs.sort_by(|a, b| { - let a_components = a.components().count(); - let b_components = b.components().count(); - b_components.cmp(&a_components).then_with(|| a.cmp(b)) - }); - - for file in files { - let mut key: Option = None; - for dir in &sorted_dirs { - if file.starts_with(dir.as_path()) { - key = Some((*dir).clone()); - break; - } - } - let key = key.unwrap_or_else(|| { - file.parent() - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) - }); - let entry = counts.entry(key).or_insert(0); - *entry += 1; - } - - let mut result: Vec = counts - .into_iter() - .filter(|(_, count)| *count >= threshold) - .map(|(path, file_count)| LargeUntrackedDir { path, file_count }) - .collect(); - result.sort_by(|a, b| { - b.file_count - .cmp(&a.file_count) - .then_with(|| a.path.cmp(&b.path)) - }); - result -} - -fn to_session_relative_path(path: &Path, repo_prefix: Option<&Path>) -> PathBuf { - match repo_prefix { - Some(prefix) => path - .strip_prefix(prefix) - .map(PathBuf::from) - .unwrap_or_else(|_| path.to_path_buf()), - None => path.to_path_buf(), - } -} - -/// Create a ghost commit capturing the current state of the repository's working tree. -pub fn create_ghost_commit( - options: &CreateGhostCommitOptions<'_>, -) -> Result { - create_ghost_commit_with_report(options).map(|(commit, _)| commit) -} - -/// Compute a report describing the working tree for a ghost snapshot without creating a commit. -pub fn capture_ghost_snapshot_report( - options: &CreateGhostCommitOptions<'_>, -) -> Result { - ensure_git_repository(options.repo_path)?; - - let repo_root = resolve_repository_root(options.repo_path)?; - let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); - let force_include = prepare_force_include(repo_prefix.as_deref(), &options.force_include)?; - let existing_untracked = capture_existing_untracked( - repo_root.as_path(), - repo_prefix.as_deref(), - options.ghost_snapshot.ignore_large_untracked_files, - options.ghost_snapshot.ignore_large_untracked_dirs, - &force_include, - )?; - - let warning_ignored_files = existing_untracked - .ignored_untracked_files - .iter() - .map(|file| IgnoredUntrackedFile { - path: to_session_relative_path(file.path.as_path(), repo_prefix.as_deref()), - byte_size: file.byte_size, - }) - .collect::>(); - let warning_ignored_dirs = existing_untracked - .ignored_large_untracked_dirs - .iter() - .map(|dir| LargeUntrackedDir { - path: to_session_relative_path(dir.path.as_path(), repo_prefix.as_deref()), - file_count: dir.file_count, - }) - .collect::>(); - - Ok(GhostSnapshotReport { - large_untracked_dirs: warning_ignored_dirs, - ignored_untracked_files: warning_ignored_files, - }) -} - -/// Create a ghost commit capturing the current state of the repository's working tree along with a report. -pub fn create_ghost_commit_with_report( - options: &CreateGhostCommitOptions<'_>, -) -> Result<(GhostCommit, GhostSnapshotReport), GitToolingError> { - ensure_git_repository(options.repo_path)?; - - let repo_root = resolve_repository_root(options.repo_path)?; - let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); - let parent = resolve_head(repo_root.as_path())?; - let force_include = prepare_force_include(repo_prefix.as_deref(), &options.force_include)?; - let status_snapshot = capture_status_snapshot( - repo_root.as_path(), - repo_prefix.as_deref(), - options.ghost_snapshot.ignore_large_untracked_files, - options.ghost_snapshot.ignore_large_untracked_dirs, - &force_include, - )?; - let existing_untracked = status_snapshot.untracked; - - let warning_ignored_files = existing_untracked - .ignored_untracked_files - .iter() - .map(|file| IgnoredUntrackedFile { - path: to_session_relative_path(file.path.as_path(), repo_prefix.as_deref()), - byte_size: file.byte_size, - }) - .collect::>(); - let large_untracked_dirs = existing_untracked - .ignored_large_untracked_dirs - .iter() - .map(|dir| LargeUntrackedDir { - path: to_session_relative_path(dir.path.as_path(), repo_prefix.as_deref()), - file_count: dir.file_count, - }) - .collect::>(); - let index_tempdir = Builder::new().prefix("codex-git-index-").tempdir()?; - let index_path = index_tempdir.path().join("index"); - let base_env = vec![( - OsString::from("GIT_INDEX_FILE"), - OsString::from(index_path.as_os_str()), - )]; - // Use a temporary index so snapshotting does not disturb the user's index state. - // Example plumbing sequence: - // GIT_INDEX_FILE=/tmp/index git read-tree HEAD - // GIT_INDEX_FILE=/tmp/index git add --all -- - // GIT_INDEX_FILE=/tmp/index git write-tree - // GIT_INDEX_FILE=/tmp/index git commit-tree -p -m "codex snapshot" - - // Pre-populate the temporary index with HEAD so unchanged tracked files - // are included in the snapshot tree. - if let Some(parent_sha) = parent.as_deref() { - run_git_for_status( - repo_root.as_path(), - vec![OsString::from("read-tree"), OsString::from(parent_sha)], - Some(base_env.as_slice()), - )?; - } - - let mut index_paths = status_snapshot.tracked_paths; - index_paths.extend(existing_untracked.untracked_files_for_index.iter().cloned()); - let index_paths = dedupe_paths(index_paths); - // Stage tracked + new files into the temp index so write-tree reflects the working tree. - // We use `git add --all` to make deletions show up in the snapshot tree too. - add_paths_to_index(repo_root.as_path(), base_env.as_slice(), &index_paths)?; - if !force_include.is_empty() { - let mut args = Vec::with_capacity(force_include.len() + 2); - args.push(OsString::from("add")); - args.push(OsString::from("--force")); - args.extend( - force_include - .iter() - .map(|path| OsString::from(path.as_os_str())), - ); - run_git_for_status(repo_root.as_path(), args, Some(base_env.as_slice()))?; - } - - let tree_id = run_git_for_stdout( - repo_root.as_path(), - vec![OsString::from("write-tree")], - Some(base_env.as_slice()), - )?; - - let mut commit_env = base_env; - commit_env.extend(default_commit_identity()); - let message = options.message.unwrap_or(DEFAULT_COMMIT_MESSAGE); - let commit_args = { - let mut result = vec![OsString::from("commit-tree"), OsString::from(&tree_id)]; - if let Some(parent) = parent.as_deref() { - result.extend([OsString::from("-p"), OsString::from(parent)]); - } - result.extend([OsString::from("-m"), OsString::from(message)]); - result - }; - - // `git commit-tree` writes a detached commit object without updating refs, - // which keeps snapshots out of the user's branch history. - // Retrieve commit ID. - let commit_id = run_git_for_stdout( - repo_root.as_path(), - commit_args, - Some(commit_env.as_slice()), - )?; - - let ghost_commit = GhostCommit::new( - commit_id, - parent, - merge_preserved_untracked_files( - existing_untracked.files, - &existing_untracked.ignored_untracked_files, - ), - merge_preserved_untracked_dirs( - existing_untracked.dirs, - &existing_untracked.ignored_large_untracked_dirs, - ), - ); - - Ok(( - ghost_commit, - GhostSnapshotReport { - large_untracked_dirs, - ignored_untracked_files: warning_ignored_files, - }, - )) -} - -/// Restore the working tree to match the provided ghost commit. -pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> { - restore_ghost_commit_with_options(&RestoreGhostCommitOptions::new(repo_path), commit) -} - -/// Restore the working tree using the provided options. -pub fn restore_ghost_commit_with_options( - options: &RestoreGhostCommitOptions<'_>, - commit: &GhostCommit, -) -> Result<(), GitToolingError> { - ensure_git_repository(options.repo_path)?; - - let repo_root = resolve_repository_root(options.repo_path)?; - let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); - let current_untracked = capture_existing_untracked( - repo_root.as_path(), - repo_prefix.as_deref(), - options.ghost_snapshot.ignore_large_untracked_files, - options.ghost_snapshot.ignore_large_untracked_dirs, - &[], - )?; - restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit.id())?; - remove_new_untracked( - repo_root.as_path(), - commit.preexisting_untracked_files(), - commit.preexisting_untracked_dirs(), - current_untracked, - ) -} - -/// Restore the working tree to match the given commit ID. -pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToolingError> { - ensure_git_repository(repo_path)?; - - let repo_root = resolve_repository_root(repo_path)?; - let repo_prefix = repo_subdir(repo_root.as_path(), repo_path); - restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit_id) -} - -/// Restores the working tree and index to the given commit using `git restore`. -/// The repository root and optional repository-relative prefix limit the restore scope. -fn restore_to_commit_inner( - repo_root: &Path, - repo_prefix: Option<&Path>, - commit_id: &str, -) -> Result<(), GitToolingError> { - // `git restore` resets the working tree to the snapshot commit. - // We intentionally avoid --staged to preserve user's staged changes. - // While this might leave some Codex-staged changes in the index (if Codex ran `git add`), - // it prevents data loss for users who use the index as a save point. - // Data safety > cleanliness. - // Example: - // git restore --source --worktree -- - let mut restore_args = vec![ - OsString::from("restore"), - OsString::from("--source"), - OsString::from(commit_id), - OsString::from("--worktree"), - OsString::from("--"), - ]; - if let Some(prefix) = repo_prefix { - restore_args.push(prefix.as_os_str().to_os_string()); - } else { - restore_args.push(OsString::from(".")); - } - - run_git_for_status(repo_root, restore_args, /*env*/ None)?; - Ok(()) -} - -#[derive(Default)] -struct UntrackedSnapshot { - files: Vec, - dirs: Vec, - untracked_files_for_index: Vec, - ignored_untracked_files: Vec, - ignored_large_untracked_dirs: Vec, - ignored_large_untracked_dir_files: Vec, -} - -#[derive(Default)] -struct StatusSnapshot { - tracked_paths: Vec, - untracked: UntrackedSnapshot, -} - -/// Captures the working tree status under `repo_root`, optionally limited by `repo_prefix`. -/// Returns the result as a `StatusSnapshot`. -fn capture_status_snapshot( - repo_root: &Path, - repo_prefix: Option<&Path>, - ignore_large_untracked_files: Option, - ignore_large_untracked_dirs: Option, - force_include: &[PathBuf], -) -> Result { - // Ask git for the zero-delimited porcelain status so we can enumerate - // tracked, untracked, and ignored entries (including ones filtered by prefix). - // This keeps the snapshot consistent without multiple git invocations. - let mut args = vec![ - OsString::from("status"), - OsString::from("--porcelain=2"), - OsString::from("-z"), - OsString::from("--untracked-files=all"), - ]; - if let Some(prefix) = repo_prefix { - args.push(OsString::from("--")); - args.push(prefix.as_os_str().to_os_string()); - } - - let output = run_git_for_stdout_all(repo_root, args, /*env*/ None)?; - if output.is_empty() { - return Ok(StatusSnapshot::default()); - } - - let mut snapshot = StatusSnapshot::default(); - let mut untracked_files_for_dir_scan: Vec = Vec::new(); - let mut expect_rename_source = false; - for entry in output.split('\0') { - if entry.is_empty() { - continue; - } - if expect_rename_source { - let normalized = normalize_relative_path(Path::new(entry))?; - snapshot.tracked_paths.push(normalized); - expect_rename_source = false; - continue; - } - - let record_type = entry.as_bytes().first().copied().unwrap_or(b' '); - match record_type { - b'?' | b'!' => { - let mut parts = entry.splitn(2, ' '); - let code = parts.next(); - let path_part = parts.next(); - let (Some(code), Some(path_part)) = (code, path_part) else { - continue; - }; - if path_part.is_empty() { - continue; - } - - let normalized = normalize_relative_path(Path::new(path_part))?; - if should_ignore_for_snapshot(&normalized) { - continue; - } - let absolute = repo_root.join(&normalized); - let is_dir = absolute.is_dir(); - if is_dir { - snapshot.untracked.dirs.push(normalized); - } else if code == "?" { - untracked_files_for_dir_scan.push(normalized.clone()); - if let Some(threshold) = ignore_large_untracked_files - && threshold > 0 - && !is_force_included(&normalized, force_include) - && let Ok(Some(byte_size)) = untracked_file_size(&absolute) - && byte_size > threshold - { - snapshot - .untracked - .ignored_untracked_files - .push(IgnoredUntrackedFile { - path: normalized, - byte_size, - }); - } else { - snapshot.untracked.files.push(normalized.clone()); - snapshot - .untracked - .untracked_files_for_index - .push(normalized); - } - } else { - snapshot.untracked.files.push(normalized); - } - } - b'1' => { - if let Some(path) = - extract_status_path_after_fields(entry, /*fields_before_path*/ 8) - { - let normalized = normalize_relative_path(Path::new(path))?; - snapshot.tracked_paths.push(normalized); - } - } - b'2' => { - if let Some(path) = - extract_status_path_after_fields(entry, /*fields_before_path*/ 9) - { - let normalized = normalize_relative_path(Path::new(path))?; - snapshot.tracked_paths.push(normalized); - } - expect_rename_source = true; - } - b'u' => { - if let Some(path) = - extract_status_path_after_fields(entry, /*fields_before_path*/ 10) - { - let normalized = normalize_relative_path(Path::new(path))?; - snapshot.tracked_paths.push(normalized); - } - } - _ => {} - } - } - - if let Some(threshold) = ignore_large_untracked_dirs - && threshold > 0 - { - let ignored_large_untracked_dirs = detect_large_untracked_dirs( - &untracked_files_for_dir_scan, - &snapshot.untracked.dirs, - Some(threshold), - ) - .into_iter() - .filter(|entry| !entry.path.as_os_str().is_empty() && entry.path != Path::new(".")) - .collect::>(); - - if !ignored_large_untracked_dirs.is_empty() { - let ignored_dir_paths = ignored_large_untracked_dirs - .iter() - .map(|entry| entry.path.as_path()) - .collect::>(); - - snapshot - .untracked - .files - .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); - snapshot - .untracked - .dirs - .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); - snapshot - .untracked - .untracked_files_for_index - .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); - snapshot.untracked.ignored_untracked_files.retain(|file| { - !ignored_dir_paths - .iter() - .any(|dir| file.path.starts_with(dir)) - }); - - snapshot.untracked.ignored_large_untracked_dir_files = untracked_files_for_dir_scan - .into_iter() - .filter(|path| ignored_dir_paths.iter().any(|dir| path.starts_with(dir))) - .collect(); - snapshot.untracked.ignored_large_untracked_dirs = ignored_large_untracked_dirs; - } - } - - Ok(snapshot) -} - -/// Captures the untracked and ignored entries under `repo_root`, optionally limited by `repo_prefix`. -/// Returns the result as an `UntrackedSnapshot`. -fn capture_existing_untracked( - repo_root: &Path, - repo_prefix: Option<&Path>, - ignore_large_untracked_files: Option, - ignore_large_untracked_dirs: Option, - force_include: &[PathBuf], -) -> Result { - Ok(capture_status_snapshot( - repo_root, - repo_prefix, - ignore_large_untracked_files, - ignore_large_untracked_dirs, - force_include, - )? - .untracked) -} - -fn extract_status_path_after_fields(record: &str, fields_before_path: i64) -> Option<&str> { - if fields_before_path <= 0 { - return None; - } - let mut spaces = 0_i64; - for (idx, byte) in record.as_bytes().iter().enumerate() { - if *byte == b' ' { - spaces += 1; - if spaces == fields_before_path { - return record.get((idx + 1)..).filter(|path| !path.is_empty()); - } - } - } - None -} - -fn should_ignore_for_snapshot(path: &Path) -> bool { - path.components().any(|component| { - if let Component::Normal(name) = component - && let Some(name_str) = name.to_str() - { - return DEFAULT_IGNORED_DIR_NAMES - .iter() - .any(|ignored| ignored == &name_str); - } - false - }) -} - -fn prepare_force_include( - repo_prefix: Option<&Path>, - force_include: &[PathBuf], -) -> Result, GitToolingError> { - let normalized_force = force_include - .iter() - .map(PathBuf::as_path) - .map(normalize_relative_path) - .collect::, _>>()?; - Ok(apply_repo_prefix_to_force_include( - repo_prefix, - &normalized_force, - )) -} - -fn is_force_included(path: &Path, force_include: &[PathBuf]) -> bool { - force_include - .iter() - .any(|candidate| path.starts_with(candidate.as_path())) -} - -fn untracked_file_size(path: &Path) -> io::Result> { - let Ok(metadata) = fs::symlink_metadata(path) else { - return Ok(None); - }; - - let Ok(len_i64) = i64::try_from(metadata.len()) else { - return Ok(Some(i64::MAX)); - }; - Ok(Some(len_i64)) -} - -fn add_paths_to_index( - repo_root: &Path, - env: &[(OsString, OsString)], - paths: &[PathBuf], -) -> Result<(), GitToolingError> { - if paths.is_empty() { - return Ok(()); - } - - let chunk_size = usize::try_from(64_i64).unwrap_or(1); - for chunk in paths.chunks(chunk_size) { - let mut args = vec![ - OsString::from("add"), - OsString::from("--all"), - OsString::from("--"), - ]; - args.extend(chunk.iter().map(|path| path.as_os_str().to_os_string())); - // Chunk the argv to avoid oversized command lines on large repos. - run_git_for_status(repo_root, args, Some(env))?; - } - - Ok(()) -} - -fn dedupe_paths(paths: Vec) -> Vec { - let mut seen = HashSet::new(); - let mut result = Vec::new(); - for path in paths { - if seen.insert(path.clone()) { - result.push(path); - } - } - result -} - -fn merge_preserved_untracked_files( - mut files: Vec, - ignored: &[IgnoredUntrackedFile], -) -> Vec { - if ignored.is_empty() { - return files; - } - - files.extend(ignored.iter().map(|entry| entry.path.clone())); - files -} - -fn merge_preserved_untracked_dirs( - mut dirs: Vec, - ignored_large_dirs: &[LargeUntrackedDir], -) -> Vec { - if ignored_large_dirs.is_empty() { - return dirs; - } - - for entry in ignored_large_dirs { - if dirs.iter().any(|dir| dir == &entry.path) { - continue; - } - dirs.push(entry.path.clone()); - } - - dirs -} - -/// Removes untracked files and directories that were not present when the snapshot was captured. -fn remove_new_untracked( - repo_root: &Path, - preserved_files: &[PathBuf], - preserved_dirs: &[PathBuf], - current: UntrackedSnapshot, -) -> Result<(), GitToolingError> { - if current.files.is_empty() && current.dirs.is_empty() { - return Ok(()); - } - - let preserved_file_set: HashSet = preserved_files.iter().cloned().collect(); - let preserved_dirs_vec: Vec = preserved_dirs.to_vec(); - - for path in current.files { - if should_preserve(&path, &preserved_file_set, &preserved_dirs_vec) { - continue; - } - remove_path(&repo_root.join(&path))?; - } - - for dir in current.dirs { - if should_preserve(&dir, &preserved_file_set, &preserved_dirs_vec) { - continue; - } - remove_path(&repo_root.join(&dir))?; - } - - Ok(()) -} - -/// Determines whether an untracked path should be kept because it existed in the snapshot. -fn should_preserve( - path: &Path, - preserved_files: &HashSet, - preserved_dirs: &[PathBuf], -) -> bool { - if preserved_files.contains(path) { - return true; - } - - preserved_dirs - .iter() - .any(|dir| path.starts_with(dir.as_path())) -} - -/// Deletes the file or directory at the provided path, ignoring if it is already absent. -fn remove_path(path: &Path) -> Result<(), GitToolingError> { - match fs::symlink_metadata(path) { - Ok(metadata) => { - if metadata.is_dir() { - fs::remove_dir_all(path)?; - } else { - fs::remove_file(path)?; - } - } - Err(err) => { - if err.kind() == io::ErrorKind::NotFound { - return Ok(()); - } - return Err(err.into()); - } - } - Ok(()) -} - -/// Returns the default author and committer identity for ghost commits. -fn default_commit_identity() -> Vec<(OsString, OsString)> { - vec![ - ( - OsString::from("GIT_AUTHOR_NAME"), - OsString::from("Codex Snapshot"), - ), - ( - OsString::from("GIT_AUTHOR_EMAIL"), - OsString::from("snapshot@codex.local"), - ), - ( - OsString::from("GIT_COMMITTER_NAME"), - OsString::from("Codex Snapshot"), - ), - ( - OsString::from("GIT_COMMITTER_EMAIL"), - OsString::from("snapshot@codex.local"), - ), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::operations::run_git_for_stdout; - use assert_matches::assert_matches; - use pretty_assertions::assert_eq; - use std::fs::File; - use std::process::Command; - use walkdir::WalkDir; - - /// Runs a git command in the test repository and asserts success. - fn run_git_in(repo_path: &Path, args: &[&str]) { - let status = Command::new("git") - .current_dir(repo_path) - .args(args) - .status() - .expect("git command"); - assert!(status.success(), "git command failed: {args:?}"); - } - - /// Runs a git command and returns its trimmed stdout output. - fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .current_dir(repo_path) - .args(args) - .output() - .expect("git command"); - assert!(output.status.success(), "git command failed: {args:?}"); - String::from_utf8_lossy(&output.stdout).trim().to_string() - } - - /// Initializes a repository with consistent settings for cross-platform tests. - fn init_test_repo(repo: &Path) { - run_git_in(repo, &["init", "--initial-branch=main"]); - run_git_in(repo, &["config", "core.autocrlf", "false"]); - } - - fn create_sparse_file(path: &Path, bytes: i64) -> io::Result<()> { - let file_len = - u64::try_from(bytes).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; - let file = File::create(path)?; - file.set_len(file_len)?; - Ok(()) - } - - #[test] - /// Verifies a ghost commit can be created and restored end to end. - fn create_and_restore_roundtrip() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - std::fs::write(repo.join("tracked.txt"), "initial\n")?; - std::fs::write(repo.join("delete-me.txt"), "to be removed\n")?; - run_git_in(repo, &["add", "tracked.txt", "delete-me.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "init", - ], - ); - - let preexisting_untracked = repo.join("notes.txt"); - std::fs::write(&preexisting_untracked, "notes before\n")?; - - let tracked_contents = "modified contents\n"; - std::fs::write(repo.join("tracked.txt"), tracked_contents)?; - std::fs::remove_file(repo.join("delete-me.txt"))?; - let new_file_contents = "hello ghost\n"; - std::fs::write(repo.join("new-file.txt"), new_file_contents)?; - std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; - let ignored_contents = "ignored but captured\n"; - std::fs::write(repo.join("ignored.txt"), ignored_contents)?; - - let options = - CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]); - let ghost = create_ghost_commit(&options)?; - - assert!(ghost.parent().is_some()); - let cat = run_git_for_stdout( - repo, - vec![ - OsString::from("show"), - OsString::from(format!("{}:ignored.txt", ghost.id())), - ], - /*env*/ None, - )?; - assert_eq!(cat, ignored_contents.trim()); - - std::fs::write(repo.join("tracked.txt"), "other state\n")?; - std::fs::write(repo.join("ignored.txt"), "changed\n")?; - std::fs::remove_file(repo.join("new-file.txt"))?; - std::fs::write(repo.join("ephemeral.txt"), "temp data\n")?; - std::fs::write(&preexisting_untracked, "notes after\n")?; - - restore_ghost_commit(repo, &ghost)?; - - let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?; - assert_eq!(tracked_after, tracked_contents); - let ignored_after = std::fs::read_to_string(repo.join("ignored.txt"))?; - assert_eq!(ignored_after, ignored_contents); - let new_file_after = std::fs::read_to_string(repo.join("new-file.txt"))?; - assert_eq!(new_file_after, new_file_contents); - assert_eq!(repo.join("delete-me.txt").exists(), false); - assert!(!repo.join("ephemeral.txt").exists()); - let notes_after = std::fs::read_to_string(&preexisting_untracked)?; - assert_eq!(notes_after, "notes before\n"); - - Ok(()) - } - - #[test] - fn snapshot_ignores_large_untracked_files() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join("tracked.txt"), "contents\n")?; - run_git_in(repo, &["add", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let big = repo.join("big.bin"); - let big_size = 2 * 1024 * 1024; - create_sparse_file(&big, big_size)?; - - let (ghost, report) = create_ghost_commit_with_report( - &CreateGhostCommitOptions::new(repo).ignore_large_untracked_files(/*bytes*/ 1024), - )?; - assert!(ghost.parent().is_some()); - assert_eq!( - report.ignored_untracked_files, - vec![IgnoredUntrackedFile { - path: PathBuf::from("big.bin"), - byte_size: big_size, - }] - ); - - let exists_in_commit = Command::new("git") - .current_dir(repo) - .args(["cat-file", "-e", &format!("{}:big.bin", ghost.id())]) - .status() - .map(|status| status.success()) - .unwrap_or(false); - assert!(!exists_in_commit); - - std::fs::write(repo.join("ephemeral.txt"), "temp\n")?; - restore_ghost_commit(repo, &ghost)?; - assert!( - big.exists(), - "big.bin should be preserved during undo cleanup" - ); - assert!(!repo.join("ephemeral.txt").exists()); - - Ok(()) - } - - #[test] - fn create_snapshot_reports_large_untracked_dirs() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join("tracked.txt"), "contents\n")?; - run_git_in(repo, &["add", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let models = repo.join("models"); - std::fs::create_dir(&models)?; - let threshold = DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS; - for idx in 0..(threshold + 1) { - let file = models.join(format!("weights-{idx}.bin")); - std::fs::write(file, "data\n")?; - } - - let (ghost, report) = - create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; - assert!(ghost.parent().is_some()); - assert_eq!( - report.large_untracked_dirs, - vec![LargeUntrackedDir { - path: PathBuf::from("models"), - file_count: threshold + 1, - }] - ); - - let exists_in_commit = Command::new("git") - .current_dir(repo) - .args([ - "cat-file", - "-e", - &format!("{}:models/weights-0.bin", ghost.id()), - ]) - .status() - .map(|status| status.success()) - .unwrap_or(false); - assert!(!exists_in_commit); - - std::fs::write(repo.join("ephemeral.txt"), "temp\n")?; - restore_ghost_commit(repo, &ghost)?; - assert!( - repo.join("models/weights-0.bin").exists(), - "ignored untracked directories should be preserved during undo cleanup" - ); - assert!(!repo.join("ephemeral.txt").exists()); - - Ok(()) - } - - #[test] - fn restore_preserves_large_untracked_dirs_when_threshold_disabled() - -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join("tracked.txt"), "contents\n")?; - run_git_in(repo, &["add", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let models = repo.join("models"); - std::fs::create_dir(&models)?; - let threshold: i64 = 2; - for idx in 0..(threshold + 1) { - let file = models.join(format!("weights-{idx}.bin")); - std::fs::write(file, "data\n")?; - } - - let snapshot_config = GhostSnapshotConfig { - ignore_large_untracked_files: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_FILES), - ignore_large_untracked_dirs: Some(threshold), - disable_warnings: false, - }; - let (ghost, _report) = create_ghost_commit_with_report( - &CreateGhostCommitOptions::new(repo).ghost_snapshot(snapshot_config), - )?; - - std::fs::write(repo.join("ephemeral.txt"), "temp\n")?; - restore_ghost_commit_with_options( - &RestoreGhostCommitOptions::new(repo) - .ignore_large_untracked_dirs(/*file_count*/ 0), - &ghost, - )?; - - assert!( - repo.join("models/weights-0.bin").exists(), - "ignored untracked directories should be preserved during undo cleanup, even when the threshold is disabled at restore time" - ); - assert!(!repo.join("ephemeral.txt").exists()); - - Ok(()) - } - - #[test] - fn snapshot_ignores_default_ignored_directories() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join("tracked.txt"), "contents\n")?; - run_git_in(repo, &["add", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let node_modules = repo.join("node_modules"); - std::fs::create_dir_all(node_modules.join("@scope/package/src"))?; - for idx in 0..50 { - let file = node_modules.join(format!("file-{idx}.js")); - std::fs::write(file, "console.log('ignored');\n")?; - } - std::fs::write( - node_modules.join("@scope/package/src/index.js"), - "console.log('nested ignored');\n", - )?; - - let venv = repo.join(".venv"); - std::fs::create_dir_all(venv.join("lib/python/site-packages"))?; - std::fs::write( - venv.join("lib/python/site-packages/pkg.py"), - "print('ignored')\n", - )?; - - let (ghost, report) = - create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; - assert!(ghost.parent().is_some()); - - for file in ghost.preexisting_untracked_files() { - let components = file.components().collect::>(); - let mut has_default_ignored_component = false; - for component in components { - if let Component::Normal(name) = component - && let Some(name_str) = name.to_str() - && DEFAULT_IGNORED_DIR_NAMES - .iter() - .any(|ignored| ignored == &name_str) - { - has_default_ignored_component = true; - break; - } - } - assert!( - !has_default_ignored_component, - "unexpected default-ignored file captured: {file:?}" - ); - } - - for dir in ghost.preexisting_untracked_dirs() { - let components = dir.components().collect::>(); - let mut has_default_ignored_component = false; - for component in components { - if let Component::Normal(name) = component - && let Some(name_str) = name.to_str() - && DEFAULT_IGNORED_DIR_NAMES - .iter() - .any(|ignored| ignored == &name_str) - { - has_default_ignored_component = true; - break; - } - } - assert!( - !has_default_ignored_component, - "unexpected default-ignored dir captured: {dir:?}" - ); - } - - for entry in &report.large_untracked_dirs { - let components = entry.path.components().collect::>(); - let mut has_default_ignored_component = false; - for component in components { - if let Component::Normal(name) = component - && let Some(name_str) = name.to_str() - && DEFAULT_IGNORED_DIR_NAMES - .iter() - .any(|ignored| ignored == &name_str) - { - has_default_ignored_component = true; - break; - } - } - assert!( - !has_default_ignored_component, - "unexpected default-ignored dir in large_untracked_dirs: {:?}", - entry.path - ); - } - - Ok(()) - } - - #[test] - fn restore_preserves_default_ignored_directories() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; - run_git_in(repo, &["add", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let node_modules = repo.join("node_modules"); - std::fs::create_dir_all(node_modules.join("pkg"))?; - std::fs::write( - node_modules.join("pkg/index.js"), - "console.log('before');\n", - )?; - - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; - - std::fs::write(repo.join("tracked.txt"), "snapshot delta\n")?; - std::fs::write(node_modules.join("pkg/index.js"), "console.log('after');\n")?; - std::fs::write(node_modules.join("pkg/extra.js"), "console.log('extra');\n")?; - std::fs::write(repo.join("temp.txt"), "new file\n")?; - - restore_ghost_commit(repo, &ghost)?; - - let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?; - assert_eq!(tracked_after, "snapshot version\n"); - - let node_modules_exists = node_modules.exists(); - assert!(node_modules_exists); - - let files_under_node_modules: Vec<_> = WalkDir::new(&node_modules) - .into_iter() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_file()) - .collect(); - assert!(!files_under_node_modules.is_empty()); - - assert!(!repo.join("temp.txt").exists()); - - Ok(()) - } - - #[test] - fn create_snapshot_reports_nested_large_untracked_dirs_under_tracked_parent() - -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - // Create a tracked src directory. - let src = repo.join("src"); - std::fs::create_dir(&src)?; - std::fs::write(src.join("main.rs"), "fn main() {}\n")?; - run_git_in(repo, &["add", "src/main.rs"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - // Create a large untracked tree nested under the tracked src directory. - let generated = src.join("generated").join("cache"); - std::fs::create_dir_all(&generated)?; - let threshold = DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS; - for idx in 0..(threshold + 1) { - let file = generated.join(format!("file-{idx}.bin")); - std::fs::write(file, "data\n")?; - } - - let (ghost, report) = - create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; - assert_eq!(report.large_untracked_dirs.len(), 1); - let entry = &report.large_untracked_dirs[0]; - assert_ne!(entry.path, PathBuf::from("src")); - assert!( - entry.path.starts_with(Path::new("src/generated")), - "unexpected path for large untracked directory: {}", - entry.path.display() - ); - assert_eq!(entry.file_count, threshold + 1); - - let exists_in_commit = Command::new("git") - .current_dir(repo) - .args([ - "cat-file", - "-e", - &format!("{}:src/generated/cache/file-0.bin", ghost.id()), - ]) - .status() - .map(|status| status.success()) - .unwrap_or(false); - assert!(!exists_in_commit); - - Ok(()) - } - - #[test] - /// Ensures ghost commits succeed in repositories without an existing HEAD. - fn create_snapshot_without_existing_head() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - let tracked_contents = "first contents\n"; - std::fs::write(repo.join("tracked.txt"), tracked_contents)?; - let ignored_contents = "ignored but captured\n"; - std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; - std::fs::write(repo.join("ignored.txt"), ignored_contents)?; - - let options = - CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]); - let ghost = create_ghost_commit(&options)?; - - assert!(ghost.parent().is_none()); - - let message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]); - assert_eq!(message, DEFAULT_COMMIT_MESSAGE); - - let ignored = run_git_stdout(repo, &["show", &format!("{}:ignored.txt", ghost.id())]); - assert_eq!(ignored, ignored_contents.trim()); - - Ok(()) - } - - #[test] - /// Confirms custom messages are used when creating ghost commits. - fn create_ghost_commit_uses_custom_message() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join("tracked.txt"), "contents\n")?; - run_git_in(repo, &["add", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let message = "custom message"; - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo).message(message))?; - let commit_message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]); - assert_eq!(commit_message, message); - - Ok(()) - } - - #[test] - /// Rejects force-included paths that escape the repository. - fn create_ghost_commit_rejects_force_include_parent_path() { - let temp = tempfile::tempdir().expect("tempdir"); - let repo = temp.path(); - init_test_repo(repo); - let options = CreateGhostCommitOptions::new(repo) - .force_include(vec![PathBuf::from("../outside.txt")]); - let err = create_ghost_commit(&options).unwrap_err(); - assert_matches!(err, GitToolingError::PathEscapesRepository { .. }); - } - - #[test] - /// Restoring a ghost commit from a non-git directory fails. - fn restore_requires_git_repository() { - let temp = tempfile::tempdir().expect("tempdir"); - let err = restore_to_commit(temp.path(), "deadbeef").unwrap_err(); - assert_matches!(err, GitToolingError::NotAGitRepository { .. }); - } - - #[test] - /// Restoring from a subdirectory affects only that subdirectory. - fn restore_from_subdirectory_restores_files_relatively() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::create_dir_all(repo.join("workspace"))?; - let workspace = repo.join("workspace"); - std::fs::write(repo.join("root.txt"), "root contents\n")?; - std::fs::write(workspace.join("nested.txt"), "nested contents\n")?; - run_git_in(repo, &["add", "."]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - std::fs::write(repo.join("root.txt"), "root modified\n")?; - std::fs::write(workspace.join("nested.txt"), "nested modified\n")?; - - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?; - - std::fs::write(repo.join("root.txt"), "root after\n")?; - std::fs::write(workspace.join("nested.txt"), "nested after\n")?; - - restore_ghost_commit(&workspace, &ghost)?; - - let root_after = std::fs::read_to_string(repo.join("root.txt"))?; - assert_eq!(root_after, "root after\n"); - let nested_after = std::fs::read_to_string(workspace.join("nested.txt"))?; - assert_eq!(nested_after, "nested modified\n"); - assert!(!workspace.join("codex-rs").exists()); - - Ok(()) - } - - #[test] - /// Restoring from a subdirectory preserves ignored files in parent folders. - fn restore_from_subdirectory_preserves_parent_vscode() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - let workspace = repo.join("codex-rs"); - std::fs::create_dir_all(&workspace)?; - std::fs::write(repo.join(".gitignore"), ".vscode/\n")?; - std::fs::write(workspace.join("tracked.txt"), "snapshot version\n")?; - run_git_in(repo, &["add", "."]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - std::fs::write(workspace.join("tracked.txt"), "snapshot delta\n")?; - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?; - - std::fs::write(workspace.join("tracked.txt"), "post-snapshot\n")?; - let vscode = repo.join(".vscode"); - std::fs::create_dir_all(&vscode)?; - std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?; - - restore_ghost_commit(&workspace, &ghost)?; - - let tracked_after = std::fs::read_to_string(workspace.join("tracked.txt"))?; - assert_eq!(tracked_after, "snapshot delta\n"); - assert!(vscode.join("settings.json").exists()); - let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?; - assert_eq!(settings_after, "{\n \"after\": true\n}\n"); - - Ok(()) - } - - #[test] - /// Restoring from the repository root keeps ignored files intact. - fn restore_preserves_ignored_files() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join(".gitignore"), ".vscode/\n")?; - std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; - let vscode = repo.join(".vscode"); - std::fs::create_dir_all(&vscode)?; - std::fs::write(vscode.join("settings.json"), "{\n \"before\": true\n}\n")?; - run_git_in(repo, &["add", ".gitignore", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - std::fs::write(repo.join("tracked.txt"), "snapshot delta\n")?; - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; - - std::fs::write(repo.join("tracked.txt"), "post-snapshot\n")?; - std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?; - std::fs::write(repo.join("temp.txt"), "new file\n")?; - - restore_ghost_commit(repo, &ghost)?; - - let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?; - assert_eq!(tracked_after, "snapshot delta\n"); - assert!(vscode.join("settings.json").exists()); - let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?; - assert_eq!(settings_after, "{\n \"after\": true\n}\n"); - assert!(!repo.join("temp.txt").exists()); - - Ok(()) - } - - #[test] - /// Restoring leaves ignored directories created after the snapshot untouched. - fn restore_preserves_new_ignored_directory() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join(".gitignore"), ".vscode/\n")?; - std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; - run_git_in(repo, &["add", ".gitignore", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; - - let vscode = repo.join(".vscode"); - std::fs::create_dir_all(&vscode)?; - std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?; - - restore_ghost_commit(repo, &ghost)?; - - assert!(vscode.exists()); - let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?; - assert_eq!(settings_after, "{\n \"after\": true\n}\n"); - - Ok(()) - } - - #[test] - /// Restoring leaves ignored files created after the snapshot untouched. - fn restore_preserves_new_ignored_file() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; - std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; - run_git_in(repo, &["add", ".gitignore", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; - - let ignored = repo.join("ignored.txt"); - std::fs::write(&ignored, "created later\n")?; - - restore_ghost_commit(repo, &ghost)?; - - assert!(ignored.exists()); - let contents = std::fs::read_to_string(&ignored)?; - assert_eq!(contents, "created later\n"); - - Ok(()) - } - - #[test] - /// Restoring keeps deleted ignored files deleted when they were absent before the snapshot. - fn restore_respects_removed_ignored_file() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; - std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; - let ignored = repo.join("ignored.txt"); - std::fs::write(&ignored, "initial state\n")?; - run_git_in(repo, &["add", ".gitignore", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; - - std::fs::remove_file(&ignored)?; - - restore_ghost_commit(repo, &ghost)?; - - assert!(!ignored.exists()); - - Ok(()) - } - - #[test] - /// Restoring leaves files matched by glob ignores intact. - fn restore_preserves_ignored_glob_matches() -> Result<(), GitToolingError> { - let temp = tempfile::tempdir()?; - let repo = temp.path(); - init_test_repo(repo); - - std::fs::write(repo.join(".gitignore"), "dummy-dir/*.txt\n")?; - std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; - run_git_in(repo, &["add", ".gitignore", "tracked.txt"]); - run_git_in( - repo, - &[ - "-c", - "user.name=Tester", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "initial", - ], - ); - - let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; - - let dummy_dir = repo.join("dummy-dir"); - std::fs::create_dir_all(&dummy_dir)?; - let file1 = dummy_dir.join("file1.txt"); - let file2 = dummy_dir.join("file2.txt"); - std::fs::write(&file1, "first\n")?; - std::fs::write(&file2, "second\n")?; - - restore_ghost_commit(repo, &ghost)?; - - assert!(file1.exists()); - assert!(file2.exists()); - assert_eq!(std::fs::read_to_string(file1)?, "first\n"); - assert_eq!(std::fs::read_to_string(file2)?, "second\n"); - - Ok(()) - } -} diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index ea7685b67..63eaf586d 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -2,7 +2,6 @@ mod apply; mod baseline; mod branch; mod errors; -mod ghost_commits; mod info; mod operations; mod platform; @@ -20,21 +19,8 @@ pub use baseline::diff_since_latest_init; pub use baseline::ensure_git_baseline_repository; pub use baseline::reset_git_repository; pub use branch::merge_base_with_head; -pub use codex_protocol::models::GhostCommit; pub use codex_protocol::protocol::GitSha; pub use errors::GitToolingError; -pub use ghost_commits::CreateGhostCommitOptions; -pub use ghost_commits::GhostSnapshotConfig; -pub use ghost_commits::GhostSnapshotReport; -pub use ghost_commits::IgnoredUntrackedFile; -pub use ghost_commits::LargeUntrackedDir; -pub use ghost_commits::RestoreGhostCommitOptions; -pub use ghost_commits::capture_ghost_snapshot_report; -pub use ghost_commits::create_ghost_commit; -pub use ghost_commits::create_ghost_commit_with_report; -pub use ghost_commits::restore_ghost_commit; -pub use ghost_commits::restore_ghost_commit_with_options; -pub use ghost_commits::restore_to_commit; pub use info::CommitLogEntry; pub use info::GitDiffToRemote; pub use info::GitInfo; diff --git a/codex-rs/git-utils/src/operations.rs b/codex-rs/git-utils/src/operations.rs index 51db04e42..921a5fd69 100644 --- a/codex-rs/git-utils/src/operations.rs +++ b/codex-rs/git-utils/src/operations.rs @@ -1,6 +1,5 @@ use std::ffi::OsStr; use std::ffi::OsString; -use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -45,38 +44,6 @@ pub(crate) fn resolve_head(path: &Path) -> Result, GitToolingErro } } -pub(crate) fn normalize_relative_path(path: &Path) -> Result { - let mut result = PathBuf::new(); - let mut saw_component = false; - for component in path.components() { - saw_component = true; - match component { - Component::Normal(part) => result.push(part), - Component::CurDir => {} - Component::ParentDir => { - if !result.pop() { - return Err(GitToolingError::PathEscapesRepository { - path: path.to_path_buf(), - }); - } - } - Component::RootDir | Component::Prefix(_) => { - return Err(GitToolingError::NonRelativePath { - path: path.to_path_buf(), - }); - } - } - } - - if !saw_component { - return Err(GitToolingError::NonRelativePath { - path: path.to_path_buf(), - }); - } - - Ok(result) -} - pub(crate) fn resolve_repository_root(path: &Path) -> Result { let root = run_git_for_stdout( path, @@ -89,47 +56,6 @@ pub(crate) fn resolve_repository_root(path: &Path) -> Result, - paths: &[PathBuf], -) -> Vec { - if paths.is_empty() { - return Vec::new(); - } - - match prefix { - Some(prefix) => paths.iter().map(|path| prefix.join(path)).collect(), - None => paths.to_vec(), - } -} - -pub(crate) fn repo_subdir(repo_root: &Path, repo_path: &Path) -> Option { - if repo_root == repo_path { - return None; - } - - repo_path - .strip_prefix(repo_root) - .ok() - .and_then(non_empty_path) - .or_else(|| { - let repo_root_canon = repo_root.canonicalize().ok()?; - let repo_path_canon = repo_path.canonicalize().ok()?; - repo_path_canon - .strip_prefix(&repo_root_canon) - .ok() - .and_then(non_empty_path) - }) -} - -fn non_empty_path(path: &Path) -> Option { - if path.as_os_str().is_empty() { - None - } else { - Some(path.to_path_buf()) - } -} - pub(crate) fn run_git_for_status( dir: &Path, args: I, @@ -161,27 +87,6 @@ where }) } -/// Executes `git` and returns the full stdout without trimming so callers -/// can parse delimiter-sensitive output, propagating UTF-8 errors with context. -pub(crate) fn run_git_for_stdout_all( - dir: &Path, - args: I, - env: Option<&[(OsString, OsString)]>, -) -> Result -where - I: IntoIterator, - S: AsRef, -{ - // Keep the raw stdout untouched so callers can parse delimiter-sensitive - // output (e.g. NUL-separated paths) without trimming artefacts. - let run = run_git(dir, args, env)?; - // Propagate UTF-8 conversion failures with the command context for debugging. - String::from_utf8(run.output.stdout).map_err(|source| GitToolingError::GitOutputUtf8 { - command: run.command, - source, - }) -} - fn run_git( dir: &Path, args: I, diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 1ca4a492e..38d361e7a 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1078,7 +1078,6 @@ impl SessionTelemetry { ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output".into(), ResponseItem::WebSearchCall { .. } => "web_search_call".into(), ResponseItem::ImageGenerationCall { .. } => "image_generation_call".into(), - ResponseItem::GhostSnapshot { .. } => "ghost_snapshot".into(), ResponseItem::Compaction { .. } => "compaction".into(), ResponseItem::Other => "other".into(), } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 735131c5f..0431974d4 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,9 +1,7 @@ use std::collections::HashMap; -use std::fmt; use std::io; use std::num::NonZeroUsize; use std::path::Path; -use std::path::PathBuf; use codex_utils_image::PromptImageMode; use codex_utils_image::load_for_prompt_bytes; @@ -28,60 +26,6 @@ use schemars::JsonSchema; use crate::mcp::CallToolResult; -type CommitID = String; - -/// Details of a ghost commit created from a repository state. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct GhostCommit { - id: CommitID, - parent: Option, - preexisting_untracked_files: Vec, - preexisting_untracked_dirs: Vec, -} - -impl GhostCommit { - /// Create a new ghost commit wrapper from a raw commit ID and optional parent. - pub fn new( - id: CommitID, - parent: Option, - preexisting_untracked_files: Vec, - preexisting_untracked_dirs: Vec, - ) -> Self { - Self { - id, - parent, - preexisting_untracked_files, - preexisting_untracked_dirs, - } - } - - /// Commit ID for the snapshot. - pub fn id(&self) -> &str { - &self.id - } - - /// Parent commit ID, if the repository had a `HEAD` at creation time. - pub fn parent(&self) -> Option<&str> { - self.parent.as_deref() - } - - /// Untracked or ignored files that already existed when the snapshot was captured. - pub fn preexisting_untracked_files(&self) -> &[PathBuf] { - &self.preexisting_untracked_files - } - - /// Untracked or ignored directories that already existed when the snapshot was captured. - pub fn preexisting_untracked_dirs(&self) -> &[PathBuf] { - &self.preexisting_untracked_dirs - } -} - -impl fmt::Display for GhostCommit { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.id) - } -} - /// Controls the per-command sandbox override requested by a shell-like tool call. #[derive( Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS, @@ -879,14 +823,8 @@ pub enum ResponseItem { revised_prompt: Option, result: String, }, - // Generated by the harness but considered exactly as a model response. - GhostSnapshot { - ghost_commit: GhostCommit, - }, #[serde(alias = "compaction_summary")] - Compaction { - encrypted_content: String, - }, + Compaction { encrypted_content: String }, #[serde(other)] Other, } @@ -1640,6 +1578,7 @@ mod tests { use anyhow::Result; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; + use std::path::PathBuf; use tempfile::tempdir; #[test] @@ -2380,6 +2319,24 @@ mod tests { Ok(()) } + #[test] + fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> { + let json = r#"{ + "type":"ghost_snapshot", + "ghost_commit":{ + "id":"ghost-1", + "parent":null, + "preexisting_untracked_files":[], + "preexisting_untracked_dirs":[] + } + }"#; + + let item: ResponseItem = serde_json::from_str(json)?; + + assert_eq!(item, ResponseItem::Other); + Ok(()) + } + #[test] fn roundtrips_web_search_call_actions() -> Result<()> { let cases = vec![ diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0e46b97b9..85ef863b8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -775,7 +775,10 @@ pub enum Op { /// model. SetThreadMemoryMode { mode: ThreadMemoryMode }, - /// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z). + /// Legacy request to undo a turn. + /// + /// The op is still accepted for compatibility, but ghost snapshots are no + /// longer produced so the request reports unavailable. Undo, /// Request Codex to drop the last N user turns from in-memory context. diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 8459f96c1..22615623f 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -37,7 +37,6 @@ pub fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } => true, ResponseItem::Other => false, } @@ -58,7 +57,6 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::WebSearchCall { .. } => true, ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } | ResponseItem::Other => false, } diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index f24cd9db9..dc2f08b7a 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -867,7 +867,7 @@ impl RolloutRecorder { if line.trim().is_empty() { continue; } - let v: Value = match serde_json::from_str(line) { + let mut v: Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => { warn!("failed to parse line as JSON: {line:?}, error: {e}"); @@ -875,6 +875,10 @@ impl RolloutRecorder { continue; } }; + if strip_legacy_ghost_snapshot_rollout_line(&mut v) { + trace!("skipping legacy ghost_snapshot rollout line"); + continue; + } // Parse the rollout line structure match serde_json::from_value::(v.clone()) { @@ -961,6 +965,29 @@ impl RolloutRecorder { } } +fn strip_legacy_ghost_snapshot_rollout_line(value: &mut Value) -> bool { + match value.get("type").and_then(Value::as_str) { + Some("response_item") => value + .get("payload") + .is_some_and(is_legacy_ghost_snapshot_response_item), + Some("compacted") => { + if let Some(replacement_history) = value + .get_mut("payload") + .and_then(|payload| payload.get_mut("replacement_history")) + .and_then(Value::as_array_mut) + { + replacement_history.retain(|item| !is_legacy_ghost_snapshot_response_item(item)); + } + false + } + _ => false, + } +} + +fn is_legacy_ghost_snapshot_response_item(value: &Value) -> bool { + value.get("type").and_then(Value::as_str) == Some("ghost_snapshot") +} + fn truncate_fs_page( mut page: ThreadsPage, page_size: usize, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index a53731bec..0138db72d 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -4,9 +4,12 @@ use super::*; use crate::config::RolloutConfig; use chrono::TimeZone; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::UserMessageEvent; @@ -62,6 +65,161 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let rollout_path = home.path().join("rollout.jsonl"); + let mut file = File::create(&rollout_path)?; + let thread_id = ThreadId::new(); + let ts = "2025-01-03T12:00:00Z"; + + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": ts, + "type": "session_meta", + "payload": { + "id": thread_id, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "cli", + "model_provider": "test-provider", + }, + }) + )?; + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": ts, + "type": "response_item", + "payload": { + "type": "ghost_snapshot", + "ghost_commit": { + "id": "deadbeef", + "preexisting_untracked_dirs": [], + "preexisting_untracked_files": [], + }, + }, + }) + )?; + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": ts, + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "hello", + } + ], + }, + }) + )?; + + let (items, loaded_thread_id, parse_errors) = + RolloutRecorder::load_rollout_items(&rollout_path).await?; + + assert_eq!(loaded_thread_id, Some(thread_id)); + assert_eq!(parse_errors, 0); + assert_eq!(items.len(), 2); + assert!(matches!(items[0], RolloutItem::SessionMeta(_))); + assert!(matches!( + items[1], + RolloutItem::ResponseItem(ResponseItem::Message { .. }) + )); + + Ok(()) +} + +#[tokio::test] +async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_history() +-> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let rollout_path = home.path().join("rollout.jsonl"); + let mut file = File::create(&rollout_path)?; + let thread_id = ThreadId::new(); + let ts = "2025-01-03T12:00:00Z"; + + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": ts, + "type": "session_meta", + "payload": { + "id": thread_id, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "cli", + "model_provider": "test-provider", + }, + }) + )?; + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": ts, + "type": "compacted", + "payload": { + "message": "summary", + "replacement_history": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "kept", + } + ], + }, + { + "type": "ghost_snapshot", + "ghost_commit": { + "id": "deadbeef", + "preexisting_untracked_dirs": [], + "preexisting_untracked_files": [], + }, + } + ], + }, + }) + )?; + + let (items, loaded_thread_id, parse_errors) = + RolloutRecorder::load_rollout_items(&rollout_path).await?; + + assert_eq!(loaded_thread_id, Some(thread_id)); + assert_eq!(parse_errors, 0); + assert_eq!(items.len(), 2); + let RolloutItem::Compacted(compacted) = &items[1] else { + panic!("expected compacted rollout item"); + }; + let replacement_history = compacted + .replacement_history + .as_ref() + .expect("replacement history"); + assert_eq!(replacement_history.len(), 1); + assert!(matches!( + &replacement_history[0], + ResponseItem::Message { .. } + )); + + Ok(()) +} + #[tokio::test] async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap index 9cb2d7852..c5211f694 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap @@ -1,11 +1,13 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Experimental features Toggle experimental features. Changes are saved to config.toml. -› [ ] Ghost snapshots Capture undo snapshots each turn. +› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for + interactive website debugging and other inline + JavaScript execution capabilities. [x] Shell tool Allow the model to run shell commands. Press space to select or enter to save for next conversation diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 3977d9d0e..728cbf017 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1692,9 +1692,9 @@ async fn experimental_features_popup_snapshot() { let features = vec![ ExperimentalFeatureItem { - feature: Feature::GhostCommit, - name: "Ghost snapshots".to_string(), - description: "Capture undo snapshots each turn.".to_string(), + feature: Feature::JsRepl, + name: "JavaScript REPL".to_string(), + description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(), enabled: false, }, ExperimentalFeatureItem { @@ -1715,12 +1715,12 @@ async fn experimental_features_popup_snapshot() { async fn experimental_features_toggle_saves_on_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let expected_feature = Feature::GhostCommit; + let expected_feature = Feature::JsRepl; let view = ExperimentalFeaturesView::new( vec![ExperimentalFeatureItem { feature: expected_feature, - name: "Ghost snapshots".to_string(), - description: "Capture undo snapshots each turn.".to_string(), + name: "JavaScript REPL".to_string(), + description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(), enabled: false, }], chat.app_event_tx.clone(),