Skip to main content

chronicle/annotate/
squash.rs

1use std::path::Path;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::chronicle_error::{IoSnafu, JsonSnafu};
7use crate::error::Result;
8use crate::git::GitOps;
9use crate::schema::{
10    Annotation, ContextLevel, CrossCuttingConcern, Provenance, ProvenanceOperation,
11    RegionAnnotation,
12};
13use snafu::ResultExt;
14
15/// Expiry time for pending-squash.json files, in seconds.
16const PENDING_SQUASH_EXPIRY_SECS: i64 = 60;
17
18/// Written to .git/chronicle/pending-squash.json by prepare-commit-msg.
19/// Consumed and deleted by post-commit.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PendingSquash {
22    pub source_commits: Vec<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub source_ref: Option<String>,
25    pub timestamp: DateTime<Utc>,
26}
27
28/// Context for squash synthesis, assembled before calling the agent.
29#[derive(Debug, Clone)]
30pub struct SquashSynthesisContext {
31    /// The squash commit's SHA.
32    pub squash_commit: String,
33    /// The squash commit's combined diff as text.
34    pub diff: String,
35    /// Annotations from source commits (those that had annotations).
36    pub source_annotations: Vec<Annotation>,
37    /// Commit messages from source commits: (sha, message).
38    pub source_messages: Vec<(String, String)>,
39    /// The squash commit's own commit message.
40    pub squash_message: String,
41}
42
43/// Context for amend migration, assembled before calling the agent.
44#[derive(Debug, Clone)]
45pub struct AmendMigrationContext {
46    /// The new (post-amend) commit SHA.
47    pub new_commit: String,
48    /// The new commit's diff (against its parent).
49    pub new_diff: String,
50    /// The old (pre-amend) annotation.
51    pub old_annotation: Annotation,
52    /// The new commit message.
53    pub new_message: String,
54}
55
56fn pending_squash_path(git_dir: &Path) -> std::path::PathBuf {
57    git_dir.join("chronicle").join("pending-squash.json")
58}
59
60/// Write pending-squash.json to .git/chronicle/.
61pub fn write_pending_squash(git_dir: &Path, pending: &PendingSquash) -> Result<()> {
62    let path = pending_squash_path(git_dir);
63    if let Some(parent) = path.parent() {
64        std::fs::create_dir_all(parent).context(IoSnafu)?;
65    }
66    let json = serde_json::to_string_pretty(pending).context(JsonSnafu)?;
67    std::fs::write(&path, json).context(IoSnafu)?;
68    Ok(())
69}
70
71/// Read pending-squash.json. Returns None if missing, stale, or invalid.
72/// Stale or invalid files are deleted with a warning.
73pub fn read_pending_squash(git_dir: &Path) -> Result<Option<PendingSquash>> {
74    let path = pending_squash_path(git_dir);
75    if !path.exists() {
76        return Ok(None);
77    }
78
79    let content = std::fs::read_to_string(&path).context(IoSnafu)?;
80    let pending: PendingSquash = match serde_json::from_str(&content) {
81        Ok(p) => p,
82        Err(e) => {
83            tracing::warn!("Invalid pending-squash.json, deleting: {e}");
84            let _ = std::fs::remove_file(&path);
85            return Ok(None);
86        }
87    };
88
89    let age = Utc::now() - pending.timestamp;
90    if age.num_seconds() > PENDING_SQUASH_EXPIRY_SECS {
91        tracing::warn!(
92            "Stale pending-squash.json ({}s old), deleting",
93            age.num_seconds()
94        );
95        std::fs::remove_file(&path).context(IoSnafu)?;
96        return Ok(None);
97    }
98
99    Ok(Some(pending))
100}
101
102/// Delete the pending-squash.json file.
103pub fn delete_pending_squash(git_dir: &Path) -> Result<()> {
104    let path = pending_squash_path(git_dir);
105    if path.exists() {
106        std::fs::remove_file(&path).context(IoSnafu)?;
107    }
108    Ok(())
109}
110
111/// Synthesize an annotation from multiple source annotations (squash merge).
112///
113/// This merges regions, combines cross-cutting concerns, and sets provenance.
114/// For MVP, this does not call the LLM — it performs a deterministic merge.
115/// A future version will pass SquashSynthesisContext to the writing agent.
116pub fn synthesize_squash_annotation(ctx: &SquashSynthesisContext) -> Annotation {
117    let mut all_regions: Vec<RegionAnnotation> = Vec::new();
118    let mut all_cross_cutting: Vec<CrossCuttingConcern> = Vec::new();
119    let mut source_shas: Vec<String> = Vec::new();
120    let has_annotations = !ctx.source_annotations.is_empty();
121
122    for ann in &ctx.source_annotations {
123        source_shas.push(ann.commit.clone());
124
125        // Merge regions: collect all, deduplicating by (file, ast_anchor.name)
126        for region in &ann.regions {
127            let already_exists = all_regions
128                .iter()
129                .any(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name);
130            if already_exists {
131                // Find existing and append reasoning
132                if let Some(existing) = all_regions
133                    .iter_mut()
134                    .find(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name)
135                {
136                    // Merge constraints (never drop)
137                    for constraint in &region.constraints {
138                        if !existing
139                            .constraints
140                            .iter()
141                            .any(|c| c.text == constraint.text)
142                        {
143                            existing.constraints.push(constraint.clone());
144                        }
145                    }
146                    // Merge semantic dependencies
147                    for dep in &region.semantic_dependencies {
148                        if !existing
149                            .semantic_dependencies
150                            .iter()
151                            .any(|d| d.file == dep.file && d.anchor == dep.anchor)
152                        {
153                            existing.semantic_dependencies.push(dep.clone());
154                        }
155                    }
156                    // Consolidate reasoning
157                    if let Some(new_reasoning) = &region.reasoning {
158                        if let Some(ref mut existing_reasoning) = existing.reasoning {
159                            existing_reasoning.push_str("\n\n");
160                            existing_reasoning.push_str(new_reasoning);
161                        } else {
162                            existing.reasoning = Some(new_reasoning.clone());
163                        }
164                    }
165                    // Update line range to encompass both
166                    existing.lines.start = existing.lines.start.min(region.lines.start);
167                    existing.lines.end = existing.lines.end.max(region.lines.end);
168                }
169            } else {
170                all_regions.push(region.clone());
171            }
172        }
173
174        // Merge cross-cutting concerns (deduplicate by description)
175        for cc in &ann.cross_cutting {
176            if !all_cross_cutting
177                .iter()
178                .any(|c| c.description == cc.description)
179            {
180                all_cross_cutting.push(cc.clone());
181            }
182        }
183    }
184
185    // Collect source SHAs from source_messages for any that didn't have annotations
186    for (sha, _) in &ctx.source_messages {
187        if !source_shas.contains(sha) {
188            source_shas.push(sha.clone());
189        }
190    }
191
192    let annotations_count = ctx.source_annotations.len();
193    let total_sources = ctx.source_messages.len();
194    let all_had_annotations = annotations_count == total_sources && total_sources > 0;
195
196    let synthesis_notes = if has_annotations {
197        Some(format!(
198            "Synthesized from {} commits ({} of {} had annotations).",
199            total_sources, annotations_count, total_sources,
200        ))
201    } else {
202        Some(format!(
203            "Synthesized from {} commits (none had annotations).",
204            total_sources,
205        ))
206    };
207
208    Annotation {
209        schema: "chronicle/v1".to_string(),
210        commit: ctx.squash_commit.clone(),
211        timestamp: Utc::now().to_rfc3339(),
212        task: None,
213        summary: ctx.squash_message.clone(),
214        context_level: if has_annotations {
215            ContextLevel::Enhanced
216        } else {
217            ContextLevel::Inferred
218        },
219        regions: all_regions,
220        cross_cutting: all_cross_cutting,
221        provenance: Provenance {
222            operation: ProvenanceOperation::Squash,
223            derived_from: source_shas,
224            original_annotations_preserved: all_had_annotations,
225            synthesis_notes,
226        },
227    }
228}
229
230/// Migrate an annotation from a pre-amend commit to a post-amend commit.
231///
232/// If the diff is empty (message-only amend), copies the annotation unchanged
233/// except for updating the commit SHA and provenance.
234pub fn migrate_amend_annotation(ctx: &AmendMigrationContext) -> Annotation {
235    let mut new_annotation = ctx.old_annotation.clone();
236    new_annotation.commit = ctx.new_commit.clone();
237    new_annotation.timestamp = Utc::now().to_rfc3339();
238
239    let is_message_only = ctx.new_diff.trim().is_empty();
240
241    new_annotation.provenance = Provenance {
242        operation: ProvenanceOperation::Amend,
243        derived_from: vec![ctx.old_annotation.commit.clone()],
244        original_annotations_preserved: true,
245        synthesis_notes: if is_message_only {
246            Some("Message-only amend; annotation unchanged.".to_string())
247        } else {
248            Some("Migrated from amend. Regions preserved from original annotation.".to_string())
249        },
250    };
251
252    // For message-only amends, update the summary to match new message
253    if is_message_only {
254        new_annotation.summary = ctx.new_message.clone();
255    }
256
257    new_annotation
258}
259
260/// Collect annotations from source commits using git notes.
261pub fn collect_source_annotations(
262    git_ops: &dyn GitOps,
263    source_shas: &[String],
264) -> Vec<(String, Option<Annotation>)> {
265    source_shas
266        .iter()
267        .map(|sha| {
268            let annotation = git_ops
269                .note_read(sha)
270                .ok()
271                .flatten()
272                .and_then(|json| serde_json::from_str::<Annotation>(&json).ok());
273            (sha.clone(), annotation)
274        })
275        .collect()
276}
277
278/// Collect commit messages from source commits.
279pub fn collect_source_messages(
280    git_ops: &dyn GitOps,
281    source_shas: &[String],
282) -> Vec<(String, String)> {
283    source_shas
284        .iter()
285        .filter_map(|sha| {
286            git_ops
287                .commit_info(sha)
288                .ok()
289                .map(|info| (sha.clone(), info.message))
290        })
291        .collect()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::schema::{
298        AstAnchor, Constraint, ConstraintSource, CrossCuttingConcern, CrossCuttingRegionRef,
299        LineRange, SemanticDependency,
300    };
301
302    fn make_test_annotation(commit: &str, file: &str, anchor: &str) -> Annotation {
303        Annotation {
304            schema: "chronicle/v1".to_string(),
305            commit: commit.to_string(),
306            timestamp: Utc::now().to_rfc3339(),
307            task: None,
308            summary: format!("Commit {commit}"),
309            context_level: ContextLevel::Inferred,
310            regions: vec![RegionAnnotation {
311                file: file.to_string(),
312                ast_anchor: AstAnchor {
313                    unit_type: "function".to_string(),
314                    name: anchor.to_string(),
315                    signature: None,
316                },
317                lines: LineRange { start: 1, end: 10 },
318                intent: format!("Modified {anchor}"),
319                reasoning: Some(format!("Reasoning for {anchor} in {commit}")),
320                constraints: vec![Constraint {
321                    text: format!("Constraint from {commit}"),
322                    source: ConstraintSource::Inferred,
323                }],
324                semantic_dependencies: vec![SemanticDependency {
325                    file: "other.rs".to_string(),
326                    anchor: "helper".to_string(),
327                    nature: "calls".to_string(),
328                }],
329                related_annotations: Vec::new(),
330                tags: Vec::new(),
331                risk_notes: None,
332                corrections: vec![],
333            }],
334            cross_cutting: vec![CrossCuttingConcern {
335                description: format!("Cross-cutting from {commit}"),
336                regions: vec![CrossCuttingRegionRef {
337                    file: file.to_string(),
338                    anchor: anchor.to_string(),
339                }],
340                tags: Vec::new(),
341            }],
342            provenance: Provenance {
343                operation: ProvenanceOperation::Initial,
344                derived_from: Vec::new(),
345                original_annotations_preserved: false,
346                synthesis_notes: None,
347            },
348        }
349    }
350
351    #[test]
352    fn test_pending_squash_roundtrip() {
353        let dir = tempfile::tempdir().unwrap();
354        let git_dir = dir.path();
355        std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
356
357        let pending = PendingSquash {
358            source_commits: vec!["abc123".to_string(), "def456".to_string()],
359            source_ref: Some("feature-branch".to_string()),
360            timestamp: Utc::now(),
361        };
362
363        write_pending_squash(git_dir, &pending).unwrap();
364        let read_back = read_pending_squash(git_dir).unwrap().unwrap();
365
366        assert_eq!(read_back.source_commits, pending.source_commits);
367        assert_eq!(read_back.source_ref, pending.source_ref);
368    }
369
370    #[test]
371    fn test_pending_squash_missing_file() {
372        let dir = tempfile::tempdir().unwrap();
373        let result = read_pending_squash(dir.path()).unwrap();
374        assert!(result.is_none());
375    }
376
377    #[test]
378    fn test_pending_squash_stale_file() {
379        let dir = tempfile::tempdir().unwrap();
380        let git_dir = dir.path();
381        std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
382
383        let pending = PendingSquash {
384            source_commits: vec!["abc123".to_string()],
385            source_ref: None,
386            timestamp: Utc::now() - chrono::Duration::seconds(120),
387        };
388
389        write_pending_squash(git_dir, &pending).unwrap();
390        let result = read_pending_squash(git_dir).unwrap();
391        assert!(result.is_none());
392        // File should have been deleted
393        assert!(!pending_squash_path(git_dir).exists());
394    }
395
396    #[test]
397    fn test_pending_squash_invalid_json() {
398        let dir = tempfile::tempdir().unwrap();
399        let git_dir = dir.path();
400        let chronicle_dir = git_dir.join("chronicle");
401        std::fs::create_dir_all(&chronicle_dir).unwrap();
402        std::fs::write(chronicle_dir.join("pending-squash.json"), "not json").unwrap();
403
404        let result = read_pending_squash(git_dir).unwrap();
405        assert!(result.is_none());
406        // File should have been deleted
407        assert!(!pending_squash_path(git_dir).exists());
408    }
409
410    #[test]
411    fn test_delete_pending_squash() {
412        let dir = tempfile::tempdir().unwrap();
413        let git_dir = dir.path();
414
415        let pending = PendingSquash {
416            source_commits: vec!["abc123".to_string()],
417            source_ref: None,
418            timestamp: Utc::now(),
419        };
420
421        write_pending_squash(git_dir, &pending).unwrap();
422        assert!(pending_squash_path(git_dir).exists());
423
424        delete_pending_squash(git_dir).unwrap();
425        assert!(!pending_squash_path(git_dir).exists());
426    }
427
428    #[test]
429    fn test_delete_pending_squash_missing_file() {
430        let dir = tempfile::tempdir().unwrap();
431        // Should not error when file doesn't exist
432        delete_pending_squash(dir.path()).unwrap();
433    }
434
435    #[test]
436    fn test_synthesize_squash_distinct_regions() {
437        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
438        let ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
439        let ann3 = make_test_annotation("ghi789", "src/baz.rs", "baz_fn");
440
441        let ctx = SquashSynthesisContext {
442            squash_commit: "squash001".to_string(),
443            diff: "some diff".to_string(),
444            source_annotations: vec![ann1, ann2, ann3],
445            source_messages: vec![
446                ("abc123".to_string(), "Commit abc".to_string()),
447                ("def456".to_string(), "Commit def".to_string()),
448                ("ghi789".to_string(), "Commit ghi".to_string()),
449            ],
450            squash_message: "Squash merge".to_string(),
451        };
452
453        let result = synthesize_squash_annotation(&ctx);
454
455        assert_eq!(result.commit, "squash001");
456        assert_eq!(result.regions.len(), 3);
457        assert_eq!(result.cross_cutting.len(), 3);
458        assert_eq!(result.provenance.operation, ProvenanceOperation::Squash);
459        assert_eq!(result.provenance.derived_from.len(), 3);
460        assert!(result.provenance.original_annotations_preserved);
461    }
462
463    #[test]
464    fn test_synthesize_squash_overlapping_regions() {
465        let ann1 = make_test_annotation("abc123", "src/foo.rs", "connect");
466        let mut ann2 = make_test_annotation("def456", "src/foo.rs", "connect");
467        // Give ann2 a different constraint
468        ann2.regions[0].constraints[0].text = "Constraint from def456".to_string();
469        ann2.regions[0].lines = LineRange { start: 5, end: 20 };
470
471        let ctx = SquashSynthesisContext {
472            squash_commit: "squash001".to_string(),
473            diff: "some diff".to_string(),
474            source_annotations: vec![ann1, ann2],
475            source_messages: vec![
476                ("abc123".to_string(), "First".to_string()),
477                ("def456".to_string(), "Second".to_string()),
478            ],
479            squash_message: "Squash merge".to_string(),
480        };
481
482        let result = synthesize_squash_annotation(&ctx);
483
484        // Should have merged into 1 region
485        assert_eq!(result.regions.len(), 1);
486        // Constraints from both should be preserved
487        assert_eq!(result.regions[0].constraints.len(), 2);
488        // Line range should encompass both
489        assert_eq!(result.regions[0].lines.start, 1);
490        assert_eq!(result.regions[0].lines.end, 20);
491        // Reasoning should be consolidated
492        assert!(result.regions[0]
493            .reasoning
494            .as_ref()
495            .unwrap()
496            .contains("abc123"));
497        assert!(result.regions[0]
498            .reasoning
499            .as_ref()
500            .unwrap()
501            .contains("def456"));
502    }
503
504    #[test]
505    fn test_synthesize_squash_partial_annotations() {
506        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
507
508        let ctx = SquashSynthesisContext {
509            squash_commit: "squash001".to_string(),
510            diff: "some diff".to_string(),
511            source_annotations: vec![ann1],
512            source_messages: vec![
513                ("abc123".to_string(), "First".to_string()),
514                ("def456".to_string(), "Second".to_string()),
515                ("ghi789".to_string(), "Third".to_string()),
516            ],
517            squash_message: "Squash merge".to_string(),
518        };
519
520        let result = synthesize_squash_annotation(&ctx);
521
522        assert!(!result.provenance.original_annotations_preserved);
523        assert!(result
524            .provenance
525            .synthesis_notes
526            .as_ref()
527            .unwrap()
528            .contains("1 of 3"));
529    }
530
531    #[test]
532    fn test_synthesize_squash_no_annotations() {
533        let ctx = SquashSynthesisContext {
534            squash_commit: "squash001".to_string(),
535            diff: "some diff".to_string(),
536            source_annotations: vec![],
537            source_messages: vec![
538                ("abc123".to_string(), "First".to_string()),
539                ("def456".to_string(), "Second".to_string()),
540            ],
541            squash_message: "Squash merge".to_string(),
542        };
543
544        let result = synthesize_squash_annotation(&ctx);
545
546        assert_eq!(result.context_level, ContextLevel::Inferred);
547        assert!(result.regions.is_empty());
548        assert!(!result.provenance.original_annotations_preserved);
549    }
550
551    #[test]
552    fn test_synthesize_preserves_cross_cutting() {
553        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
554        let mut ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
555        // Add a second cross-cutting concern to ann2
556        ann2.cross_cutting.push(CrossCuttingConcern {
557            description: "Another concern".to_string(),
558            regions: vec![CrossCuttingRegionRef {
559                file: "src/bar.rs".to_string(),
560                anchor: "bar_fn".to_string(),
561            }],
562            tags: Vec::new(),
563        });
564
565        let ctx = SquashSynthesisContext {
566            squash_commit: "squash001".to_string(),
567            diff: "some diff".to_string(),
568            source_annotations: vec![ann1, ann2],
569            source_messages: vec![
570                ("abc123".to_string(), "First".to_string()),
571                ("def456".to_string(), "Second".to_string()),
572            ],
573            squash_message: "Squash merge".to_string(),
574        };
575
576        let result = synthesize_squash_annotation(&ctx);
577        // 1 from ann1, 2 from ann2 = 3 unique cross-cutting concerns
578        assert_eq!(result.cross_cutting.len(), 3);
579    }
580
581    #[test]
582    fn test_migrate_amend_message_only() {
583        let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
584
585        let ctx = AmendMigrationContext {
586            new_commit: "new_sha".to_string(),
587            new_diff: "".to_string(), // empty = message-only
588            old_annotation: old_ann,
589            new_message: "Updated commit message".to_string(),
590        };
591
592        let result = migrate_amend_annotation(&ctx);
593
594        assert_eq!(result.commit, "new_sha");
595        assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
596        assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
597        assert!(result.provenance.original_annotations_preserved);
598        assert!(result
599            .provenance
600            .synthesis_notes
601            .as_ref()
602            .unwrap()
603            .contains("Message-only"));
604        assert_eq!(result.summary, "Updated commit message");
605        // Regions should be preserved
606        assert_eq!(result.regions.len(), 1);
607    }
608
609    #[test]
610    fn test_migrate_amend_with_code_changes() {
611        let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
612
613        let ctx = AmendMigrationContext {
614            new_commit: "new_sha".to_string(),
615            new_diff: "+some new code\n-some old code\n".to_string(),
616            old_annotation: old_ann,
617            new_message: "Updated commit".to_string(),
618        };
619
620        let result = migrate_amend_annotation(&ctx);
621
622        assert_eq!(result.commit, "new_sha");
623        assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
624        assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
625        assert!(result
626            .provenance
627            .synthesis_notes
628            .as_ref()
629            .unwrap()
630            .contains("Migrated from amend"));
631        // Regions preserved from original (MVP doesn't re-analyze)
632        assert_eq!(result.regions.len(), 1);
633    }
634}