use anyhow::Result;
use repo::{
GitRemoteTrackingStatus, Repository, RepositoryOperationStatus, ThreadFreshness, ThreadState,
};
use serde::Serialize;
use tokio::time::{Duration, sleep};
use super::{
operator_loop::primary_next_action,
thread::{ThreadSummary, collect_thread_summaries, git_history_label},
};
use crate::cli::{Cli, WorkspaceShowArgs, should_output_json, style};
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceThreadGroup {
pub id: String,
pub label: String,
pub threads: Vec<ThreadSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceSummaryOutput {
pub repository: String,
pub repository_capability: String,
pub storage_model: String,
pub hosted_enabled: bool,
pub operation: Option<RepositoryOperationStatus>,
pub remote_tracking: Option<GitRemoteTrackingStatus>,
pub recommended_action: String,
pub current_thread: Option<String>,
pub groups: Vec<WorkspaceThreadGroup>,
pub thread_count: usize,
#[serde(skip)]
pub git_overlay_import_hint: Option<WorkspaceGitOverlayImportHintOutput>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceGitOverlayImportHintOutput {
pub current_branch: String,
pub missing_branch_count: usize,
pub missing_branches: Vec<String>,
pub recommended_command: String,
}
pub async fn cmd_workspace(cli: &Cli, command: crate::cli::WorkspaceCommands) -> Result<()> {
match command {
crate::cli::WorkspaceCommands::Show(args) => cmd_workspace_show(cli, args).await,
}
}
pub async fn cmd_workspace_show(cli: &Cli, args: WorkspaceShowArgs) -> Result<()> {
if args.watch {
return watch_workspace(cli, args.watch_iterations, args.watch_interval_ms).await;
}
let output = build_workspace_output(cli)?;
render_workspace(cli, &output);
Ok(())
}
pub(crate) fn build_workspace_output(cli: &Cli) -> Result<WorkspaceSummaryOutput> {
let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
let mut summaries = collect_thread_summaries(&repo)?;
let current_thread = repo.current_lane()?;
let current_name = current_thread.clone();
let current_stack = current_name
.as_deref()
.map(|thread| stack_members(&summaries, thread))
.unwrap_or_default();
let mut current = Vec::new();
let mut stacked = Vec::new();
let mut parallel = Vec::new();
let mut ready = Vec::new();
let mut blocked = Vec::new();
let mut recent = Vec::new();
summaries.sort_by(|a, b| a.name.cmp(&b.name));
for summary in summaries {
if summary.is_current {
current.push(summary);
continue;
}
if summary.thread_state == Some(ThreadState::Merged) {
recent.push(summary);
continue;
}
if is_blocked(&summary) {
blocked.push(summary);
continue;
}
if summary.thread_state == Some(ThreadState::Ready) {
ready.push(summary);
continue;
}
if current_stack.contains(&summary.name) {
stacked.push(summary);
continue;
}
parallel.push(summary);
}
let groups = vec![
WorkspaceThreadGroup {
id: "current".to_string(),
label: "Current thread".to_string(),
threads: current,
},
WorkspaceThreadGroup {
id: "stacked".to_string(),
label: "Stacked child threads".to_string(),
threads: stacked,
},
WorkspaceThreadGroup {
id: "parallel".to_string(),
label: "Parallel threads".to_string(),
threads: parallel,
},
WorkspaceThreadGroup {
id: "ready".to_string(),
label: "Ready to merge".to_string(),
threads: ready,
},
WorkspaceThreadGroup {
id: "blocked".to_string(),
label: "Blocked or stale".to_string(),
threads: blocked,
},
WorkspaceThreadGroup {
id: "recent".to_string(),
label: "Recently merged".to_string(),
threads: recent,
},
]
.into_iter()
.filter(|group| !group.threads.is_empty())
.collect::<Vec<_>>();
let thread_count = groups.iter().map(|group| group.threads.len()).sum();
let operation = repo.operation_status()?;
let remote_tracking = repo.git_remote_tracking_status()?;
let import_hint = repo.git_overlay_import_hint()?;
let current_summary = groups
.iter()
.flat_map(|group| group.threads.iter())
.find(|thread| thread.is_current);
Ok(WorkspaceSummaryOutput {
repository: repo.root().display().to_string(),
repository_capability: repo.capability_label().to_string(),
storage_model: repo.storage_model_label().to_string(),
hosted_enabled: repo.hosted_enabled(),
git_overlay_import_hint: import_hint.clone().map(|hint| {
WorkspaceGitOverlayImportHintOutput {
current_branch: hint.current_branch,
missing_branch_count: hint.missing_branch_count,
missing_branches: hint.missing_branches,
recommended_command: hint.recommended_command,
}
}),
operation: operation.clone(),
remote_tracking: remote_tracking.clone(),
recommended_action: primary_next_action(
operation.as_ref(),
remote_tracking.as_ref(),
import_hint.as_ref(),
current_summary.map(|thread| thread.recommended_action.as_str()),
),
current_thread,
groups,
thread_count,
})
}
fn stack_members(summaries: &[ThreadSummary], root: &str) -> Vec<String> {
let mut out = Vec::new();
let mut frontier = vec![root.to_string()];
while let Some(parent) = frontier.pop() {
for summary in summaries
.iter()
.filter(|summary| summary.parent_thread.as_deref() == Some(parent.as_str()))
{
out.push(summary.name.clone());
frontier.push(summary.name.clone());
}
}
out
}
fn is_blocked(summary: &ThreadSummary) -> bool {
summary.stale_from_parent
|| summary.blockers.iter().any(|_| true)
|| summary.thread_health == "blocked"
|| matches!(
summary.coordination_status,
super::thread::CoordinationStatus::Blocked
| super::thread::CoordinationStatus::Diverged
)
}
fn render_workspace(cli: &Cli, output: &WorkspaceSummaryOutput) {
if should_output_json(cli, None) {
println!(
"{}",
serde_json::to_string(output).expect("workspace JSON serializes")
);
return;
}
println!("Workspace: {}", style::bold(&output.repository));
println!(
"Repository mode: {} ({})",
output.repository_capability, output.storage_model
);
if output.hosted_enabled {
println!("Hosted: enabled");
}
if let Some(operation) = &output.operation {
println!(
"In progress: {} {} ({})",
operation.scope, operation.kind, operation.state
);
} else if let Some(remote_tracking) = &output.remote_tracking {
println!("Remote drift: {}", remote_tracking.message);
} else if let Some(hint) = &output.git_overlay_import_hint {
println!(
"Git import: {} other branch(es) still live only in Git ({})",
hint.missing_branch_count,
crate::cli::render::preview_list(&hint.missing_branches, hint.missing_branch_count,)
);
}
if !output.recommended_action.is_empty() {
println!("Next step: {}", style::dim(&output.recommended_action));
}
if let Some(current) = &output.current_thread {
println!("Current thread: {}", style::bold(current));
}
println!("Threads in flight: {}", output.thread_count);
println!();
for group in &output.groups {
println!("{}:", style::bold(&group.label));
for thread in &group.threads {
println!(
" {} [{} · {}]",
style::bold(&thread.name),
style::dim(&thread.visibility),
style::thread_state(&thread.coordination_status.to_string()),
);
if let Some(task) = &thread.task {
println!(" task: {task}");
}
if let Some(target) = &thread.target_thread {
println!(" target: {}", style::dim(target));
}
if let Some(parent) = &thread.parent_thread {
println!(" parent: {}", style::dim(parent));
}
if !thread.child_threads.is_empty() {
println!(" children: {}", thread.child_threads.join(", "));
}
if let Some(freshness) = &thread.freshness
&& *freshness != ThreadFreshness::Unknown
{
println!(" sync: {}", style::thread_state(&freshness.to_string()));
}
if let Some(git_branch_tip) = &thread.git_branch_tip {
println!(
" git tip: {} ({})",
style::dim(git_branch_tip),
git_history_label(thread.history_imported)
);
}
if let Some(actor) = &thread.actor
&& let Some(text) = crate::cli::render::actor_display(
actor.provider.as_deref(),
actor.model.as_deref(),
)
{
println!(" actor: {}", style::dim(&text));
}
if let Some(last_activity_at) = &thread.last_activity_at {
println!(" last activity: {}", style::dim(last_activity_at));
}
if !thread.blockers.is_empty() {
println!(
" blockers: {}",
style::warn(&thread.blockers.join(" | "))
);
}
if !thread.recommended_action.is_empty() {
println!(" next: {}", style::bold(&thread.recommended_action));
}
}
println!();
}
}
async fn watch_workspace(
cli: &Cli,
watch_iterations: Option<usize>,
watch_interval_ms: Option<u64>,
) -> Result<()> {
let interval = Duration::from_millis(watch_interval_ms.unwrap_or(1000));
let mut iterations = 0usize;
loop {
let output = build_workspace_output(cli)?;
render_workspace(cli, &output);
iterations += 1;
if watch_iterations.is_some_and(|limit| iterations >= limit) {
break;
}
sleep(interval).await;
}
Ok(())
}