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