Skip to content
Open
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
49 changes: 49 additions & 0 deletions code-rs/code-auto-drive-core/src/auto_coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ pub enum AutoCoordinatorEvent {
agents_timing: Option<AutoTurnAgentsTiming>,
agents: Vec<AutoTurnAgentsAction>,
transcript: Vec<ResponseItem>,
turn_descriptor: Option<TurnDescriptor>,
review_commit: Option<ReviewCommitDescriptor>,
},
Thinking {
delta: String,
Expand Down Expand Up @@ -151,10 +153,24 @@ struct PendingDecision {
agents_timing: Option<AutoTurnAgentsTiming>,
agents: Vec<AutoTurnAgentsAction>,
transcript: Vec<ResponseItem>,
turn_descriptor: Option<TurnDescriptor>,
review_commit: Option<ReviewCommitDescriptor>,
}

impl PendingDecision {
fn into_event(self) -> AutoCoordinatorEvent {
let (turn_descriptor, review_commit) = if matches!(self.status, AutoCoordinatorStatus::Success) {
match self.turn_descriptor.clone() {
Some(descriptor) if descriptor.diagnostics_enabled => (
Some(descriptor),
self.review_commit.clone(),
),
_ => (None, None),
}
} else {
(None, None)
};

AutoCoordinatorEvent::Decision {
status: self.status,
progress_past: self.progress_past,
Expand All @@ -164,6 +180,8 @@ impl PendingDecision {
agents_timing: self.agents_timing,
agents: self.agents,
transcript: self.transcript,
turn_descriptor,
review_commit,
}
}
}
Expand Down Expand Up @@ -262,9 +280,18 @@ pub struct TurnDescriptor {
#[serde(default)]
pub review_strategy: Option<ReviewStrategy>,
#[serde(default)]
pub diagnostics_enabled: bool,
#[serde(default)]
pub text_format_override: Option<code_core::TextFormat>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ReviewCommitDescriptor {
pub source: String,
#[serde(default)]
pub sha: Option<String>,
}

impl Default for TurnDescriptor {
fn default() -> Self {
Self {
Expand All @@ -273,6 +300,7 @@ impl Default for TurnDescriptor {
complexity: None,
agent_preferences: None,
review_strategy: None,
diagnostics_enabled: false,
text_format_override: None,
}
}
Expand All @@ -294,6 +322,7 @@ mod tests {
assert!(descriptor.complexity.is_none());
assert!(descriptor.agent_preferences.is_none());
assert!(descriptor.review_strategy.is_none());
assert!(!descriptor.diagnostics_enabled);
}

#[test]
Expand Down Expand Up @@ -598,6 +627,10 @@ struct CoordinatorDecisionNew {
agents: Option<AgentsField>,
#[serde(default)]
goal: Option<String>,
#[serde(default)]
turn_descriptor: Option<TurnDescriptor>,
#[serde(default)]
review_commit: Option<ReviewCommitDescriptor>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -689,6 +722,8 @@ struct ParsedCoordinatorDecision {
agents: Vec<AgentAction>,
goal: Option<String>,
response_items: Vec<ResponseItem>,
turn_descriptor: Option<TurnDescriptor>,
review_commit: Option<ReviewCommitDescriptor>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -870,6 +905,8 @@ fn run_auto_loop(
mut agents_timing,
mut agents,
mut response_items,
turn_descriptor,
review_commit,
}) => {
retry_conversation.take();
if !include_agents {
Expand Down Expand Up @@ -898,6 +935,8 @@ fn run_auto_loop(
agents_timing,
agents: agents.iter().map(agent_action_to_event).collect(),
transcript: std::mem::take(&mut response_items),
turn_descriptor: None,
review_commit: None,
};
event_tx.send(event);
continue;
Expand All @@ -912,6 +951,8 @@ fn run_auto_loop(
agents_timing,
agents: agents.iter().map(agent_action_to_event).collect(),
transcript: response_items,
turn_descriptor,
review_commit,
};

let should_stop = matches!(decision_event.status, AutoCoordinatorStatus::Failed);
Expand Down Expand Up @@ -962,6 +1003,8 @@ fn run_auto_loop(
agents_timing: None,
agents: Vec::new(),
transcript: Vec::new(),
turn_descriptor: None,
review_commit: None,
};
event_tx.send(event);
stopped = true;
Expand Down Expand Up @@ -2048,6 +2091,8 @@ fn convert_decision_new(
cli,
agents: agent_payloads,
goal,
turn_descriptor,
review_commit,
} = decision;

let progress_past = clean_optional(progress.past);
Expand Down Expand Up @@ -2116,6 +2161,8 @@ fn convert_decision_new(
agents: agent_actions,
goal,
response_items: Vec::new(),
turn_descriptor,
review_commit,
})
}

Expand Down Expand Up @@ -2161,6 +2208,8 @@ fn convert_decision_legacy(
agents: Vec::new(),
goal,
response_items: Vec::new(),
turn_descriptor: None,
review_commit: None,
})
}

Expand Down
1 change: 1 addition & 0 deletions code-rs/code-auto-drive-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub use auto_coordinator::{
AutoTurnAgentsAction,
AutoTurnAgentsTiming,
AutoTurnCliAction,
ReviewCommitDescriptor,
TurnComplexity,
TurnConfig,
TurnDescriptor,
Expand Down
2 changes: 1 addition & 1 deletion code-rs/core/src/config_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ impl Default for Tui {
spinner: SpinnerSelection::default(),
notifications: Notifications::default(),
alternate_screen: true,
review_auto_resolve: false,
review_auto_resolve: true,
}
}
}
Expand Down
126 changes: 126 additions & 0 deletions code-rs/core/src/git_worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use base64::Engine;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs as stdfs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use tokio::fs::OpenOptions;
use tokio::process::Command;
Expand Down Expand Up @@ -89,6 +90,8 @@ pub fn generate_branch_name_from_task(task: Option<&str>) -> String {

pub const LOCAL_DEFAULT_REMOTE: &str = "local-default";
const BRANCH_METADATA_DIR: &str = "_branch-meta";
const REVIEW_WORKTREES_DIR: &str = "reviews";
const REVIEW_WORKTREE_PREFIX: &str = "review";

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BranchMetadata {
Expand All @@ -102,6 +105,22 @@ pub struct BranchMetadata {
pub remote_url: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ReviewWorktreeCleanupToken {
git_root: PathBuf,
worktree_path: PathBuf,
}

impl ReviewWorktreeCleanupToken {
pub fn git_root(&self) -> &Path {
&self.git_root
}

pub fn worktree_path(&self) -> &Path {
&self.worktree_path
}
}

/// Resolve the git repository root (top-level) for the given cwd.
pub async fn get_git_root_from(cwd: &Path) -> Result<PathBuf, String> {
let output = Command::new("git")
Expand Down Expand Up @@ -195,6 +214,69 @@ pub async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<(PathBuf
Ok((worktree_path, effective_branch))
}

pub async fn setup_review_worktree(
git_root: &Path,
revision: &str,
name_hint: Option<&str>,
) -> Result<(PathBuf, ReviewWorktreeCleanupToken), String> {
let repo_name = git_root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("repo");

let mut base_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
base_dir = base_dir
.join(".code")
.join("working")
.join(repo_name)
.join(REVIEW_WORKTREES_DIR);
tokio::fs::create_dir_all(&base_dir)
.await
.map_err(|e| format!("Failed to create review worktree directory: {}", e))?;

let slug = name_hint
.map(sanitize_ref_component)
.filter(|candidate| !candidate.is_empty());
let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
let base_name = match slug {
Some(ref slug) => format!("{REVIEW_WORKTREE_PREFIX}-{slug}-{timestamp}"),
None => format!("{REVIEW_WORKTREE_PREFIX}-{timestamp}"),
};

let mut worktree_name = base_name.clone();
let mut worktree_path = base_dir.join(&worktree_name);
let mut suffix = 1usize;
while worktree_path.exists() {
worktree_name = format!("{base_name}-{suffix}");
worktree_path = base_dir.join(&worktree_name);
suffix += 1;
}

let worktree_str = worktree_path
.to_str()
.ok_or_else(|| format!("Review worktree path not valid UTF-8: {}", worktree_path.display()))?
.to_string();

let output = Command::new("git")
.current_dir(git_root)
.args(["worktree", "add", "--detach", &worktree_str, revision])
.output()
.await
.map_err(|e| format!("Failed to create review worktree: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("Failed to create review worktree: {stderr}"));
}

record_worktree_in_session(git_root, &worktree_path).await;
let token = ReviewWorktreeCleanupToken {
git_root: git_root.to_path_buf(),
worktree_path: worktree_path.clone(),
};

Ok((worktree_path, token))
}

/// Append the created worktree to a per-process session file so the TUI can
/// clean it up on exit without touching worktrees from other processes.
async fn record_worktree_in_session(git_root: &Path, worktree_path: &Path) {
Expand Down Expand Up @@ -499,6 +581,50 @@ pub async fn copy_uncommitted_to_worktree(src_root: &Path, worktree_path: &Path)
Ok(count)
}

pub async fn cleanup_review_worktree(token: ReviewWorktreeCleanupToken) -> Result<(), String> {
cleanup_review_worktree_at(token.git_root(), token.worktree_path()).await
}

pub async fn cleanup_review_worktree_at(
git_root: &Path,
worktree_path: &Path,
) -> Result<(), String> {
let worktree_str = worktree_path
.to_str()
.ok_or_else(|| format!("Review worktree path not valid UTF-8: {}", worktree_path.display()))?
.to_string();

let output = Command::new("git")
.current_dir(git_root)
.args(["worktree", "remove", "--force", &worktree_str])
.output()
.await
.map_err(|e| format!("Failed to remove review worktree: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let trimmed = stderr.trim();
if !trimmed.is_empty()
&& !trimmed.contains("not found")
&& !trimmed.contains("No such file or directory")
&& !trimmed.contains("not a worktree")
{
return Err(format!("Failed to remove review worktree: {trimmed}"));
}
}

match tokio::fs::remove_dir_all(worktree_path).await {
Ok(_) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(format!("Failed to delete review worktree directory: {err}")),
}

if let Some(parent) = worktree_path.parent() {
let _ = tokio::fs::remove_dir(parent).await;
}

Ok(())
}

/// Determine repository default branch. Prefers `origin/HEAD` symbolic ref, then local `main`/`master`.
pub async fn detect_default_branch(cwd: &Path) -> Option<String> {
// Try origin/HEAD first
Expand Down
5 changes: 5 additions & 0 deletions code-rs/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,8 @@ pub use environment_context::ToolCandidate;
pub use environment_context::TOOL_CANDIDATES;
pub use openai_tools::{get_openai_tools, OpenAiTool, ToolsConfig};
pub use otel_init::*;
pub use git_worktree::cleanup_review_worktree;
pub use git_worktree::cleanup_review_worktree_at;
pub use git_worktree::copy_uncommitted_to_worktree;
pub use git_worktree::setup_review_worktree;
pub use git_worktree::ReviewWorktreeCleanupToken;
3 changes: 3 additions & 0 deletions code-rs/protocol/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,9 @@ pub struct ReviewContextMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub current_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub worktree_path: Option<String>,
}

/// Structured review result produced by a child review session.
Expand Down
Loading
Loading