Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ authors = ["Amazon Q CLI Team (q-cli@amazon.com)", "Chay Nabors (nabochay@amazon
edition = "2024"
homepage = "https://aws.amazon.com/q/"
publish = false
version = "1.16.3"
version = "1.17.0"
license = "MIT OR Apache-2.0"

[workspace.dependencies]
Expand Down Expand Up @@ -129,7 +129,7 @@ winnow = "=0.6.2"
winreg = "0.55.0"
schemars = "1.0.4"
jsonschema = "0.30.0"
rmcp = { version = "0.6.3", features = ["client", "transport-sse-client-reqwest", "reqwest", "transport-streamable-http-client-reqwest", "transport-child-process", "tower", "auth"] }
rmcp = { version = "0.7.0", features = ["client", "transport-sse-client-reqwest", "reqwest", "transport-streamable-http-client-reqwest", "transport-child-process", "tower", "auth"] }

[workspace.lints.rust]
future_incompatible = "warn"
Expand Down
102 changes: 74 additions & 28 deletions crates/chat-cli/src/cli/chat/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use serde::{
Deserialize,
Serialize,
};
use tracing::debug;

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

/// Path to current working directory
pub work_tree_path: PathBuf,

/// All checkpoints in chronological order
pub checkpoints: Vec<Checkpoint>,

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

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

let work_tree_path =
std::env::current_dir().map_err(|e| eyre!("Failed to get current working directory: {}", e))?;

// Initialize bare repository
run_git(path, false, &["init", "--bare", &path.to_string_lossy()])?;
run_git(path, None, &["init", "--bare", &path.to_string_lossy()])?;

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

// Create initial checkpoint
stage_commit_tag(&path.to_string_lossy(), "Initial state", "0")?;
stage_commit_tag(&path.to_string_lossy(), &work_tree_path, "Initial state", "0")?;

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

Ok(Self {
shadow_repo_path: path.to_path_buf(),
work_tree_path,
checkpoints: vec![initial_checkpoint],
tag_index,
current_turn: 0,
Expand All @@ -146,7 +154,12 @@ impl CheckpointManager {
tool_name: Option<String>,
) -> Result<()> {
// Stage, commit and tag
stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), description, tag)?;
stage_commit_tag(
&self.shadow_repo_path.to_string_lossy(),
&self.work_tree_path,
description,
tag,
)?;

// Record checkpoint metadata
let checkpoint = Checkpoint {
Expand Down Expand Up @@ -175,9 +188,14 @@ impl CheckpointManager {

if hard {
// Hard: reset the whole work-tree to the tag
let output = run_git(&self.shadow_repo_path, true, &["reset", "--hard", tag])?;
let output = run_git(&self.shadow_repo_path, Some(&self.work_tree_path), &[
"reset", "--hard", tag,
])?;
if !output.status.success() {
bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr));
bail!(
"Failed to restore checkpoint: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
// Soft: only restore tracked files. If the tag is an empty tree, this is a no-op.
Expand All @@ -187,9 +205,14 @@ impl CheckpointManager {
return Ok(());
}
// Use checkout against work-tree
let output = run_git(&self.shadow_repo_path, true, &["checkout", tag, "--", "."])?;
let output = run_git(&self.shadow_repo_path, Some(&self.work_tree_path), &[
"checkout", tag, "--", ".",
])?;
if !output.status.success() {
bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr));
bail!(
"Failed to restore checkpoint: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}

Expand All @@ -205,7 +228,7 @@ impl CheckpointManager {
let out = run_git(
&self.shadow_repo_path,
// work_tree
false,
None,
&["ls-tree", "-r", "--name-only", tag],
)?;
Ok(!out.stdout.is_empty())
Expand All @@ -223,7 +246,7 @@ impl CheckpointManager {

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

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

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

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

// Add statistics
let stat_output = run_git(&self.shadow_repo_path, false, &[
let stat_output = run_git(&self.shadow_repo_path, None, &[
"diff",
from,
to,
Expand All @@ -279,7 +302,10 @@ impl CheckpointManager {

/// Check for uncommitted changes
pub fn has_changes(&self) -> Result<bool> {
let output = run_git(&self.shadow_repo_path, true, &["status", "--porcelain"])?;
let output = run_git(&self.shadow_repo_path, Some(&self.work_tree_path), &[
"status",
"--porcelain",
])?;
Ok(!output.stdout.is_empty())
}

Expand Down Expand Up @@ -351,18 +377,18 @@ fn is_in_git_repo() -> bool {
}

fn configure_git(shadow_path: &str) -> Result<()> {
run_git(Path::new(shadow_path), false, &["config", "user.name", "Q"])?;
run_git(Path::new(shadow_path), false, &["config", "user.email", "qcli@local"])?;
run_git(Path::new(shadow_path), false, &["config", "core.preloadindex", "true"])?;
run_git(Path::new(shadow_path), None, &["config", "user.name", "Q"])?;
run_git(Path::new(shadow_path), None, &["config", "user.email", "qcli@local"])?;
run_git(Path::new(shadow_path), None, &["config", "core.preloadindex", "true"])?;
Ok(())
}

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

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

if !output.status.success() {
bail!("Git commit failed: {}", String::from_utf8_lossy(&output.stderr));
bail!(
"Checkpoint initialization failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}

// Tag
let output = run_git(Path::new(shadow_path), false, &["tag", tag])?;
let output = run_git(Path::new(shadow_path), None, &["tag", tag])?;
if !output.status.success() {
bail!("Git tag failed: {}", String::from_utf8_lossy(&output.stderr));
bail!(
"Checkpoint initialization failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}

Ok(())
}

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

if with_work_tree {
cmd.arg("--work-tree=.");
if let Some(work_tree_path) = work_tree {
cmd.arg(format!("--work-tree={}", work_tree_path.display()));
}

cmd.args(args);

debug!("Executing git command: {:?}", cmd);
let output = cmd.output()?;
if !output.status.success() && !output.stderr.is_empty() {
bail!(String::from_utf8_lossy(&output.stderr).to_string());

if !output.status.success() {
debug!("Git command failed with exit code: {:?}", output.status.code());
debug!("Git stderr: {}", String::from_utf8_lossy(&output.stderr));
debug!("Git stdout: {}", String::from_utf8_lossy(&output.stdout));

if !output.stderr.is_empty() {
bail!(
"Checkpoint operation failed: {}",
String::from_utf8_lossy(&output.stderr)
);
} else {
bail!("Checkpoint operation failed unexpectedly");
}
}

debug!("Git command succeeded");
Ok(output)
}

Expand Down
2 changes: 1 addition & 1 deletion crates/chat-cli/src/cli/chat/cli/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ impl CheckpointSubcommand {
session.stderr,
style::SetForegroundColor(Color::Yellow),
style::Print(
"⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n"
"⚠️ Checkpoint is disabled while in tangent mode. Please exit tangent mode if you want to use checkpoint.\n\n"
),
style::SetForegroundColor(Color::Reset),
)?;
Expand Down
30 changes: 13 additions & 17 deletions crates/chat-cli/src/cli/chat/cli/experiment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use crossterm::{
};
use dialoguer::Select;

use crate::cli::chat::conversation::format_tool_spec;
use crate::cli::chat::{
ChatError,
ChatSession,
Expand Down Expand Up @@ -52,11 +51,7 @@ static AVAILABLE_EXPERIMENTS: &[Experiment] = &[
},
Experiment {
name: "Checkpoint",
description: concat!(
"Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/checkpoint)\n",
" ",
"Cannot be used in tangent mode (to avoid mixing up conversation history)"
),
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)",
setting_key: Setting::EnabledCheckpoint,
},
Experiment {
Expand Down Expand Up @@ -85,17 +80,21 @@ async fn select_experiment(os: &mut Os, session: &mut ChatSession) -> Result<Opt
let is_enabled = os.database.settings.get_bool(experiment.setting_key).unwrap_or(false);

current_states.push(is_enabled);
// Create clean single-line format: "Knowledge [ON] - Description"

let status_indicator = if is_enabled {
style::Stylize::green("[ON] ")
format!("{}", style::Stylize::green("[ON] "))
} else {
style::Stylize::grey("[OFF]")
format!("{}", style::Stylize::grey("[OFF]"))
};

// Handle multi-line descriptions with proper indentation
let description = experiment.description.replace('\n', &format!("\n{}", " ".repeat(34)));

let label = format!(
"{:<18} {} - {}",
"{:<25} {:<6} {}",
experiment.name,
status_indicator,
style::Stylize::dark_grey(experiment.description)
style::Stylize::dark_grey(description)
);
experiment_labels.push(label);
}
Expand Down Expand Up @@ -163,16 +162,13 @@ async fn select_experiment(os: &mut Os, session: &mut ChatSession) -> Result<Opt
.await
.map_err(|e| ChatError::Custom(format!("Failed to update experiment setting: {e}").into()))?;

// Reload tools to reflect the experiment change
let tools = session
// Reload built-in tools to reflect the experiment change while preserving MCP tools
session
.conversation
.tool_manager
.load_tools(os, &mut session.stderr)
.reload_builtin_tools(os, &mut session.stderr)
.await
.map_err(|e| ChatError::Custom(format!("Failed to update tool spec: {e}").into()))?;

session.conversation.tools = format_tool_spec(tools);

let status_text = if new_state { "enabled" } else { "disabled" };

queue!(
Expand Down
5 changes: 4 additions & 1 deletion crates/chat-cli/src/cli/chat/cli/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,10 @@ mod tests {
#[cfg(unix)]
let command = format!("cat > {}", test_file_str);
#[cfg(windows)]
let command = format!("type > {}", test_file_str);
let command = format!(
"powershell -Command \"$input | Out-File -FilePath '{}'\"",
test_file_str
);

let hook = Hook {
command,
Expand Down
Loading
Loading