use rusqlite::{params, Connection, Result}; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Worktree { pub id: String, pub ticket_id: String, pub path: String, pub branch_name: String, pub status: String, pub created_at: String, pub merged_at: Option, pub merged_into: Option, } fn from_row(row: &rusqlite::Row) -> rusqlite::Result { Ok(Worktree { id: row.get(0)?, ticket_id: row.get(1)?, path: row.get(2)?, branch_name: row.get(3)?, status: row.get(4)?, created_at: row.get(5)?, merged_at: row.get(6)?, merged_into: row.get(7)?, }) } const SELECT_ALL_COLS: &str = "SELECT id, ticket_id, path, branch_name, status, \ created_at, merged_at, merged_into FROM worktrees"; impl Worktree { pub fn insert( conn: &Connection, ticket_id: &str, path: &str, branch_name: &str, ) -> Result { let id = Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); conn.execute( "INSERT INTO worktrees (id, ticket_id, path, branch_name, status, created_at) \ VALUES (?1, ?2, ?3, ?4, 'Active', ?5)", params![id, ticket_id, path, branch_name, now], )?; Ok(Worktree { id, ticket_id: ticket_id.to_string(), path: path.to_string(), branch_name: branch_name.to_string(), status: "Active".to_string(), created_at: now, merged_at: None, merged_into: None, }) } pub fn get_by_id(conn: &Connection, id: &str) -> Result { let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS); conn.query_row(&sql, params![id], from_row) } pub fn get_by_ticket_id(conn: &Connection, ticket_id: &str) -> Result> { let sql = format!("{} WHERE ticket_id = ?1", SELECT_ALL_COLS); let mut stmt = conn.prepare(&sql)?; let mut rows = stmt.query_map(params![ticket_id], from_row)?; match rows.next() { Some(Ok(w)) => Ok(Some(w)), Some(Err(e)) => Err(e), None => Ok(None), } } pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { let sql = "SELECT w.id, w.ticket_id, w.path, w.branch_name, w.status, \ w.created_at, w.merged_at, w.merged_into \ FROM worktrees w \ JOIN processed_tickets pt ON w.ticket_id = pt.id \ WHERE pt.project_id = ?1 \ ORDER BY w.created_at DESC"; let mut stmt = conn.prepare(sql)?; let rows = stmt.query_map(params![project_id], from_row)?; rows.collect() } pub fn set_merged(conn: &Connection, id: &str, target_branch: &str) -> Result<()> { let now = chrono::Utc::now().to_rfc3339(); conn.execute( "UPDATE worktrees SET status = 'Merged', merged_at = ?1, merged_into = ?2 WHERE id = ?3", params![now, target_branch, id], )?; Ok(()) } pub fn delete(conn: &Connection, id: &str) -> Result<()> { conn.execute("DELETE FROM worktrees WHERE id = ?1", params![id])?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::db; use crate::models::agent::{Agent, AgentRole, AgentTool}; use crate::models::project::Project; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::{NewWatchedTracker, WatchedTracker}; fn setup() -> (Connection, String) { let conn = db::init_in_memory().expect("db init"); let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap(); let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); let tracker = WatchedTracker::insert( &conn, NewWatchedTracker { project_id: project.id.clone(), tracker_id: 100, tracker_label: "Bugs".to_string(), polling_interval: 10, analyst_agent_id: analyst.id.clone(), developer_agent_id: developer.id.clone(), filters: vec![], }, ) .unwrap(); let ticket = ProcessedTicket::insert_if_new(&conn, &project.id, &tracker.id, 42, "Bug 42", "{}") .unwrap() .unwrap(); (conn, ticket.id) } #[test] fn test_insert_and_get_by_id() { let (conn, ticket_id) = setup(); let wt = Worktree::insert(&conn, &ticket_id, "/tmp/orchai-42", "orchai/42").unwrap(); assert_eq!(wt.status, "Active"); assert_eq!(wt.branch_name, "orchai/42"); let found = Worktree::get_by_id(&conn, &wt.id).unwrap(); assert_eq!(found.id, wt.id); assert_eq!(found.ticket_id, ticket_id); assert_eq!(found.path, "/tmp/orchai-42"); } #[test] fn test_get_by_ticket_id() { let (conn, ticket_id) = setup(); let none = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap(); assert!(none.is_none()); Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap(); let some = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap(); assert!(some.is_some()); assert_eq!(some.unwrap().ticket_id, ticket_id); } #[test] fn test_list_by_project() { let conn = db::init_in_memory().expect("db init"); let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap(); let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); let tracker = WatchedTracker::insert( &conn, NewWatchedTracker { project_id: project.id.clone(), tracker_id: 100, tracker_label: "Bugs".to_string(), polling_interval: 10, analyst_agent_id: analyst.id.clone(), developer_agent_id: developer.id.clone(), filters: vec![], }, ) .unwrap(); let t1 = ProcessedTicket::insert_if_new(&conn, &project.id, &tracker.id, 1, "T1", "{}") .unwrap() .unwrap(); let t2 = ProcessedTicket::insert_if_new(&conn, &project.id, &tracker.id, 2, "T2", "{}") .unwrap() .unwrap(); Worktree::insert(&conn, &t1.id, "/wt1", "orchai/1").unwrap(); Worktree::insert(&conn, &t2.id, "/wt2", "orchai/2").unwrap(); let worktrees = Worktree::list_by_project(&conn, &project.id).unwrap(); assert_eq!(worktrees.len(), 2); } #[test] fn test_list_by_project_includes_external_source_worktrees() { let conn = db::init_in_memory().expect("db init"); let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap(); let ticket = ProcessedTicket::insert_external( &conn, &project.id, "graylog", Some("subject-1"), -101, "Graylog subject", "{}", ) .expect("external ticket insert should succeed"); Worktree::insert(&conn, &ticket.id, "/wt-graylog", "orchai/graylog-101") .expect("worktree insert should succeed"); let worktrees = Worktree::list_by_project(&conn, &project.id).unwrap(); assert_eq!(worktrees.len(), 1); assert_eq!(worktrees[0].ticket_id, ticket.id); } #[test] fn test_set_merged() { let (conn, ticket_id) = setup(); let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap(); Worktree::set_merged(&conn, &wt.id, "feature/login").unwrap(); let updated = Worktree::get_by_id(&conn, &wt.id).unwrap(); assert_eq!(updated.status, "Merged"); assert_eq!(updated.merged_into.unwrap(), "feature/login"); let merged_at = updated .merged_at .as_deref() .expect("merged_at should be set"); assert!(chrono::DateTime::parse_from_rfc3339(merged_at).is_ok()); } #[test] fn test_delete() { let (conn, ticket_id) = setup(); let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap(); Worktree::delete(&conn, &wt.id).unwrap(); let result = Worktree::get_by_id(&conn, &wt.id); assert!(result.is_err()); } }