11use rusqlite:: Connection ;
22
3+ fn column_exists ( conn : & Connection , table : & str , column : & str ) -> rusqlite:: Result < bool > {
4+ let mut stmt = conn. prepare ( & format ! ( "PRAGMA table_info({table})" ) ) ?;
5+ let exists = stmt
6+ . query_map ( [ ] , |row| row. get :: < _ , String > ( 1 ) ) ?
7+ . any ( |col| col. as_deref ( ) == Ok ( column) ) ;
8+ Ok ( exists)
9+ }
10+
311/// Run all database migrations. This is idempotent — tables are created only if
412/// they do not already exist.
513pub fn run_migrations ( conn : & Connection ) -> rusqlite:: Result < ( ) > {
@@ -46,12 +54,7 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
4654 ) ?;
4755
4856 // Add color column to threads if missing
49- let has_color: bool = conn
50- . prepare ( "PRAGMA table_info(threads)" ) ?
51- . query_map ( [ ] , |row| row. get :: < _ , String > ( 1 ) ) ?
52- . any ( |col| col. as_deref ( ) == Ok ( "color" ) ) ;
53-
54- if !has_color {
57+ if !column_exists ( conn, "threads" , "color" ) ? {
5558 conn. execute_batch ( "ALTER TABLE threads ADD COLUMN color TEXT;" ) ?;
5659 }
5760
@@ -74,15 +77,167 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
7477 ) ?;
7578
7679 // Add claude_session_id column to sessions if missing (used for --resume)
77- let has_claude_session_id: bool = conn
78- . prepare ( "PRAGMA table_info(sessions)" ) ?
79- . query_map ( [ ] , |row| row. get :: < _ , String > ( 1 ) ) ?
80- . any ( |col| col. as_deref ( ) == Ok ( "claude_session_id" ) ) ;
81-
82- if !has_claude_session_id {
80+ if !column_exists ( conn, "sessions" , "claude_session_id" ) ? {
8381 conn. execute_batch ( "ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;" ) ?;
8482 }
8583
84+ // Worktrees table — replaces worktree:* and pr:* settings keys
85+ conn. execute_batch (
86+ "
87+ CREATE TABLE IF NOT EXISTS worktrees (
88+ id TEXT PRIMARY KEY NOT NULL,
89+ thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
90+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
91+ branch TEXT NOT NULL,
92+ path TEXT NOT NULL,
93+ pr_number INTEGER,
94+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'merged', 'deleted', 'orphaned')),
95+ created_at TEXT NOT NULL,
96+ updated_at TEXT NOT NULL
97+ );
98+ " ,
99+ ) ?;
100+
101+ // Migrate existing worktree/pr data from settings table into worktrees table.
102+ // Format in settings: worktree:<thread_id> = <branch>|<path>, pr:<thread_id> = <number>
103+ {
104+ let mut wt_stmt = conn. prepare (
105+ "SELECT key, value FROM settings WHERE key LIKE 'worktree:%'"
106+ ) ?;
107+ let wt_rows: Vec < ( String , String ) > = wt_stmt
108+ . query_map ( [ ] , |row| Ok ( ( row. get :: < _ , String > ( 0 ) ?, row. get :: < _ , String > ( 1 ) ?) ) ) ?
109+ . filter_map ( |r| r. ok ( ) )
110+ . collect ( ) ;
111+
112+ for ( key, val) in & wt_rows {
113+ let thread_id = key. strip_prefix ( "worktree:" ) . unwrap_or ( key) ;
114+ let parts: Vec < & str > = val. splitn ( 2 , '|' ) . collect ( ) ;
115+ if parts. len ( ) != 2 { continue ; }
116+ let branch = parts[ 0 ] ;
117+ let path = parts[ 1 ] ;
118+
119+ // Look up PR number if linked
120+ let pr_num: Option < u32 > = conn
121+ . query_row (
122+ "SELECT value FROM settings WHERE key = ?1" ,
123+ rusqlite:: params![ format!( "pr:{thread_id}" ) ] ,
124+ |row| row. get :: < _ , String > ( 0 ) ,
125+ )
126+ . ok ( )
127+ . and_then ( |v| v. parse ( ) . ok ( ) ) ;
128+
129+ // Look up project_id from threads table
130+ let project_id: Option < String > = conn
131+ . query_row (
132+ "SELECT project_id FROM threads WHERE id = ?1" ,
133+ rusqlite:: params![ thread_id] ,
134+ |row| row. get ( 0 ) ,
135+ )
136+ . ok ( ) ;
137+
138+ if let Some ( project_id) = project_id {
139+ // Only migrate if not already present
140+ let exists: bool = conn
141+ . query_row (
142+ "SELECT COUNT(*) FROM worktrees WHERE thread_id = ?1" ,
143+ rusqlite:: params![ thread_id] ,
144+ |row| row. get :: < _ , i64 > ( 0 ) ,
145+ )
146+ . unwrap_or ( 0 ) > 0 ;
147+
148+ if !exists {
149+ let id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
150+ let now = chrono:: Utc :: now ( ) . to_rfc3339 ( ) ;
151+ let _ = conn. execute (
152+ "INSERT INTO worktrees (id, thread_id, project_id, branch, path, pr_number, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, ?7)" ,
153+ rusqlite:: params![ id, thread_id, project_id, branch, path, pr_num, now] ,
154+ ) ;
155+ }
156+ }
157+ }
158+
159+ // Clean up migrated settings
160+ if !wt_rows. is_empty ( ) {
161+ conn. execute_batch (
162+ "DELETE FROM settings WHERE key LIKE 'worktree:%'; DELETE FROM settings WHERE key LIKE 'pr:%';" ,
163+ ) ?;
164+ }
165+ }
166+
167+ // Turn checkpoints — tracks HEAD commit at start of each AI turn for undo
168+ conn. execute_batch (
169+ "
170+ CREATE TABLE IF NOT EXISTS turn_checkpoints (
171+ id TEXT PRIMARY KEY NOT NULL,
172+ thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
173+ turn_id TEXT NOT NULL,
174+ commit_sha TEXT NOT NULL,
175+ created_at TEXT NOT NULL
176+ );
177+ " ,
178+ ) ?;
179+
180+ // ── Add full PR lifecycle columns to worktrees ──
181+ if !column_exists ( conn, "worktrees" , "pr_state" ) ? {
182+ conn. execute_batch ( "ALTER TABLE worktrees ADD COLUMN pr_state TEXT;" ) ?;
183+ }
184+ if !column_exists ( conn, "worktrees" , "pr_merge_commit" ) ? {
185+ conn. execute_batch ( "ALTER TABLE worktrees ADD COLUMN pr_merge_commit TEXT;" ) ?;
186+ }
187+ if !column_exists ( conn, "worktrees" , "last_seen_comment_count" ) ? {
188+ conn. execute_batch (
189+ "ALTER TABLE worktrees ADD COLUMN last_seen_comment_count INTEGER NOT NULL DEFAULT 0;" ,
190+ ) ?;
191+ }
192+ if !column_exists ( conn, "worktrees" , "pr_url" ) ? {
193+ conn. execute_batch ( "ALTER TABLE worktrees ADD COLUMN pr_url TEXT;" ) ?;
194+ }
195+
196+ // ── Expand the CHECK constraint on worktrees.status to include 'closed' ──
197+ // SQLite can't ALTER a CHECK constraint; we rebuild the table only if needed.
198+ let needs_status_rebuild: bool = conn
199+ . query_row (
200+ "SELECT COUNT(*) FROM sqlite_master
201+ WHERE type='table' AND name='worktrees'
202+ AND sql LIKE '%''active'', ''merged'', ''deleted'', ''orphaned''%'
203+ AND sql NOT LIKE '%''closed''%'" ,
204+ [ ] ,
205+ |row| row. get :: < _ , i64 > ( 0 ) ,
206+ )
207+ . unwrap_or ( 0 ) > 0 ;
208+
209+ if needs_status_rebuild {
210+ conn. execute_batch (
211+ "
212+ CREATE TABLE worktrees_new (
213+ id TEXT PRIMARY KEY NOT NULL,
214+ thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
215+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
216+ branch TEXT NOT NULL,
217+ path TEXT NOT NULL,
218+ pr_number INTEGER,
219+ status TEXT NOT NULL DEFAULT 'active'
220+ CHECK(status IN ('active', 'merged', 'deleted', 'orphaned', 'closed')),
221+ created_at TEXT NOT NULL,
222+ updated_at TEXT NOT NULL,
223+ pr_state TEXT,
224+ pr_merge_commit TEXT,
225+ last_seen_comment_count INTEGER NOT NULL DEFAULT 0,
226+ pr_url TEXT
227+ );
228+ INSERT INTO worktrees_new (id, thread_id, project_id, branch, path, pr_number, status, created_at, updated_at, pr_state, pr_merge_commit, last_seen_comment_count, pr_url)
229+ SELECT id, thread_id, project_id, branch, path, pr_number, status, created_at, updated_at, pr_state, pr_merge_commit, last_seen_comment_count, pr_url FROM worktrees;
230+ DROP TABLE worktrees;
231+ ALTER TABLE worktrees_new RENAME TO worktrees;
232+ " ,
233+ ) ?;
234+ }
235+
236+ // ── Add system_kind to messages for event vs state differentiation ──
237+ if !column_exists ( conn, "messages" , "system_kind" ) ? {
238+ conn. execute_batch ( "ALTER TABLE messages ADD COLUMN system_kind TEXT;" ) ?;
239+ }
240+
86241 // Performance indexes — idempotent via IF NOT EXISTS
87242 conn. execute_batch (
88243 "
@@ -92,6 +247,9 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
92247 CREATE INDEX IF NOT EXISTS idx_sessions_thread_id ON sessions(thread_id);
93248 CREATE INDEX IF NOT EXISTS idx_sessions_thread_claude ON sessions(thread_id, claude_session_id);
94249 CREATE INDEX IF NOT EXISTS idx_usage_logs_thread_id ON usage_logs(thread_id);
250+ CREATE INDEX IF NOT EXISTS idx_worktrees_thread_id ON worktrees(thread_id);
251+ CREATE INDEX IF NOT EXISTS idx_worktrees_project_id ON worktrees(project_id);
252+ CREATE INDEX IF NOT EXISTS idx_turn_checkpoints_thread ON turn_checkpoints(thread_id);
95253 " ,
96254 ) ?;
97255
0 commit comments