Skip to content

Commit 4274e61

Browse files
authored
feat: config ghost commits (openai#7873)
1 parent fc53411 commit 4274e61

File tree

7 files changed

+695
-61
lines changed

7 files changed

+695
-61
lines changed

codex-rs/core/src/codex.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ use crate::client_common::Prompt;
7777
use crate::client_common::ResponseEvent;
7878
use crate::compact::collect_user_messages;
7979
use crate::config::Config;
80+
use crate::config::GhostSnapshotConfig;
8081
use crate::config::types::ShellEnvironmentPolicy;
8182
use crate::context_manager::ContextManager;
8283
use crate::environment_context::EnvironmentContext;
@@ -364,6 +365,7 @@ pub(crate) struct TurnContext {
364365
pub(crate) sandbox_policy: SandboxPolicy,
365366
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
366367
pub(crate) tools_config: ToolsConfig,
368+
pub(crate) ghost_snapshot: GhostSnapshotConfig,
367369
pub(crate) final_output_json_schema: Option<Value>,
368370
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
369371
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
@@ -525,6 +527,7 @@ impl Session {
525527
sandbox_policy: session_configuration.sandbox_policy.clone(),
526528
shell_environment_policy: per_turn_config.shell_environment_policy.clone(),
527529
tools_config,
530+
ghost_snapshot: per_turn_config.ghost_snapshot.clone(),
528531
final_output_json_schema: None,
529532
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
530533
tool_call_gate: Arc::new(ReadinessFlag::new()),
@@ -2070,6 +2073,7 @@ async fn spawn_review_thread(
20702073
sub_id: sub_id.to_string(),
20712074
client,
20722075
tools_config,
2076+
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
20732077
developer_instructions: None,
20742078
user_instructions: None,
20752079
base_instructions: Some(base_instructions.clone()),

codex-rs/core/src/config/mod.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ pub use service::ConfigServiceError;
6464

6565
const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max";
6666

67+
pub use codex_git::GhostSnapshotConfig;
68+
6769
/// Maximum number of bytes of the documentation that will be embedded. Larger
6870
/// files are *silently truncated* to this size so we do not take up too much of
6971
/// the context window.
@@ -266,6 +268,9 @@ pub struct Config {
266268
/// https://github.com/modelcontextprotocol/rust-sdk
267269
pub use_experimental_use_rmcp_client: bool,
268270

271+
/// Settings for ghost snapshots (used for undo).
272+
pub ghost_snapshot: GhostSnapshotConfig,
273+
269274
/// Centralized feature flags; source of truth for feature gating.
270275
pub features: Features,
271276

@@ -658,6 +663,10 @@ pub struct ConfigToml {
658663
#[serde(default)]
659664
pub features: Option<FeaturesToml>,
660665

666+
/// Settings for ghost snapshots (used for undo).
667+
#[serde(default)]
668+
pub ghost_snapshot: Option<GhostSnapshotToml>,
669+
661670
/// When `true`, checks for Codex updates on startup and surfaces update prompts.
662671
/// Set to `false` only if your Codex updates are centrally managed.
663672
/// Defaults to `true`.
@@ -747,6 +756,17 @@ impl From<ToolsToml> for Tools {
747756
}
748757
}
749758

759+
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
760+
pub struct GhostSnapshotToml {
761+
/// Exclude untracked files larger than this many bytes from ghost snapshots.
762+
#[serde(alias = "ignore_untracked_files_over_bytes")]
763+
pub ignore_large_untracked_files: Option<i64>,
764+
/// Ignore untracked directories that contain this many files or more.
765+
/// (Still emits a warning.)
766+
#[serde(alias = "large_untracked_dir_warning_threshold")]
767+
pub ignore_large_untracked_dirs: Option<i64>,
768+
}
769+
750770
#[derive(Debug, PartialEq, Eq)]
751771
pub struct SandboxPolicyResolution {
752772
pub policy: SandboxPolicy,
@@ -1047,6 +1067,26 @@ impl Config {
10471067

10481068
let history = cfg.history.unwrap_or_default();
10491069

1070+
let ghost_snapshot = {
1071+
let mut config = GhostSnapshotConfig::default();
1072+
if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref()
1073+
&& let Some(ignore_over_bytes) = ghost_snapshot.ignore_large_untracked_files
1074+
{
1075+
config.ignore_large_untracked_files = if ignore_over_bytes > 0 {
1076+
Some(ignore_over_bytes)
1077+
} else {
1078+
None
1079+
};
1080+
}
1081+
if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref()
1082+
&& let Some(threshold) = ghost_snapshot.ignore_large_untracked_dirs
1083+
{
1084+
config.ignore_large_untracked_dirs =
1085+
if threshold > 0 { Some(threshold) } else { None };
1086+
}
1087+
config
1088+
};
1089+
10501090
let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform);
10511091
let tools_web_search_request = features.enabled(Feature::WebSearchRequest);
10521092
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
@@ -1179,6 +1219,7 @@ impl Config {
11791219
tools_web_search_request,
11801220
use_experimental_unified_exec_tool,
11811221
use_experimental_use_rmcp_client,
1222+
ghost_snapshot,
11821223
features,
11831224
active_profile: active_profile_name,
11841225
active_project,
@@ -2940,6 +2981,7 @@ model_verbosity = "high"
29402981
tools_web_search_request: false,
29412982
use_experimental_unified_exec_tool: false,
29422983
use_experimental_use_rmcp_client: false,
2984+
ghost_snapshot: GhostSnapshotConfig::default(),
29432985
features: Features::with_defaults(),
29442986
active_profile: Some("o3".to_string()),
29452987
active_project: ProjectConfig { trust_level: None },
@@ -3014,6 +3056,7 @@ model_verbosity = "high"
30143056
tools_web_search_request: false,
30153057
use_experimental_unified_exec_tool: false,
30163058
use_experimental_use_rmcp_client: false,
3059+
ghost_snapshot: GhostSnapshotConfig::default(),
30173060
features: Features::with_defaults(),
30183061
active_profile: Some("gpt3".to_string()),
30193062
active_project: ProjectConfig { trust_level: None },
@@ -3103,6 +3146,7 @@ model_verbosity = "high"
31033146
tools_web_search_request: false,
31043147
use_experimental_unified_exec_tool: false,
31053148
use_experimental_use_rmcp_client: false,
3149+
ghost_snapshot: GhostSnapshotConfig::default(),
31063150
features: Features::with_defaults(),
31073151
active_profile: Some("zdr".to_string()),
31083152
active_project: ProjectConfig { trust_level: None },
@@ -3178,6 +3222,7 @@ model_verbosity = "high"
31783222
tools_web_search_request: false,
31793223
use_experimental_unified_exec_tool: false,
31803224
use_experimental_use_rmcp_client: false,
3225+
ghost_snapshot: GhostSnapshotConfig::default(),
31813226
features: Features::with_defaults(),
31823227
active_profile: Some("gpt5".to_string()),
31833228
active_project: ProjectConfig { trust_level: None },

codex-rs/core/src/tasks/ghost_snapshot.rs

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,30 +73,43 @@ impl SessionTask for GhostSnapshotTask {
7373
_ = cancellation_token.cancelled() => true,
7474
_ = async {
7575
let repo_path = ctx_for_task.cwd.clone();
76+
let ghost_snapshot = ctx_for_task.ghost_snapshot.clone();
77+
let ignore_large_untracked_dirs = ghost_snapshot.ignore_large_untracked_dirs;
7678
// First, compute a snapshot report so we can warn about
7779
// large untracked directories before running the heavier
7880
// snapshot logic.
7981
if let Ok(Ok(report)) = tokio::task::spawn_blocking({
8082
let repo_path = repo_path.clone();
83+
let ghost_snapshot = ghost_snapshot.clone();
8184
move || {
82-
let options = CreateGhostCommitOptions::new(&repo_path);
85+
let options =
86+
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
8387
capture_ghost_snapshot_report(&options)
8488
}
8589
})
8690
.await
87-
&& let Some(message) = format_large_untracked_warning(&report) {
88-
session
89-
.session
90-
.send_event(
91-
&ctx_for_task,
92-
EventMsg::Warning(WarningEvent { message }),
93-
)
94-
.await;
95-
}
91+
{
92+
for message in
93+
format_snapshot_warnings(
94+
ghost_snapshot.ignore_large_untracked_files,
95+
ignore_large_untracked_dirs,
96+
&report,
97+
)
98+
{
99+
session
100+
.session
101+
.send_event(
102+
&ctx_for_task,
103+
EventMsg::Warning(WarningEvent { message }),
104+
)
105+
.await;
106+
}
107+
}
96108

97109
// Required to run in a dedicated blocking pool.
98110
match tokio::task::spawn_blocking(move || {
99-
let options = CreateGhostCommitOptions::new(&repo_path);
111+
let options =
112+
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
100113
create_ghost_commit(&options)
101114
})
102115
.await
@@ -161,10 +174,31 @@ impl GhostSnapshotTask {
161174
}
162175
}
163176

164-
fn format_large_untracked_warning(report: &GhostSnapshotReport) -> Option<String> {
177+
fn format_snapshot_warnings(
178+
ignore_large_untracked_files: Option<i64>,
179+
ignore_large_untracked_dirs: Option<i64>,
180+
report: &GhostSnapshotReport,
181+
) -> Vec<String> {
182+
let mut warnings = Vec::new();
183+
if let Some(message) = format_large_untracked_warning(ignore_large_untracked_dirs, report) {
184+
warnings.push(message);
185+
}
186+
if let Some(message) =
187+
format_ignored_untracked_files_warning(ignore_large_untracked_files, report)
188+
{
189+
warnings.push(message);
190+
}
191+
warnings
192+
}
193+
194+
fn format_large_untracked_warning(
195+
ignore_large_untracked_dirs: Option<i64>,
196+
report: &GhostSnapshotReport,
197+
) -> Option<String> {
165198
if report.large_untracked_dirs.is_empty() {
166199
return None;
167200
}
201+
let threshold = ignore_large_untracked_dirs?;
168202
const MAX_DIRS: usize = 3;
169203
let mut parts: Vec<String> = Vec::new();
170204
for dir in report.large_untracked_dirs.iter().take(MAX_DIRS) {
@@ -175,7 +209,85 @@ fn format_large_untracked_warning(report: &GhostSnapshotReport) -> Option<String
175209
parts.push(format!("{remaining} more"));
176210
}
177211
Some(format!(
178-
"Repository snapshot encountered large untracked directories: {}. This can slow Codex; consider adding these paths to .gitignore or disabling undo in your config.",
212+
"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.",
213+
parts.join(", ")
214+
))
215+
}
216+
217+
fn format_ignored_untracked_files_warning(
218+
ignore_large_untracked_files: Option<i64>,
219+
report: &GhostSnapshotReport,
220+
) -> Option<String> {
221+
let threshold = ignore_large_untracked_files?;
222+
if report.ignored_untracked_files.is_empty() {
223+
return None;
224+
}
225+
226+
const MAX_FILES: usize = 3;
227+
let mut parts: Vec<String> = Vec::new();
228+
for file in report.ignored_untracked_files.iter().take(MAX_FILES) {
229+
parts.push(format!(
230+
"{} ({})",
231+
file.path.display(),
232+
format_bytes(file.byte_size)
233+
));
234+
}
235+
if report.ignored_untracked_files.len() > MAX_FILES {
236+
let remaining = report.ignored_untracked_files.len() - MAX_FILES;
237+
parts.push(format!("{remaining} more"));
238+
}
239+
240+
Some(format!(
241+
"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`.",
242+
format_bytes(threshold),
179243
parts.join(", ")
180244
))
181245
}
246+
247+
fn format_bytes(bytes: i64) -> String {
248+
const KIB: i64 = 1024;
249+
const MIB: i64 = 1024 * 1024;
250+
251+
if bytes >= MIB {
252+
return format!("{} MiB", bytes / MIB);
253+
}
254+
if bytes >= KIB {
255+
return format!("{} KiB", bytes / KIB);
256+
}
257+
format!("{bytes} B")
258+
}
259+
260+
#[cfg(test)]
261+
mod tests {
262+
use super::*;
263+
use codex_git::LargeUntrackedDir;
264+
use pretty_assertions::assert_eq;
265+
use std::path::PathBuf;
266+
267+
#[test]
268+
fn large_untracked_warning_includes_threshold() {
269+
let report = GhostSnapshotReport {
270+
large_untracked_dirs: vec![LargeUntrackedDir {
271+
path: PathBuf::from("models"),
272+
file_count: 250,
273+
}],
274+
ignored_untracked_files: Vec::new(),
275+
};
276+
277+
let message = format_large_untracked_warning(Some(200), &report).unwrap();
278+
assert!(message.contains(">= 200 files"));
279+
}
280+
281+
#[test]
282+
fn large_untracked_warning_disabled_when_threshold_disabled() {
283+
let report = GhostSnapshotReport {
284+
large_untracked_dirs: vec![LargeUntrackedDir {
285+
path: PathBuf::from("models"),
286+
file_count: 250,
287+
}],
288+
ignored_untracked_files: Vec::new(),
289+
};
290+
291+
assert_eq!(format_large_untracked_warning(None, &report), None);
292+
}
293+
}

codex-rs/core/src/tasks/undo.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use crate::state::TaskKind;
88
use crate::tasks::SessionTask;
99
use crate::tasks::SessionTaskContext;
1010
use async_trait::async_trait;
11-
use codex_git::restore_ghost_commit;
11+
use codex_git::RestoreGhostCommitOptions;
12+
use codex_git::restore_ghost_commit_with_options;
1213
use codex_protocol::models::ResponseItem;
1314
use codex_protocol::user_input::UserInput;
1415
use tokio_util::sync::CancellationToken;
@@ -85,9 +86,12 @@ impl SessionTask for UndoTask {
8586

8687
let commit_id = ghost_commit.id().to_string();
8788
let repo_path = ctx.cwd.clone();
88-
let restore_result =
89-
tokio::task::spawn_blocking(move || restore_ghost_commit(&repo_path, &ghost_commit))
90-
.await;
89+
let ghost_snapshot = ctx.ghost_snapshot.clone();
90+
let restore_result = tokio::task::spawn_blocking(move || {
91+
let options = RestoreGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
92+
restore_ghost_commit_with_options(&options, &ghost_commit)
93+
})
94+
.await;
9195

9296
match restore_result {
9397
Ok(Ok(())) => {

0 commit comments

Comments
 (0)