feat: config ghost commits (#7873)

This commit is contained in:
jif-oai
2025-12-15 09:13:06 +01:00
committed by GitHub
parent fc53411938
commit 4274e6189a
7 changed files with 695 additions and 61 deletions
+4
View File
@@ -77,6 +77,7 @@ use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::compact::collect_user_messages;
use crate::config::Config;
use crate::config::GhostSnapshotConfig;
use crate::config::types::ShellEnvironmentPolicy;
use crate::context_manager::ContextManager;
use crate::environment_context::EnvironmentContext;
@@ -364,6 +365,7 @@ pub(crate) struct TurnContext {
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
pub(crate) tools_config: ToolsConfig,
pub(crate) ghost_snapshot: GhostSnapshotConfig,
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
@@ -525,6 +527,7 @@ impl Session {
sandbox_policy: session_configuration.sandbox_policy.clone(),
shell_environment_policy: per_turn_config.shell_environment_policy.clone(),
tools_config,
ghost_snapshot: per_turn_config.ghost_snapshot.clone(),
final_output_json_schema: None,
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
@@ -2070,6 +2073,7 @@ async fn spawn_review_thread(
sub_id: sub_id.to_string(),
client,
tools_config,
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
developer_instructions: None,
user_instructions: None,
base_instructions: Some(base_instructions.clone()),
+45
View File
@@ -64,6 +64,8 @@ pub use service::ConfigServiceError;
const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max";
pub use codex_git::GhostSnapshotConfig;
/// 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
/// the context window.
@@ -266,6 +268,9 @@ pub struct Config {
/// https://github.com/modelcontextprotocol/rust-sdk
pub use_experimental_use_rmcp_client: bool,
/// Settings for ghost snapshots (used for undo).
pub ghost_snapshot: GhostSnapshotConfig,
/// Centralized feature flags; source of truth for feature gating.
pub features: Features,
@@ -658,6 +663,10 @@ pub struct ConfigToml {
#[serde(default)]
pub features: Option<FeaturesToml>,
/// Settings for ghost snapshots (used for undo).
#[serde(default)]
pub ghost_snapshot: Option<GhostSnapshotToml>,
/// When `true`, checks for Codex updates on startup and surfaces update prompts.
/// Set to `false` only if your Codex updates are centrally managed.
/// Defaults to `true`.
@@ -747,6 +756,17 @@ impl From<ToolsToml> for Tools {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct GhostSnapshotToml {
/// Exclude untracked files larger than this many bytes from ghost snapshots.
#[serde(alias = "ignore_untracked_files_over_bytes")]
pub ignore_large_untracked_files: Option<i64>,
/// Ignore untracked directories that contain this many files or more.
/// (Still emits a warning.)
#[serde(alias = "large_untracked_dir_warning_threshold")]
pub ignore_large_untracked_dirs: Option<i64>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct SandboxPolicyResolution {
pub policy: SandboxPolicy,
@@ -1047,6 +1067,26 @@ impl Config {
let history = cfg.history.unwrap_or_default();
let ghost_snapshot = {
let mut config = GhostSnapshotConfig::default();
if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref()
&& let Some(ignore_over_bytes) = ghost_snapshot.ignore_large_untracked_files
{
config.ignore_large_untracked_files = if ignore_over_bytes > 0 {
Some(ignore_over_bytes)
} else {
None
};
}
if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref()
&& let Some(threshold) = ghost_snapshot.ignore_large_untracked_dirs
{
config.ignore_large_untracked_dirs =
if threshold > 0 { Some(threshold) } else { None };
}
config
};
let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform);
let tools_web_search_request = features.enabled(Feature::WebSearchRequest);
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
@@ -1179,6 +1219,7 @@ impl Config {
tools_web_search_request,
use_experimental_unified_exec_tool,
use_experimental_use_rmcp_client,
ghost_snapshot,
features,
active_profile: active_profile_name,
active_project,
@@ -2940,6 +2981,7 @@ model_verbosity = "high"
tools_web_search_request: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
active_profile: Some("o3".to_string()),
active_project: ProjectConfig { trust_level: None },
@@ -3014,6 +3056,7 @@ model_verbosity = "high"
tools_web_search_request: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
active_profile: Some("gpt3".to_string()),
active_project: ProjectConfig { trust_level: None },
@@ -3103,6 +3146,7 @@ model_verbosity = "high"
tools_web_search_request: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
active_profile: Some("zdr".to_string()),
active_project: ProjectConfig { trust_level: None },
@@ -3178,6 +3222,7 @@ model_verbosity = "high"
tools_web_search_request: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
active_profile: Some("gpt5".to_string()),
active_project: ProjectConfig { trust_level: None },
+125 -13
View File
@@ -73,30 +73,43 @@ impl SessionTask for GhostSnapshotTask {
_ = cancellation_token.cancelled() => true,
_ = async {
let repo_path = ctx_for_task.cwd.clone();
let ghost_snapshot = ctx_for_task.ghost_snapshot.clone();
let ignore_large_untracked_dirs = ghost_snapshot.ignore_large_untracked_dirs;
// First, compute a snapshot report so we can warn about
// large untracked directories before running the heavier
// snapshot logic.
if let Ok(Ok(report)) = tokio::task::spawn_blocking({
let repo_path = repo_path.clone();
let ghost_snapshot = ghost_snapshot.clone();
move || {
let options = CreateGhostCommitOptions::new(&repo_path);
let options =
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
capture_ghost_snapshot_report(&options)
}
})
.await
&& let Some(message) = format_large_untracked_warning(&report) {
session
.session
.send_event(
&ctx_for_task,
EventMsg::Warning(WarningEvent { message }),
)
.await;
}
{
for message in
format_snapshot_warnings(
ghost_snapshot.ignore_large_untracked_files,
ignore_large_untracked_dirs,
&report,
)
{
session
.session
.send_event(
&ctx_for_task,
EventMsg::Warning(WarningEvent { message }),
)
.await;
}
}
// Required to run in a dedicated blocking pool.
match tokio::task::spawn_blocking(move || {
let options = CreateGhostCommitOptions::new(&repo_path);
let options =
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
create_ghost_commit(&options)
})
.await
@@ -161,10 +174,31 @@ impl GhostSnapshotTask {
}
}
fn format_large_untracked_warning(report: &GhostSnapshotReport) -> Option<String> {
fn format_snapshot_warnings(
ignore_large_untracked_files: Option<i64>,
ignore_large_untracked_dirs: Option<i64>,
report: &GhostSnapshotReport,
) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(message) = format_large_untracked_warning(ignore_large_untracked_dirs, report) {
warnings.push(message);
}
if let Some(message) =
format_ignored_untracked_files_warning(ignore_large_untracked_files, report)
{
warnings.push(message);
}
warnings
}
fn format_large_untracked_warning(
ignore_large_untracked_dirs: Option<i64>,
report: &GhostSnapshotReport,
) -> Option<String> {
if report.large_untracked_dirs.is_empty() {
return None;
}
let threshold = ignore_large_untracked_dirs?;
const MAX_DIRS: usize = 3;
let mut parts: Vec<String> = Vec::new();
for dir in report.large_untracked_dirs.iter().take(MAX_DIRS) {
@@ -175,7 +209,85 @@ fn format_large_untracked_warning(report: &GhostSnapshotReport) -> Option<String
parts.push(format!("{remaining} more"));
}
Some(format!(
"Repository snapshot encountered large untracked directories: {}. This can slow Codex; consider adding these paths to .gitignore or disabling undo in your config.",
"Repository snapshot ignored large untracked directories (>= {threshold} files): {}. These directories are excluded from snapshots and undo cleanup. Adjust `ghost_snapshot.ignore_large_untracked_dirs` to change this behavior.",
parts.join(", ")
))
}
fn format_ignored_untracked_files_warning(
ignore_large_untracked_files: Option<i64>,
report: &GhostSnapshotReport,
) -> Option<String> {
let threshold = ignore_large_untracked_files?;
if report.ignored_untracked_files.is_empty() {
return None;
}
const MAX_FILES: usize = 3;
let mut parts: Vec<String> = Vec::new();
for file in report.ignored_untracked_files.iter().take(MAX_FILES) {
parts.push(format!(
"{} ({})",
file.path.display(),
format_bytes(file.byte_size)
));
}
if report.ignored_untracked_files.len() > MAX_FILES {
let remaining = report.ignored_untracked_files.len() - MAX_FILES;
parts.push(format!("{remaining} more"));
}
Some(format!(
"Repository snapshot ignored untracked files larger than {}: {}. These files are preserved during undo cleanup, but their contents are not captured in the snapshot. Adjust `ghost_snapshot.ignore_large_untracked_files` to change this behavior. To avoid this message in the future, update your `.gitignore`.",
format_bytes(threshold),
parts.join(", ")
))
}
fn format_bytes(bytes: i64) -> String {
const KIB: i64 = 1024;
const MIB: i64 = 1024 * 1024;
if bytes >= MIB {
return format!("{} MiB", bytes / MIB);
}
if bytes >= KIB {
return format!("{} KiB", bytes / KIB);
}
format!("{bytes} B")
}
#[cfg(test)]
mod tests {
use super::*;
use codex_git::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(None, &report), None);
}
}
+8 -4
View File
@@ -8,7 +8,8 @@ use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use async_trait::async_trait;
use codex_git::restore_ghost_commit;
use codex_git::RestoreGhostCommitOptions;
use codex_git::restore_ghost_commit_with_options;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
@@ -85,9 +86,12 @@ impl SessionTask for UndoTask {
let commit_id = ghost_commit.id().to_string();
let repo_path = ctx.cwd.clone();
let restore_result =
tokio::task::spawn_blocking(move || restore_ghost_commit(&repo_path, &ghost_commit))
.await;
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(())) => {