1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::core::{Event, EventFilter, EventStore, EventType, Result, ShuttleError};
5use crate::task::{HandoffStatus, HandoffSummary, TaskStatus, TaskSummary};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Context {
10 pub repo: String,
11 pub branch: String,
12 pub commit: String,
13 pub git_remote: Option<String>,
14 pub dirty: bool,
15 pub dirty_files: Vec<String>,
16 pub open_tasks: Vec<TaskSummary>,
17 pub claimed_tasks: Vec<TaskSummary>,
18 pub recent_decisions: Vec<Event>,
19 pub related_memories: Vec<Event>,
20 pub recent_messages: Vec<Event>,
21 pub pending_handoffs: Vec<HandoffSummary>,
22 pub recent_completed_handoffs: Vec<HandoffSummary>,
23 pub inbox: Vec<Event>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct RepoStatus {
28 pub repo_path: String,
29 pub git_remote: Option<String>,
30 pub branch: String,
31 pub commit: String,
32 pub dirty: bool,
33 pub dirty_files: Vec<String>,
34}
35
36pub fn repo_status(path: impl AsRef<Path>) -> Result<RepoStatus> {
37 let path = path.as_ref();
38 let repo_path = git(path, ["rev-parse", "--show-toplevel"])?;
39 let repo_path_buf = PathBuf::from(repo_path.trim());
40 let branch = git(&repo_path_buf, ["rev-parse", "--abbrev-ref", "HEAD"])?;
41 let commit = git(&repo_path_buf, ["rev-parse", "HEAD"])?;
42 let remote = git(&repo_path_buf, ["config", "--get", "remote.origin.url"]).ok();
43 let status = git(&repo_path_buf, ["status", "--porcelain"])?;
44 let dirty_files = parse_dirty_files(&status);
45
46 Ok(RepoStatus {
47 repo_path: repo_path.trim().to_owned(),
48 git_remote: remote
49 .map(|value| value.trim().to_owned())
50 .filter(|value| !value.is_empty()),
51 branch: branch.trim().to_owned(),
52 commit: commit.trim().to_owned(),
53 dirty: !dirty_files.is_empty(),
54 dirty_files,
55 })
56}
57
58pub async fn assemble_context(
59 store: &impl EventStore,
60 cwd: impl AsRef<Path>,
61 workspace_id: &str,
62 agent: &str,
63) -> Result<Context> {
64 let status = repo_status(cwd)?;
65 let task_summaries = crate::task::tasks(store, Some(workspace_id), None).await?;
66 let open_tasks = task_summaries
67 .iter()
68 .filter(|task| task.status == TaskStatus::Open)
69 .take(20)
70 .cloned()
71 .collect();
72 let claimed_tasks = task_summaries
73 .iter()
74 .filter(|task| task.status == TaskStatus::Claimed)
75 .take(20)
76 .cloned()
77 .collect();
78 let recent_decisions = store
79 .list(EventFilter {
80 event_type: Some(EventType::Decision),
81 workspace_id: Some(workspace_id.to_owned()),
82 limit: Some(20),
83 ..EventFilter::default()
84 })
85 .await?;
86 let related_memories = store
87 .list(EventFilter {
88 event_type: Some(EventType::Memory),
89 workspace_id: Some(workspace_id.to_owned()),
90 limit: Some(20),
91 ..EventFilter::default()
92 })
93 .await?;
94 let recent_messages = store
95 .list(EventFilter {
96 event_type: Some(EventType::Message),
97 workspace_id: Some(workspace_id.to_owned()),
98 limit: Some(20),
99 ..EventFilter::default()
100 })
101 .await?;
102 let handoff_summaries = crate::task::handoffs(store, Some(workspace_id), None).await?;
103 let pending_handoffs = handoff_summaries
104 .iter()
105 .filter(|handoff| handoff.status == HandoffStatus::Pending)
106 .take(20)
107 .cloned()
108 .collect();
109 let recent_completed_handoffs = handoff_summaries
110 .iter()
111 .filter(|handoff| handoff.status == HandoffStatus::Completed)
112 .take(20)
113 .cloned()
114 .collect();
115 let inbox = inbox_events(store, workspace_id, agent).await?;
116
117 Ok(Context {
118 repo: status.repo_path,
119 branch: status.branch,
120 commit: status.commit,
121 git_remote: status.git_remote,
122 dirty: status.dirty,
123 dirty_files: status.dirty_files,
124 open_tasks,
125 claimed_tasks,
126 recent_decisions,
127 related_memories,
128 recent_messages,
129 pending_handoffs,
130 recent_completed_handoffs,
131 inbox,
132 })
133}
134
135pub fn repo_id(status: &RepoStatus) -> String {
136 status
137 .git_remote
138 .clone()
139 .unwrap_or_else(|| status.repo_path.clone())
140}
141
142fn parse_dirty_files(status: &str) -> Vec<String> {
143 status
144 .lines()
145 .filter_map(|line| {
146 let path = line.get(3..)?.trim();
147 if path.is_empty() {
148 None
149 } else if let Some((_, destination)) = path.split_once(" -> ") {
150 Some(destination.to_owned())
151 } else {
152 Some(path.to_owned())
153 }
154 })
155 .collect()
156}
157
158async fn inbox_events(
159 store: &impl EventStore,
160 workspace_id: &str,
161 agent: &str,
162) -> Result<Vec<Event>> {
163 let mut events = store
164 .list(EventFilter {
165 event_type: Some(EventType::Message),
166 workspace_id: Some(workspace_id.to_owned()),
167 recipient: Some(agent.to_owned()),
168 limit: Some(20),
169 ..EventFilter::default()
170 })
171 .await?;
172 events.extend(
173 store
174 .list(EventFilter {
175 event_type: Some(EventType::Handoff),
176 workspace_id: Some(workspace_id.to_owned()),
177 recipient: Some(agent.to_owned()),
178 limit: Some(20),
179 ..EventFilter::default()
180 })
181 .await?,
182 );
183 events.sort_by(|left, right| right.created_at.cmp(&left.created_at));
184 events.truncate(20);
185 Ok(events)
186}
187
188fn git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<String> {
189 let output = Command::new("git")
190 .args(args)
191 .current_dir(cwd)
192 .output()
193 .map_err(|err| ShuttleError::Store(format!("failed to run git: {err}")))?;
194
195 if !output.status.success() {
196 let stderr = String::from_utf8_lossy(&output.stderr);
197 return Err(ShuttleError::Store(format!(
198 "git command failed: {}",
199 stderr.trim()
200 )));
201 }
202
203 Ok(String::from_utf8_lossy(&output.stdout).to_string())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::core::EventStore;
210 use crate::store::SqliteEventStore;
211 use std::fs;
212
213 #[test]
214 fn repo_status_reports_dirty_files() {
215 let dir = tempfile::tempdir().unwrap();
216 Command::new("git")
217 .arg("init")
218 .current_dir(dir.path())
219 .output()
220 .unwrap();
221 fs::write(dir.path().join("README.md"), "repo").unwrap();
222 Command::new("git")
223 .args(["add", "README.md"])
224 .current_dir(dir.path())
225 .output()
226 .unwrap();
227 Command::new("git")
228 .args([
229 "-c",
230 "user.name=Shuttle Test",
231 "-c",
232 "user.email=shuttle@example.test",
233 "commit",
234 "-m",
235 "initial",
236 ])
237 .current_dir(dir.path())
238 .output()
239 .unwrap();
240 fs::write(dir.path().join("note.txt"), "dirty").unwrap();
241
242 let status = repo_status(dir.path()).unwrap();
243
244 assert!(status.dirty);
245 assert_eq!(status.dirty_files, vec!["note.txt"]);
246 }
247
248 #[test]
249 fn repo_id_prefers_remote_over_path() {
250 let status = RepoStatus {
251 repo_path: "/tmp/repo".into(),
252 git_remote: Some("https://example.test/repo.git".into()),
253 branch: "main".into(),
254 commit: "abc".into(),
255 dirty: false,
256 dirty_files: Vec::new(),
257 };
258
259 assert_eq!(repo_id(&status), "https://example.test/repo.git");
260 }
261
262 #[test]
263 fn dirty_file_parser_normalizes_rename_destinations() {
264 let files = parse_dirty_files("R old-name.txt -> new-name.txt\nC old.rs -> copy.rs\n");
265
266 assert_eq!(files, vec!["new-name.txt", "copy.rs"]);
267 }
268
269 #[test]
270 fn context_excludes_claimed_tasks_from_open_tasks() {
271 let repo = tempfile::tempdir().unwrap();
272 let data = tempfile::tempdir().unwrap();
273 init_git_repo(repo.path());
274 let store = SqliteEventStore::open(data.path().join("shuttle.db")).unwrap();
275 let task = crate::task::new_task(
276 "workspace".into(),
277 "codex".into(),
278 "session".into(),
279 "ship mvp".into(),
280 );
281 let claim = crate::task::new_claim(
282 "workspace".into(),
283 "claude".into(),
284 "session".into(),
285 task.id,
286 );
287 futures_executor::block_on(store.append(task)).unwrap();
288 futures_executor::block_on(store.append(claim)).unwrap();
289
290 let context =
291 futures_executor::block_on(assemble_context(&store, repo.path(), "workspace", "codex"))
292 .unwrap();
293
294 assert!(context.open_tasks.is_empty());
295 }
296
297 fn init_git_repo(path: &Path) {
298 Command::new("git")
299 .arg("init")
300 .current_dir(path)
301 .output()
302 .unwrap();
303 fs::write(path.join("README.md"), "repo").unwrap();
304 Command::new("git")
305 .args(["add", "README.md"])
306 .current_dir(path)
307 .output()
308 .unwrap();
309 Command::new("git")
310 .args([
311 "-c",
312 "user.name=Shuttle Test",
313 "-c",
314 "user.email=shuttle@example.test",
315 "commit",
316 "-m",
317 "initial",
318 ])
319 .current_dir(path)
320 .output()
321 .unwrap();
322 }
323}