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::v1::{
10    self, ContextLevel, CrossCuttingConcern, Provenance, ProvenanceOperation, RegionAnnotation,
11};
12type Annotation = v1::Annotation;
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::common::{AstAnchor, LineRange};
298    use crate::schema::v1::{
299        Constraint, ConstraintSource, CrossCuttingConcern, CrossCuttingRegionRef,
300        SemanticDependency,
301    };
302
303    fn make_test_annotation(commit: &str, file: &str, anchor: &str) -> Annotation {
304        Annotation {
305            schema: "chronicle/v1".to_string(),
306            commit: commit.to_string(),
307            timestamp: Utc::now().to_rfc3339(),
308            task: None,
309            summary: format!("Commit {commit}"),
310            context_level: ContextLevel::Inferred,
311            regions: vec![RegionAnnotation {
312                file: file.to_string(),
313                ast_anchor: AstAnchor {
314                    unit_type: "function".to_string(),
315                    name: anchor.to_string(),
316                    signature: None,
317                },
318                lines: LineRange { start: 1, end: 10 },
319                intent: format!("Modified {anchor}"),
320                reasoning: Some(format!("Reasoning for {anchor} in {commit}")),
321                constraints: vec![Constraint {
322                    text: format!("Constraint from {commit}"),
323                    source: ConstraintSource::Inferred,
324                }],
325                semantic_dependencies: vec![SemanticDependency {
326                    file: "other.rs".to_string(),
327                    anchor: "helper".to_string(),
328                    nature: "calls".to_string(),
329                }],
330                related_annotations: Vec::new(),
331                tags: Vec::new(),
332                risk_notes: None,
333                corrections: vec![],
334            }],
335            cross_cutting: vec![CrossCuttingConcern {
336                description: format!("Cross-cutting from {commit}"),
337                regions: vec![CrossCuttingRegionRef {
338                    file: file.to_string(),
339                    anchor: anchor.to_string(),
340                }],
341                tags: Vec::new(),
342            }],
343            provenance: Provenance {
344                operation: ProvenanceOperation::Initial,
345                derived_from: Vec::new(),
346                original_annotations_preserved: false,
347                synthesis_notes: None,
348            },
349        }
350    }
351
352    #[test]
353    fn test_pending_squash_roundtrip() {
354        let dir = tempfile::tempdir().unwrap();
355        let git_dir = dir.path();
356        std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
357
358        let pending = PendingSquash {
359            source_commits: vec!["abc123".to_string(), "def456".to_string()],
360            source_ref: Some("feature-branch".to_string()),
361            timestamp: Utc::now(),
362        };
363
364        write_pending_squash(git_dir, &pending).unwrap();
365        let read_back = read_pending_squash(git_dir).unwrap().unwrap();
366
367        assert_eq!(read_back.source_commits, pending.source_commits);
368        assert_eq!(read_back.source_ref, pending.source_ref);
369    }
370
371    #[test]
372    fn test_pending_squash_missing_file() {
373        let dir = tempfile::tempdir().unwrap();
374        let result = read_pending_squash(dir.path()).unwrap();
375        assert!(result.is_none());
376    }
377
378    #[test]
379    fn test_pending_squash_stale_file() {
380        let dir = tempfile::tempdir().unwrap();
381        let git_dir = dir.path();
382        std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
383
384        let pending = PendingSquash {
385            source_commits: vec!["abc123".to_string()],
386            source_ref: None,
387            timestamp: Utc::now() - chrono::Duration::seconds(120),
388        };
389
390        write_pending_squash(git_dir, &pending).unwrap();
391        let result = read_pending_squash(git_dir).unwrap();
392        assert!(result.is_none());
393        // File should have been deleted
394        assert!(!pending_squash_path(git_dir).exists());
395    }
396
397    #[test]
398    fn test_pending_squash_invalid_json() {
399        let dir = tempfile::tempdir().unwrap();
400        let git_dir = dir.path();
401        let chronicle_dir = git_dir.join("chronicle");
402        std::fs::create_dir_all(&chronicle_dir).unwrap();
403        std::fs::write(chronicle_dir.join("pending-squash.json"), "not json").unwrap();
404
405        let result = read_pending_squash(git_dir).unwrap();
406        assert!(result.is_none());
407        // File should have been deleted
408        assert!(!pending_squash_path(git_dir).exists());
409    }
410
411    #[test]
412    fn test_delete_pending_squash() {
413        let dir = tempfile::tempdir().unwrap();
414        let git_dir = dir.path();
415
416        let pending = PendingSquash {
417            source_commits: vec!["abc123".to_string()],
418            source_ref: None,
419            timestamp: Utc::now(),
420        };
421
422        write_pending_squash(git_dir, &pending).unwrap();
423        assert!(pending_squash_path(git_dir).exists());
424
425        delete_pending_squash(git_dir).unwrap();
426        assert!(!pending_squash_path(git_dir).exists());
427    }
428
429    #[test]
430    fn test_delete_pending_squash_missing_file() {
431        let dir = tempfile::tempdir().unwrap();
432        // Should not error when file doesn't exist
433        delete_pending_squash(dir.path()).unwrap();
434    }
435
436    #[test]
437    fn test_synthesize_squash_distinct_regions() {
438        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
439        let ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
440        let ann3 = make_test_annotation("ghi789", "src/baz.rs", "baz_fn");
441
442        let ctx = SquashSynthesisContext {
443            squash_commit: "squash001".to_string(),
444            diff: "some diff".to_string(),
445            source_annotations: vec![ann1, ann2, ann3],
446            source_messages: vec![
447                ("abc123".to_string(), "Commit abc".to_string()),
448                ("def456".to_string(), "Commit def".to_string()),
449                ("ghi789".to_string(), "Commit ghi".to_string()),
450            ],
451            squash_message: "Squash merge".to_string(),
452        };
453
454        let result = synthesize_squash_annotation(&ctx);
455
456        assert_eq!(result.commit, "squash001");
457        assert_eq!(result.regions.len(), 3);
458        assert_eq!(result.cross_cutting.len(), 3);
459        assert_eq!(result.provenance.operation, ProvenanceOperation::Squash);
460        assert_eq!(result.provenance.derived_from.len(), 3);
461        assert!(result.provenance.original_annotations_preserved);
462    }
463
464    #[test]
465    fn test_synthesize_squash_overlapping_regions() {
466        let ann1 = make_test_annotation("abc123", "src/foo.rs", "connect");
467        let mut ann2 = make_test_annotation("def456", "src/foo.rs", "connect");
468        // Give ann2 a different constraint
469        ann2.regions[0].constraints[0].text = "Constraint from def456".to_string();
470        ann2.regions[0].lines = LineRange { start: 5, end: 20 };
471
472        let ctx = SquashSynthesisContext {
473            squash_commit: "squash001".to_string(),
474            diff: "some diff".to_string(),
475            source_annotations: vec![ann1, ann2],
476            source_messages: vec![
477                ("abc123".to_string(), "First".to_string()),
478                ("def456".to_string(), "Second".to_string()),
479            ],
480            squash_message: "Squash merge".to_string(),
481        };
482
483        let result = synthesize_squash_annotation(&ctx);
484
485        // Should have merged into 1 region
486        assert_eq!(result.regions.len(), 1);
487        // Constraints from both should be preserved
488        assert_eq!(result.regions[0].constraints.len(), 2);
489        // Line range should encompass both
490        assert_eq!(result.regions[0].lines.start, 1);
491        assert_eq!(result.regions[0].lines.end, 20);
492        // Reasoning should be consolidated
493        assert!(result.regions[0]
494            .reasoning
495            .as_ref()
496            .unwrap()
497            .contains("abc123"));
498        assert!(result.regions[0]
499            .reasoning
500            .as_ref()
501            .unwrap()
502            .contains("def456"));
503    }
504
505    #[test]
506    fn test_synthesize_squash_partial_annotations() {
507        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
508
509        let ctx = SquashSynthesisContext {
510            squash_commit: "squash001".to_string(),
511            diff: "some diff".to_string(),
512            source_annotations: vec![ann1],
513            source_messages: vec![
514                ("abc123".to_string(), "First".to_string()),
515                ("def456".to_string(), "Second".to_string()),
516                ("ghi789".to_string(), "Third".to_string()),
517            ],
518            squash_message: "Squash merge".to_string(),
519        };
520
521        let result = synthesize_squash_annotation(&ctx);
522
523        assert!(!result.provenance.original_annotations_preserved);
524        assert!(result
525            .provenance
526            .synthesis_notes
527            .as_ref()
528            .unwrap()
529            .contains("1 of 3"));
530    }
531
532    #[test]
533    fn test_synthesize_squash_no_annotations() {
534        let ctx = SquashSynthesisContext {
535            squash_commit: "squash001".to_string(),
536            diff: "some diff".to_string(),
537            source_annotations: vec![],
538            source_messages: vec![
539                ("abc123".to_string(), "First".to_string()),
540                ("def456".to_string(), "Second".to_string()),
541            ],
542            squash_message: "Squash merge".to_string(),
543        };
544
545        let result = synthesize_squash_annotation(&ctx);
546
547        assert_eq!(result.context_level, ContextLevel::Inferred);
548        assert!(result.regions.is_empty());
549        assert!(!result.provenance.original_annotations_preserved);
550    }
551
552    #[test]
553    fn test_synthesize_preserves_cross_cutting() {
554        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
555        let mut ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
556        // Add a second cross-cutting concern to ann2
557        ann2.cross_cutting.push(CrossCuttingConcern {
558            description: "Another concern".to_string(),
559            regions: vec![CrossCuttingRegionRef {
560                file: "src/bar.rs".to_string(),
561                anchor: "bar_fn".to_string(),
562            }],
563            tags: Vec::new(),
564        });
565
566        let ctx = SquashSynthesisContext {
567            squash_commit: "squash001".to_string(),
568            diff: "some diff".to_string(),
569            source_annotations: vec![ann1, ann2],
570            source_messages: vec![
571                ("abc123".to_string(), "First".to_string()),
572                ("def456".to_string(), "Second".to_string()),
573            ],
574            squash_message: "Squash merge".to_string(),
575        };
576
577        let result = synthesize_squash_annotation(&ctx);
578        // 1 from ann1, 2 from ann2 = 3 unique cross-cutting concerns
579        assert_eq!(result.cross_cutting.len(), 3);
580    }
581
582    #[test]
583    fn test_migrate_amend_message_only() {
584        let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
585
586        let ctx = AmendMigrationContext {
587            new_commit: "new_sha".to_string(),
588            new_diff: "".to_string(), // empty = message-only
589            old_annotation: old_ann,
590            new_message: "Updated commit message".to_string(),
591        };
592
593        let result = migrate_amend_annotation(&ctx);
594
595        assert_eq!(result.commit, "new_sha");
596        assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
597        assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
598        assert!(result.provenance.original_annotations_preserved);
599        assert!(result
600            .provenance
601            .synthesis_notes
602            .as_ref()
603            .unwrap()
604            .contains("Message-only"));
605        assert_eq!(result.summary, "Updated commit message");
606        // Regions should be preserved
607        assert_eq!(result.regions.len(), 1);
608    }
609
610    #[test]
611    fn test_migrate_amend_with_code_changes() {
612        let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
613
614        let ctx = AmendMigrationContext {
615            new_commit: "new_sha".to_string(),
616            new_diff: "+some new code\n-some old code\n".to_string(),
617            old_annotation: old_ann,
618            new_message: "Updated commit".to_string(),
619        };
620
621        let result = migrate_amend_annotation(&ctx);
622
623        assert_eq!(result.commit, "new_sha");
624        assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
625        assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
626        assert!(result
627            .provenance
628            .synthesis_notes
629            .as_ref()
630            .unwrap()
631            .contains("Migrated from amend"));
632        // Regions preserved from original (MVP doesn't re-analyze)
633        assert_eq!(result.regions.len(), 1);
634    }
635}