Skip to main content

remem/
git_trace.rs

1use anyhow::{bail, Result};
2use rusqlite::types::Type;
3use rusqlite::{params, Connection, OptionalExtension, Row};
4use serde::{Deserialize, Serialize};
5
6use crate::git_util::{short_sha_for, GitCommitMetadata};
7
8mod poisoning;
9use poisoning::gate_link_summary;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct GitCommitRecord {
13    pub id: i64,
14    pub project: String,
15    pub repo_path: String,
16    pub sha: String,
17    pub short_sha: String,
18    pub branch: Option<String>,
19    pub message: Option<String>,
20    pub authored_at_epoch: Option<i64>,
21    pub changed_files: Vec<String>,
22    pub created_at_epoch: i64,
23    pub updated_at_epoch: i64,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct SessionSummaryTrace {
28    pub request: Option<String>,
29    pub completed: Option<String>,
30    pub decisions: Option<String>,
31    pub learned: Option<String>,
32    pub next_steps: Option<String>,
33    pub preferences: Option<String>,
34    pub created_at_epoch: Option<i64>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct CommitSessionLink {
39    pub session_id: String,
40    pub memory_session_id: Option<String>,
41    pub source: String,
42    pub linked_at_epoch: i64,
43    pub summary: Option<SessionSummaryTrace>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct CommitLookup {
48    pub git: GitCommitRecord,
49    pub sessions: Vec<CommitSessionLink>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct SessionCommit {
54    pub git: GitCommitRecord,
55    pub link: CommitSessionLink,
56}
57
58pub struct CommitMetadataInput<'a> {
59    pub project: &'a str,
60    pub repo_path: Option<&'a str>,
61    pub sha: &'a str,
62    pub short_sha: Option<&'a str>,
63    pub branch: Option<&'a str>,
64    pub message: Option<&'a str>,
65    pub authored_at_epoch: Option<i64>,
66    pub changed_files: &'a [String],
67}
68
69pub struct CommitLinkInput<'a> {
70    pub metadata: CommitMetadataInput<'a>,
71    pub session_id: &'a str,
72    pub memory_session_id: Option<&'a str>,
73    pub source: &'a str,
74}
75
76pub fn upsert_commit_metadata(conn: &Connection, input: &CommitMetadataInput<'_>) -> Result<i64> {
77    let sha = normalize_sha(input.sha)?;
78    let short_sha = input
79        .short_sha
80        .map(str::trim)
81        .filter(|value| !value.is_empty())
82        .map(str::to_string)
83        .unwrap_or_else(|| short_sha_for(&sha));
84    let repo_path = input
85        .repo_path
86        .map(str::trim)
87        .filter(|value| !value.is_empty())
88        .unwrap_or(input.project);
89    let changed_files = serde_json::to_string(input.changed_files)?;
90    let now = chrono::Utc::now().timestamp();
91
92    if sha != short_sha {
93        if let Some(id) =
94            find_upgradeable_placeholder_commit_id(conn, input.project, &sha, &short_sha)?
95        {
96            conn.execute(
97                "UPDATE git_commits SET
98                   repo_path = ?1,
99                   sha = ?2,
100                   short_sha = ?3,
101                   branch = COALESCE(?4, branch),
102                   message = COALESCE(?5, message),
103                   authored_at_epoch = COALESCE(?6, authored_at_epoch),
104                   changed_files = CASE WHEN ?7 != '[]' THEN ?7 ELSE changed_files END,
105                   updated_at_epoch = ?8
106                 WHERE id = ?9",
107                params![
108                    repo_path,
109                    sha,
110                    short_sha,
111                    input.branch,
112                    input.message,
113                    input.authored_at_epoch,
114                    changed_files,
115                    now,
116                    id
117                ],
118            )?;
119            return Ok(id);
120        }
121    }
122
123    conn.execute(
124        "INSERT INTO git_commits
125         (project, repo_path, sha, short_sha, branch, message, authored_at_epoch,
126          changed_files, created_at_epoch, updated_at_epoch)
127         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
128         ON CONFLICT(project, sha) DO UPDATE SET
129           repo_path = excluded.repo_path,
130           short_sha = excluded.short_sha,
131           branch = COALESCE(excluded.branch, git_commits.branch),
132           message = COALESCE(excluded.message, git_commits.message),
133           authored_at_epoch = COALESCE(excluded.authored_at_epoch, git_commits.authored_at_epoch),
134           changed_files = CASE
135             WHEN excluded.changed_files != '[]' THEN excluded.changed_files
136             ELSE git_commits.changed_files
137           END,
138           updated_at_epoch = excluded.updated_at_epoch",
139        params![
140            input.project,
141            repo_path,
142            sha,
143            short_sha,
144            input.branch,
145            input.message,
146            input.authored_at_epoch,
147            changed_files,
148            now
149        ],
150    )?;
151
152    let id = conn.query_row(
153        "SELECT id FROM git_commits WHERE project = ?1 AND sha = ?2",
154        params![input.project, sha],
155        |row| row.get(0),
156    )?;
157    Ok(id)
158}
159
160pub fn link_commit_to_session(conn: &Connection, input: &CommitLinkInput<'_>) -> Result<i64> {
161    let session_id = input.session_id.trim();
162    if session_id.is_empty() {
163        bail!("session_id is required to link a commit");
164    }
165    let source = input.source.trim();
166    if source.is_empty() {
167        bail!("source is required to link a commit");
168    }
169
170    let commit_id = upsert_commit_metadata(conn, &input.metadata)?;
171    link_session_to_commit_id(
172        conn,
173        commit_id,
174        None,
175        session_id,
176        input.memory_session_id,
177        source,
178    )?;
179    Ok(commit_id)
180}
181
182pub fn link_git_metadata_to_session(
183    conn: &Connection,
184    project: &str,
185    session_id: &str,
186    memory_session_id: &str,
187    metadata: &GitCommitMetadata,
188    source: &str,
189) -> Result<i64> {
190    link_commit_to_session(
191        conn,
192        &CommitLinkInput {
193            metadata: CommitMetadataInput {
194                project,
195                repo_path: Some(&metadata.repo_path),
196                sha: &metadata.sha,
197                short_sha: Some(&metadata.short_sha),
198                branch: metadata.branch.as_deref(),
199                message: metadata.message.as_deref(),
200                authored_at_epoch: metadata.authored_at_epoch,
201                changed_files: &metadata.changed_files,
202            },
203            session_id,
204            memory_session_id: Some(memory_session_id),
205            source,
206        },
207    )
208}
209
210pub fn link_captured_git_metadata_to_session(
211    conn: &Connection,
212    project: &str,
213    session_row_id: i64,
214    session_id: &str,
215    memory_session_id: &str,
216    metadata: &GitCommitMetadata,
217) -> Result<i64> {
218    validate_capture_session_identity(conn, project, session_row_id, session_id)?;
219    let full_sha = validate_full_commit_sha(&metadata.sha)?;
220    let short_sha = metadata.short_sha.trim().to_ascii_lowercase();
221    if short_sha.len() < 7
222        || !short_sha.bytes().all(|byte| byte.is_ascii_hexdigit())
223        || !full_sha.starts_with(&short_sha)
224    {
225        bail!("captured Git metadata short SHA does not match full SHA {full_sha}");
226    }
227    let commit_id = upsert_commit_metadata(
228        conn,
229        &CommitMetadataInput {
230            project,
231            repo_path: Some(&metadata.repo_path),
232            sha: &full_sha,
233            short_sha: Some(&short_sha),
234            branch: metadata.branch.as_deref(),
235            message: metadata.message.as_deref(),
236            authored_at_epoch: metadata.authored_at_epoch,
237            changed_files: &metadata.changed_files,
238        },
239    )?;
240    link_session_to_commit_id(
241        conn,
242        commit_id,
243        Some(session_row_id),
244        session_id,
245        Some(memory_session_id),
246        "capture_git_evidence",
247    )?;
248    Ok(commit_id)
249}
250
251pub fn link_observed_commit_to_session(
252    conn: &Connection,
253    project: &str,
254    session_id: &str,
255    memory_session_id: &str,
256    commit_sha: &str,
257    branch: Option<&str>,
258    metadata: Option<&GitCommitMetadata>,
259) -> Result<i64> {
260    if let Some(metadata) = metadata.filter(|metadata| metadata.matches_sha(commit_sha)) {
261        return link_git_metadata_to_session(
262            conn,
263            project,
264            session_id,
265            memory_session_id,
266            metadata,
267            "git_metadata",
268        );
269    }
270
271    if let Some(commit_id) = find_existing_commit_id(conn, project, commit_sha)? {
272        link_session_to_commit_id(
273            conn,
274            commit_id,
275            None,
276            session_id,
277            Some(memory_session_id),
278            "observations",
279        )?;
280        return Ok(commit_id);
281    }
282
283    let changed_files = Vec::new();
284    link_commit_to_session(
285        conn,
286        &CommitLinkInput {
287            metadata: CommitMetadataInput {
288                project,
289                repo_path: Some(project),
290                sha: commit_sha,
291                short_sha: None,
292                branch,
293                message: None,
294                authored_at_epoch: None,
295                changed_files: &changed_files,
296            },
297            session_id,
298            memory_session_id: Some(memory_session_id),
299            source: "observations",
300        },
301    )
302}
303
304pub fn link_observed_commits_for_session(
305    conn: &Connection,
306    project: &str,
307    session_id: &str,
308    memory_session_id: &str,
309) -> Result<usize> {
310    let mut stmt = conn.prepare(
311        "SELECT commit_sha, branch
312         FROM observations
313         WHERE project = ?1
314           AND memory_session_id = ?2
315           AND commit_sha IS NOT NULL
316           AND length(trim(commit_sha)) > 0
317         GROUP BY commit_sha, branch",
318    )?;
319    let rows = stmt.query_map(params![project, memory_session_id], |row| {
320        Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
321    })?;
322    let observed = rows.collect::<rusqlite::Result<Vec<_>>>()?;
323
324    for (commit_sha, branch) in &observed {
325        link_observed_commit_to_session(
326            conn,
327            project,
328            session_id,
329            memory_session_id,
330            commit_sha,
331            branch.as_deref(),
332            None,
333        )?;
334    }
335
336    Ok(observed.len())
337}
338
339fn find_upgradeable_placeholder_commit_id(
340    conn: &Connection,
341    project: &str,
342    full_sha: &str,
343    short_sha: &str,
344) -> Result<Option<i64>> {
345    let mut stmt = conn.prepare(
346        "SELECT id
347         FROM git_commits
348         WHERE project = ?1
349           AND sha != ?2
350           AND (
351             sha = ?3
352             OR short_sha = ?3
353             OR ?2 LIKE sha || '%'
354             OR ?2 LIKE short_sha || '%'
355           )
356         ORDER BY
357           CASE
358             WHEN sha = ?3 THEN 0
359             WHEN short_sha = ?3 THEN 1
360             WHEN ?2 LIKE sha || '%' THEN 2
361             ELSE 3
362           END,
363           length(sha) DESC,
364           updated_at_epoch DESC
365         LIMIT 2",
366    )?;
367    let ids = stmt
368        .query_map(params![project, full_sha, short_sha], |row| {
369            row.get::<_, i64>(0)
370        })?
371        .collect::<rusqlite::Result<Vec<_>>>()?;
372    match ids.as_slice() {
373        [] => Ok(None),
374        [id] => Ok(Some(*id)),
375        _ => bail!("ambiguous commit SHA placeholder: {full_sha}"),
376    }
377}
378
379fn find_existing_commit_id(
380    conn: &Connection,
381    project: &str,
382    sha_or_prefix: &str,
383) -> Result<Option<i64>> {
384    let needle = normalize_sha(sha_or_prefix)?;
385    let prefix = format!("{needle}%");
386    let mut stmt = conn.prepare(
387        "SELECT id
388         FROM git_commits
389         WHERE project = ?1
390           AND (
391             sha = ?2
392             OR short_sha = ?2
393             OR sha LIKE ?3
394             OR short_sha LIKE ?3
395             OR ?2 LIKE sha || '%'
396             OR ?2 LIKE short_sha || '%'
397           )
398         ORDER BY CASE WHEN sha = ?2 THEN 0 WHEN short_sha = ?2 THEN 1 ELSE 2 END,
399                  updated_at_epoch DESC
400         LIMIT 2",
401    )?;
402    let ids = stmt
403        .query_map(params![project, needle, prefix], |row| row.get::<_, i64>(0))?
404        .collect::<rusqlite::Result<Vec<_>>>()?;
405    match ids.as_slice() {
406        [] => Ok(None),
407        [id] => Ok(Some(*id)),
408        _ => bail!("ambiguous commit SHA prefix: {sha_or_prefix}"),
409    }
410}
411
412fn link_session_to_commit_id(
413    conn: &Connection,
414    commit_id: i64,
415    session_row_id: Option<i64>,
416    session_id: &str,
417    memory_session_id: Option<&str>,
418    source: &str,
419) -> Result<()> {
420    let now = chrono::Utc::now().timestamp();
421    if let Some(session_row_id) = session_row_id {
422        promote_exact_legacy_link(
423            conn,
424            commit_id,
425            session_row_id,
426            session_id,
427            memory_session_id,
428        )?;
429        conn.execute(
430            "INSERT INTO git_commit_sessions
431             (commit_id, session_row_id, session_id, memory_session_id, source, linked_at_epoch)
432             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
433             ON CONFLICT(commit_id, session_row_id) WHERE session_row_id IS NOT NULL DO UPDATE SET
434               session_id = excluded.session_id,
435               memory_session_id = COALESCE(excluded.memory_session_id, git_commit_sessions.memory_session_id),
436               source = CASE
437                 WHEN git_commit_sessions.source IN ('git_metadata', 'capture_git_evidence')
438                   THEN git_commit_sessions.source
439                 WHEN excluded.source IN ('git_metadata', 'capture_git_evidence')
440                   THEN excluded.source
441                 ELSE excluded.source
442               END",
443            params![
444                commit_id,
445                session_row_id,
446                session_id,
447                memory_session_id,
448                source,
449                now
450            ],
451        )?;
452    } else {
453        conn.execute(
454            "INSERT INTO git_commit_sessions
455             (commit_id, session_row_id, session_id, memory_session_id, source, linked_at_epoch)
456             VALUES (?1, NULL, ?2, ?3, ?4, ?5)
457             ON CONFLICT(commit_id, session_id) WHERE session_row_id IS NULL DO UPDATE SET
458               memory_session_id = COALESCE(excluded.memory_session_id, git_commit_sessions.memory_session_id),
459               source = CASE
460                 WHEN git_commit_sessions.source IN ('git_metadata', 'capture_git_evidence')
461                   THEN git_commit_sessions.source
462                 WHEN excluded.source IN ('git_metadata', 'capture_git_evidence')
463                   THEN excluded.source
464                 ELSE excluded.source
465               END",
466            params![commit_id, session_id, memory_session_id, source, now],
467        )?;
468    }
469    Ok(())
470}
471
472fn promote_exact_legacy_link(
473    conn: &Connection,
474    commit_id: i64,
475    session_row_id: i64,
476    session_id: &str,
477    memory_session_id: Option<&str>,
478) -> Result<()> {
479    let expected_memory_session_id = format!("capture-rollup-{session_row_id}");
480    if memory_session_id != Some(expected_memory_session_id.as_str()) {
481        return Ok(());
482    }
483    conn.execute(
484        "DELETE FROM git_commit_sessions
485         WHERE commit_id = ?1
486           AND session_row_id IS NULL
487           AND session_id = ?2
488           AND memory_session_id = ?3
489           AND EXISTS (
490             SELECT 1 FROM git_commit_sessions durable
491             WHERE durable.commit_id = ?1 AND durable.session_row_id = ?4
492           )",
493        params![
494            commit_id,
495            session_id,
496            expected_memory_session_id,
497            session_row_id
498        ],
499    )?;
500    conn.execute(
501        "UPDATE git_commit_sessions
502         SET session_row_id = ?4
503         WHERE commit_id = ?1
504           AND session_row_id IS NULL
505           AND session_id = ?2
506           AND memory_session_id = ?3",
507        params![
508            commit_id,
509            session_id,
510            expected_memory_session_id,
511            session_row_id
512        ],
513    )?;
514    Ok(())
515}
516
517pub fn lookup_commit(
518    conn: &Connection,
519    project: Option<&str>,
520    sha_or_prefix: &str,
521) -> Result<Vec<CommitLookup>> {
522    let needle = normalize_sha(sha_or_prefix)?;
523    let prefix = format!("{needle}%");
524    let commits = if let Some(project) = project {
525        let mut stmt = conn.prepare(
526            "SELECT id, project, repo_path, sha, short_sha, branch, message,
527                    authored_at_epoch, changed_files, created_at_epoch, updated_at_epoch
528             FROM git_commits
529             WHERE project = ?1
530               AND (sha = ?2 OR short_sha = ?2 OR sha LIKE ?3 OR short_sha LIKE ?3)
531             ORDER BY CASE WHEN sha = ?2 THEN 0 WHEN short_sha = ?2 THEN 1 ELSE 2 END,
532                      updated_at_epoch DESC
533             LIMIT 20",
534        )?;
535        let rows = stmt.query_map(params![project, needle, prefix], commit_from_row)?;
536        rows.collect::<rusqlite::Result<Vec<_>>>()?
537    } else {
538        let mut stmt = conn.prepare(
539            "SELECT id, project, repo_path, sha, short_sha, branch, message,
540                    authored_at_epoch, changed_files, created_at_epoch, updated_at_epoch
541             FROM git_commits
542             WHERE sha = ?1 OR short_sha = ?1 OR sha LIKE ?2 OR short_sha LIKE ?2
543             ORDER BY CASE WHEN sha = ?1 THEN 0 WHEN short_sha = ?1 THEN 1 ELSE 2 END,
544                      updated_at_epoch DESC
545             LIMIT 20",
546        )?;
547        let rows = stmt.query_map(params![needle, prefix], commit_from_row)?;
548        rows.collect::<rusqlite::Result<Vec<_>>>()?
549    };
550
551    commits
552        .into_iter()
553        .map(|git| {
554            let sessions = linked_sessions_for_commit(conn, git.id, &git.project)?;
555            Ok(CommitLookup { git, sessions })
556        })
557        .collect()
558}
559
560pub fn commits_for_session(
561    conn: &Connection,
562    project: Option<&str>,
563    session_id: &str,
564    limit: i64,
565) -> Result<Vec<SessionCommit>> {
566    let session_id = session_id.trim();
567    if session_id.is_empty() {
568        bail!("session_id is required");
569    }
570    let limit = limit.clamp(1, 100);
571    if let Some(project) = project {
572        query_session_commits(
573            conn,
574            "WHERE c.project = ?1 AND (l.session_id = ?2 OR l.memory_session_id = ?2)",
575            params![project, session_id, limit],
576        )
577    } else {
578        query_session_commits(
579            conn,
580            "WHERE l.session_id = ?1 OR l.memory_session_id = ?1",
581            params![session_id, limit],
582        )
583    }
584}
585
586fn query_session_commits<P>(
587    conn: &Connection,
588    where_clause: &str,
589    params: P,
590) -> Result<Vec<SessionCommit>>
591where
592    P: rusqlite::Params,
593{
594    let sql = format!(
595        "SELECT c.id, c.project, c.repo_path, c.sha, c.short_sha, c.branch, c.message,
596                c.authored_at_epoch, c.changed_files, c.created_at_epoch, c.updated_at_epoch,
597                l.session_id, l.memory_session_id, l.source, l.linked_at_epoch,
598                ss.request, ss.completed, ss.decisions, ss.learned, ss.next_steps,
599                ss.preferences, ss.created_at_epoch, ss.id
600         FROM git_commit_sessions l
601         JOIN git_commits c ON c.id = l.commit_id
602         LEFT JOIN session_summaries ss ON ss.id = (
603           SELECT latest.id
604           FROM session_summaries latest
605           WHERE latest.memory_session_id = l.memory_session_id
606             AND latest.project = c.project
607             AND COALESCE(latest.poisoning_status, 'legacy_unscanned') != 'quarantined'
608           ORDER BY COALESCE(latest.covered_to_event_id, 0) DESC,
609                    latest.created_at_epoch DESC,
610                    latest.id DESC
611           LIMIT 1
612         )
613         {where_clause}
614         ORDER BY COALESCE(c.authored_at_epoch, c.updated_at_epoch) DESC, c.id DESC
615         LIMIT ?{}",
616        if where_clause.contains("?2") {
617            "3"
618        } else {
619            "2"
620        }
621    );
622    let mut stmt = conn.prepare(&sql)?;
623    let rows = stmt.query_map(params, |row| {
624        let (link, summary_id) = link_from_row(row, 11)?;
625        Ok((
626            SessionCommit {
627                git: commit_from_row(row)?,
628                link,
629            },
630            summary_id,
631        ))
632    })?;
633    let mut commits = Vec::new();
634    for row in rows {
635        let (mut commit, summary_id) = row?;
636        gate_link_summary(conn, &mut commit.link, summary_id);
637        commits.push(commit);
638    }
639    Ok(commits)
640}
641
642fn linked_sessions_for_commit(
643    conn: &Connection,
644    commit_id: i64,
645    project: &str,
646) -> Result<Vec<CommitSessionLink>> {
647    let mut stmt = conn.prepare(
648        "SELECT l.session_id, l.memory_session_id, l.source, l.linked_at_epoch,
649                ss.request, ss.completed, ss.decisions, ss.learned, ss.next_steps,
650                ss.preferences, ss.created_at_epoch, ss.id
651         FROM git_commit_sessions l
652         LEFT JOIN session_summaries ss ON ss.id = (
653           SELECT latest.id
654           FROM session_summaries latest
655           WHERE latest.memory_session_id = l.memory_session_id
656             AND latest.project = ?2
657             AND COALESCE(latest.poisoning_status, 'legacy_unscanned') != 'quarantined'
658           ORDER BY COALESCE(latest.covered_to_event_id, 0) DESC,
659                    latest.created_at_epoch DESC,
660                    latest.id DESC
661           LIMIT 1
662         )
663         WHERE l.commit_id = ?1
664         ORDER BY l.linked_at_epoch DESC",
665    )?;
666    let rows = stmt.query_map(params![commit_id, project], |row| link_from_row(row, 0))?;
667    let mut links = Vec::new();
668    for row in rows {
669        let (mut link, summary_id) = row?;
670        gate_link_summary(conn, &mut link, summary_id);
671        links.push(link);
672    }
673    Ok(links)
674}
675
676fn normalize_sha(raw: &str) -> Result<String> {
677    let sha = raw.trim();
678    if sha.is_empty() {
679        bail!("commit SHA is required");
680    }
681    Ok(sha.to_string())
682}
683
684fn validate_full_commit_sha(raw: &str) -> Result<String> {
685    let sha = raw.trim().to_ascii_lowercase();
686    if !matches!(sha.len(), 40 | 64) || !sha.bytes().all(|byte| byte.is_ascii_hexdigit()) {
687        bail!("captured commit SHA must be a full 40- or 64-character hexadecimal hash");
688    }
689    Ok(sha)
690}
691
692fn validate_capture_session_identity(
693    conn: &Connection,
694    project: &str,
695    session_row_id: i64,
696    session_id: &str,
697) -> Result<()> {
698    let identity = conn
699        .query_row(
700            "SELECT sessions.session_id, projects.project_path
701             FROM sessions
702             JOIN projects ON projects.id = sessions.project_id
703             WHERE sessions.id = ?1",
704            [session_row_id],
705            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
706        )
707        .optional()?;
708    let Some((stored_session_id, stored_project)) = identity else {
709        bail!("captured commit session_row_id {session_row_id} does not exist");
710    };
711    if stored_session_id != session_id || stored_project != project {
712        bail!(
713            "captured commit identity mismatch for session_row_id {session_row_id}: expected project={project} session={session_id}, stored project={stored_project} session={stored_session_id}"
714        );
715    }
716    Ok(())
717}
718
719fn commit_from_row(row: &Row<'_>) -> rusqlite::Result<GitCommitRecord> {
720    Ok(GitCommitRecord {
721        id: row.get(0)?,
722        project: row.get(1)?,
723        repo_path: row.get(2)?,
724        sha: row.get(3)?,
725        short_sha: row.get(4)?,
726        branch: row.get(5)?,
727        message: row.get(6)?,
728        authored_at_epoch: row.get(7)?,
729        changed_files: changed_files_from_row(row, 8)?,
730        created_at_epoch: row.get(9)?,
731        updated_at_epoch: row.get(10)?,
732    })
733}
734
735fn link_from_row(
736    row: &Row<'_>,
737    offset: usize,
738) -> rusqlite::Result<(CommitSessionLink, Option<i64>)> {
739    let summary = summary_from_row(row, offset + 4)?;
740    let summary_id: Option<i64> = row.get(offset + 11)?;
741    Ok((
742        CommitSessionLink {
743            session_id: row.get(offset)?,
744            memory_session_id: row.get(offset + 1)?,
745            source: row.get(offset + 2)?,
746            linked_at_epoch: row.get(offset + 3)?,
747            summary,
748        },
749        summary_id,
750    ))
751}
752
753fn summary_from_row(row: &Row<'_>, offset: usize) -> rusqlite::Result<Option<SessionSummaryTrace>> {
754    let summary = SessionSummaryTrace {
755        request: row.get(offset)?,
756        completed: row.get(offset + 1)?,
757        decisions: row.get(offset + 2)?,
758        learned: row.get(offset + 3)?,
759        next_steps: row.get(offset + 4)?,
760        preferences: row.get(offset + 5)?,
761        created_at_epoch: row.get(offset + 6)?,
762    };
763    if summary.request.is_none()
764        && summary.completed.is_none()
765        && summary.decisions.is_none()
766        && summary.learned.is_none()
767        && summary.next_steps.is_none()
768        && summary.preferences.is_none()
769        && summary.created_at_epoch.is_none()
770    {
771        Ok(None)
772    } else {
773        Ok(Some(summary))
774    }
775}
776
777fn changed_files_from_row(row: &Row<'_>, idx: usize) -> rusqlite::Result<Vec<String>> {
778    let raw: String = row.get(idx)?;
779    serde_json::from_str(&raw)
780        .map_err(|err| rusqlite::Error::FromSqlConversionFailure(idx, Type::Text, Box::new(err)))
781}
782
783#[cfg(test)]
784mod tests;