Skip to content

Commit 49962ac

Browse files
kkashilkkensave
authored andcommitted
refactor: improve checkpoint system reliability and error handling (#3041)
- Add work_tree_path field to CheckpointManager for explicit work tree tracking - Refactor run_git function to use Option<&Path> for cleaner work tree handling - Improve error messages with more user-friendly descriptions - Add debug logging for git operations to aid troubleshooting - Fix checkpoint state management in conversation restoration - Update experiment UI formatting for better readability - Add missing fields to test mocks for rmcp::model::Prompt - Clean up imports and formatting in reply.rs
1 parent 89a7201 commit 49962ac

File tree

8 files changed

+99
-45
lines changed

8 files changed

+99
-45
lines changed

crates/chat-cli/src/cli/chat/checkpoint.rs

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use serde::{
2525
Deserialize,
2626
Serialize,
2727
};
28+
use tracing::debug;
2829

2930
use crate::cli::ConversationState;
3031
use crate::cli::chat::conversation::HistoryEntry;
@@ -36,6 +37,9 @@ pub struct CheckpointManager {
3637
/// Path to the shadow (bare) git repository
3738
pub shadow_repo_path: PathBuf,
3839

40+
/// Path to current working directory
41+
pub work_tree_path: PathBuf,
42+
3943
/// All checkpoints in chronological order
4044
pub checkpoints: Vec<Checkpoint>,
4145

@@ -84,10 +88,10 @@ impl CheckpointManager {
8488
current_history: &VecDeque<HistoryEntry>,
8589
) -> Result<Self> {
8690
if !is_git_installed() {
87-
bail!("Git is not installed. Checkpoints require git to function.");
91+
bail!("Checkpoints are not available. Git is required but not installed.");
8892
}
8993
if !is_in_git_repo() {
90-
bail!("Not in a git repository. Use '/checkpoint init' to manually enable checkpoints.");
94+
bail!("Checkpoints are not available in this directory. Use '/checkpoint init' to enable checkpoints.");
9195
}
9296

9397
let manager = Self::manual_init(os, shadow_path, current_history).await?;
@@ -103,14 +107,17 @@ impl CheckpointManager {
103107
let path = path.as_ref();
104108
os.fs.create_dir_all(path).await?;
105109

110+
let work_tree_path =
111+
std::env::current_dir().map_err(|e| eyre!("Failed to get current working directory: {}", e))?;
112+
106113
// Initialize bare repository
107-
run_git(path, false, &["init", "--bare", &path.to_string_lossy()])?;
114+
run_git(path, None, &["init", "--bare", &path.to_string_lossy()])?;
108115

109116
// Configure git
110117
configure_git(&path.to_string_lossy())?;
111118

112119
// Create initial checkpoint
113-
stage_commit_tag(&path.to_string_lossy(), "Initial state", "0")?;
120+
stage_commit_tag(&path.to_string_lossy(), &work_tree_path, "Initial state", "0")?;
114121

115122
let initial_checkpoint = Checkpoint {
116123
tag: "0".to_string(),
@@ -126,6 +133,7 @@ impl CheckpointManager {
126133

127134
Ok(Self {
128135
shadow_repo_path: path.to_path_buf(),
136+
work_tree_path,
129137
checkpoints: vec![initial_checkpoint],
130138
tag_index,
131139
current_turn: 0,
@@ -146,7 +154,12 @@ impl CheckpointManager {
146154
tool_name: Option<String>,
147155
) -> Result<()> {
148156
// Stage, commit and tag
149-
stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), description, tag)?;
157+
stage_commit_tag(
158+
&self.shadow_repo_path.to_string_lossy(),
159+
&self.work_tree_path,
160+
description,
161+
tag,
162+
)?;
150163

151164
// Record checkpoint metadata
152165
let checkpoint = Checkpoint {
@@ -175,9 +188,14 @@ impl CheckpointManager {
175188

176189
if hard {
177190
// Hard: reset the whole work-tree to the tag
178-
let output = run_git(&self.shadow_repo_path, true, &["reset", "--hard", tag])?;
191+
let output = run_git(&self.shadow_repo_path, Some(&self.work_tree_path), &[
192+
"reset", "--hard", tag,
193+
])?;
179194
if !output.status.success() {
180-
bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr));
195+
bail!(
196+
"Failed to restore checkpoint: {}",
197+
String::from_utf8_lossy(&output.stderr)
198+
);
181199
}
182200
} else {
183201
// Soft: only restore tracked files. If the tag is an empty tree, this is a no-op.
@@ -187,9 +205,14 @@ impl CheckpointManager {
187205
return Ok(());
188206
}
189207
// Use checkout against work-tree
190-
let output = run_git(&self.shadow_repo_path, true, &["checkout", tag, "--", "."])?;
208+
let output = run_git(&self.shadow_repo_path, Some(&self.work_tree_path), &[
209+
"checkout", tag, "--", ".",
210+
])?;
191211
if !output.status.success() {
192-
bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr));
212+
bail!(
213+
"Failed to restore checkpoint: {}",
214+
String::from_utf8_lossy(&output.stderr)
215+
);
193216
}
194217
}
195218

@@ -205,7 +228,7 @@ impl CheckpointManager {
205228
let out = run_git(
206229
&self.shadow_repo_path,
207230
// work_tree
208-
false,
231+
None,
209232
&["ls-tree", "-r", "--name-only", tag],
210233
)?;
211234
Ok(!out.stdout.is_empty())
@@ -223,7 +246,7 @@ impl CheckpointManager {
223246

224247
/// Compute file statistics between two checkpoints
225248
pub fn compute_stats_between(&self, from: &str, to: &str) -> Result<FileStats> {
226-
let output = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", from, to])?;
249+
let output = run_git(&self.shadow_repo_path, None, &["diff", "--name-status", from, to])?;
227250

228251
let mut stats = FileStats::default();
229252
for line in String::from_utf8_lossy(&output.stdout).lines() {
@@ -246,7 +269,7 @@ impl CheckpointManager {
246269
let mut result = String::new();
247270

248271
// Get file changes
249-
let output = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", from, to])?;
272+
let output = run_git(&self.shadow_repo_path, None, &["diff", "--name-status", from, to])?;
250273

251274
for line in String::from_utf8_lossy(&output.stdout).lines() {
252275
if let Some((status, file)) = line.split_once('\t') {
@@ -261,7 +284,7 @@ impl CheckpointManager {
261284
}
262285

263286
// Add statistics
264-
let stat_output = run_git(&self.shadow_repo_path, false, &[
287+
let stat_output = run_git(&self.shadow_repo_path, None, &[
265288
"diff",
266289
from,
267290
to,
@@ -279,7 +302,10 @@ impl CheckpointManager {
279302

280303
/// Check for uncommitted changes
281304
pub fn has_changes(&self) -> Result<bool> {
282-
let output = run_git(&self.shadow_repo_path, true, &["status", "--porcelain"])?;
305+
let output = run_git(&self.shadow_repo_path, Some(&self.work_tree_path), &[
306+
"status",
307+
"--porcelain",
308+
])?;
283309
Ok(!output.stdout.is_empty())
284310
}
285311

@@ -351,18 +377,18 @@ fn is_in_git_repo() -> bool {
351377
}
352378

353379
fn configure_git(shadow_path: &str) -> Result<()> {
354-
run_git(Path::new(shadow_path), false, &["config", "user.name", "Q"])?;
355-
run_git(Path::new(shadow_path), false, &["config", "user.email", "qcli@local"])?;
356-
run_git(Path::new(shadow_path), false, &["config", "core.preloadindex", "true"])?;
380+
run_git(Path::new(shadow_path), None, &["config", "user.name", "Q"])?;
381+
run_git(Path::new(shadow_path), None, &["config", "user.email", "qcli@local"])?;
382+
run_git(Path::new(shadow_path), None, &["config", "core.preloadindex", "true"])?;
357383
Ok(())
358384
}
359385

360-
fn stage_commit_tag(shadow_path: &str, message: &str, tag: &str) -> Result<()> {
386+
fn stage_commit_tag(shadow_path: &str, work_tree: &Path, message: &str, tag: &str) -> Result<()> {
361387
// Stage all changes
362-
run_git(Path::new(shadow_path), true, &["add", "-A"])?;
388+
run_git(Path::new(shadow_path), Some(work_tree), &["add", "-A"])?;
363389

364390
// Commit
365-
let output = run_git(Path::new(shadow_path), true, &[
391+
let output = run_git(Path::new(shadow_path), Some(work_tree), &[
366392
"commit",
367393
"--allow-empty",
368394
"--no-verify",
@@ -371,33 +397,53 @@ fn stage_commit_tag(shadow_path: &str, message: &str, tag: &str) -> Result<()> {
371397
])?;
372398

373399
if !output.status.success() {
374-
bail!("Git commit failed: {}", String::from_utf8_lossy(&output.stderr));
400+
bail!(
401+
"Checkpoint initialization failed: {}",
402+
String::from_utf8_lossy(&output.stderr)
403+
);
375404
}
376405

377406
// Tag
378-
let output = run_git(Path::new(shadow_path), false, &["tag", tag])?;
407+
let output = run_git(Path::new(shadow_path), None, &["tag", tag])?;
379408
if !output.status.success() {
380-
bail!("Git tag failed: {}", String::from_utf8_lossy(&output.stderr));
409+
bail!(
410+
"Checkpoint initialization failed: {}",
411+
String::from_utf8_lossy(&output.stderr)
412+
);
381413
}
382414

383415
Ok(())
384416
}
385417

386-
fn run_git(dir: &Path, with_work_tree: bool, args: &[&str]) -> Result<Output> {
418+
fn run_git(dir: &Path, work_tree: Option<&Path>, args: &[&str]) -> Result<Output> {
387419
let mut cmd = Command::new("git");
388420
cmd.arg(format!("--git-dir={}", dir.display()));
389421

390-
if with_work_tree {
391-
cmd.arg("--work-tree=.");
422+
if let Some(work_tree_path) = work_tree {
423+
cmd.arg(format!("--work-tree={}", work_tree_path.display()));
392424
}
393425

394426
cmd.args(args);
395427

428+
debug!("Executing git command: {:?}", cmd);
396429
let output = cmd.output()?;
397-
if !output.status.success() && !output.stderr.is_empty() {
398-
bail!(String::from_utf8_lossy(&output.stderr).to_string());
430+
431+
if !output.status.success() {
432+
debug!("Git command failed with exit code: {:?}", output.status.code());
433+
debug!("Git stderr: {}", String::from_utf8_lossy(&output.stderr));
434+
debug!("Git stdout: {}", String::from_utf8_lossy(&output.stdout));
435+
436+
if !output.stderr.is_empty() {
437+
bail!(
438+
"Checkpoint operation failed: {}",
439+
String::from_utf8_lossy(&output.stderr)
440+
);
441+
} else {
442+
bail!("Checkpoint operation failed unexpectedly");
443+
}
399444
}
400445

446+
debug!("Git command succeeded");
401447
Ok(output)
402448
}
403449

crates/chat-cli/src/cli/chat/cli/checkpoint.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ impl CheckpointSubcommand {
107107
session.stderr,
108108
style::SetForegroundColor(Color::Yellow),
109109
style::Print(
110-
"⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n"
110+
"⚠️ Checkpoint is disabled while in tangent mode. Please exit tangent mode if you want to use checkpoint.\n\n"
111111
),
112112
style::SetForegroundColor(Color::Reset),
113113
)?;

crates/chat-cli/src/cli/chat/cli/experiment.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,7 @@ static AVAILABLE_EXPERIMENTS: &[Experiment] = &[
5151
},
5252
Experiment {
5353
name: "Checkpoint",
54-
description: concat!(
55-
"Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/checkpoint)\n",
56-
" ",
57-
"Cannot be used in tangent mode (to avoid mixing up conversation history)"
58-
),
54+
description: "Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/checkpoint)\nNote: Cannot be used in tangent mode (to avoid mixing up conversation history)",
5955
setting_key: Setting::EnabledCheckpoint,
6056
},
6157
Experiment {
@@ -84,17 +80,21 @@ async fn select_experiment(os: &mut Os, session: &mut ChatSession) -> Result<Opt
8480
let is_enabled = os.database.settings.get_bool(experiment.setting_key).unwrap_or(false);
8581

8682
current_states.push(is_enabled);
87-
// Create clean single-line format: "Knowledge [ON] - Description"
83+
8884
let status_indicator = if is_enabled {
89-
style::Stylize::green("[ON] ")
85+
format!("{}", style::Stylize::green("[ON] "))
9086
} else {
91-
style::Stylize::grey("[OFF]")
87+
format!("{}", style::Stylize::grey("[OFF]"))
9288
};
89+
90+
// Handle multi-line descriptions with proper indentation
91+
let description = experiment.description.replace('\n', &format!("\n{}", " ".repeat(34)));
92+
9393
let label = format!(
94-
"{:<18} {} - {}",
94+
"{:<25} {:<6} {}",
9595
experiment.name,
9696
status_indicator,
97-
style::Stylize::dark_grey(experiment.description)
97+
style::Stylize::dark_grey(description)
9898
);
9999
experiment_labels.push(label);
100100
}

crates/chat-cli/src/cli/chat/cli/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ pub enum SlashCommand {
108108
Persist(PersistSubcommand),
109109
// #[command(flatten)]
110110
// Root(RootSubcommand),
111-
#[command(subcommand)]
111+
#[command(
112+
about = "(Beta) Manage workspace checkpoints (init, list, restore, expand, diff, clean)\nExperimental features may be changed or removed at any time",
113+
hide = true,
114+
subcommand
115+
)]
112116
Checkpoint(CheckpointSubcommand),
113117
/// View, manage, and resume to-do lists
114118
#[command(subcommand)]

crates/chat-cli/src/cli/chat/cli/prompts.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2462,19 +2462,19 @@ mod tests {
24622462
let prompt1 = rmcp::model::Prompt {
24632463
name: "test_prompt".to_string(),
24642464
description: Some("Test description".to_string()),
2465-
title: None,
2465+
title: Some("Test Prompt".to_string()),
24662466
icons: None,
24672467
arguments: Some(vec![
24682468
PromptArgument {
24692469
name: "arg1".to_string(),
24702470
description: Some("First argument".to_string()),
2471-
title: None,
2471+
title: Some("Argument 1".to_string()),
24722472
required: Some(true),
24732473
},
24742474
PromptArgument {
24752475
name: "arg2".to_string(),
2476+
title: Some("Argument 2".to_string()),
24762477
description: None,
2477-
title: None,
24782478
required: Some(false),
24792479
},
24802480
]),

crates/chat-cli/src/cli/chat/conversation.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ impl ConversationState {
253253
self.transcript = checkpoint.main_transcript;
254254
self.latest_summary = checkpoint.main_latest_summary;
255255
self.valid_history_range = (0, self.history.len());
256+
if let Some(manager) = self.checkpoint_manager.as_mut() {
257+
manager.message_locked = false;
258+
manager.pending_user_message = None;
259+
}
256260
}
257261

258262
/// Enter tangent mode - creates checkpoint of current state

crates/chat-cli/src/cli/chat/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3030,11 +3030,11 @@ impl ChatSession {
30303030

30313031
// Reset for next turn
30323032
manager.tools_in_turn = 0;
3033-
manager.message_locked = false; // Unlock for next turn
30343033
} else {
30353034
// Clear pending message even if no tools were used
30363035
manager.pending_user_message = None;
30373036
}
3037+
manager.message_locked = false; // Unlock for next turn
30383038

30393039
// Put manager back
30403040
self.conversation.checkpoint_manager = Some(manager);

crates/chat-cli/src/cli/chat/tool_manager.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2112,8 +2112,8 @@ mod tests {
21122112
// Create mock prompt bundles
21132113
let prompt = rmcp::model::Prompt {
21142114
name: "test_prompt".to_string(),
2115+
title: Some("Test Prompt".to_string()),
21152116
description: Some("Test description".to_string()),
2116-
title: None,
21172117
icons: None,
21182118
arguments: None,
21192119
};

0 commit comments

Comments
 (0)