chronicle/cli/
annotate.rs1use crate::annotate::squash::{
2 collect_source_annotations, collect_source_messages, migrate_amend_annotation,
3 synthesize_squash_annotation, AmendMigrationContext, SquashSynthesisContext,
4};
5use crate::error::chronicle_error::{GitSnafu, JsonSnafu};
6use crate::error::Result;
7use crate::git::{CliOps, GitOps};
8use snafu::ResultExt;
9
10pub struct AnnotateArgs {
11 pub commit: String,
12 pub live: bool,
13 pub squash_sources: Option<String>,
14 pub amend_source: Option<String>,
15 pub summary: Option<String>,
16 pub json_input: Option<String>,
17 pub auto: bool,
18}
19
20pub fn run(args: AnnotateArgs) -> Result<()> {
21 let AnnotateArgs {
22 commit,
23 live,
24 squash_sources,
25 amend_source,
26 summary,
27 json_input,
28 auto,
29 } = args;
30 let repo_dir = std::env::current_dir().map_err(|e| crate::error::ChronicleError::Io {
31 source: e,
32 location: snafu::Location::default(),
33 })?;
34 let git_ops = CliOps::new(repo_dir.clone());
35
36 let git_dir = repo_dir.join(".git");
38 let staged_notes_text = crate::annotate::staging::read_staged(&git_dir)
39 .ok()
40 .filter(|notes| !notes.is_empty())
41 .map(|notes| crate::annotate::staging::format_for_provenance(¬es));
42
43 if let Some(summary_text) = summary {
45 let input = crate::annotate::live::LiveInput {
46 commit,
47 summary: summary_text,
48 wisdom: vec![],
49 staged_notes: staged_notes_text.clone(),
50 };
51 let result = crate::annotate::live::handle_annotate_v3(&git_ops, input)?;
52 let _ = crate::annotate::staging::clear_staged(&git_dir);
53 let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
54 println!("{json}");
55 return Ok(());
56 }
57
58 if let Some(json_str) = json_input {
60 let mut input: crate::annotate::live::LiveInput =
61 serde_json::from_str(&json_str).context(JsonSnafu)?;
62 input.staged_notes = staged_notes_text.clone();
63 let result = crate::annotate::live::handle_annotate_v3(&git_ops, input)?;
64 let _ = crate::annotate::staging::clear_staged(&git_dir);
65 let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
66 println!("{json}");
67 return Ok(());
68 }
69
70 if auto {
72 let full_sha = git_ops.resolve_ref(&commit).context(GitSnafu)?;
73 let commit_info = git_ops.commit_info(&full_sha).context(GitSnafu)?;
74 let input = crate::annotate::live::LiveInput {
75 commit,
76 summary: commit_info.message,
77 wisdom: vec![],
78 staged_notes: staged_notes_text.clone(),
79 };
80 let result = crate::annotate::live::handle_annotate_v3(&git_ops, input)?;
81 let _ = crate::annotate::staging::clear_staged(&git_dir);
82 let json = serde_json::to_string_pretty(&result).context(JsonSnafu)?;
83 println!("{json}");
84 return Ok(());
85 }
86
87 if live {
88 return run_live(&git_ops);
89 }
90
91 if let Some(sources) = squash_sources {
93 return run_squash_synthesis(&git_ops, &commit, &sources);
94 }
95
96 if let Some(old_sha) = amend_source {
98 return run_amend_migration(&git_ops, &commit, &old_sha);
99 }
100
101 let provider = crate::provider::discover_provider().map_err(|e| {
102 crate::error::ChronicleError::Provider {
103 source: e,
104 location: snafu::Location::default(),
105 }
106 })?;
107
108 let annotation = crate::annotate::run(&git_ops, provider.as_ref(), &commit)?;
109
110 let json = serde_json::to_string_pretty(&annotation).map_err(|e| {
111 crate::error::ChronicleError::Json {
112 source: e,
113 location: snafu::Location::default(),
114 }
115 })?;
116 println!("{json}");
117
118 Ok(())
119}
120
121fn run_squash_synthesis(git_ops: &CliOps, commit: &str, sources: &str) -> Result<()> {
123 let source_shas: Vec<String> = sources
124 .split(',')
125 .map(|s| s.trim().to_string())
126 .filter(|s| !s.is_empty())
127 .collect();
128
129 if source_shas.is_empty() {
130 return Err(crate::error::ChronicleError::Validation {
131 message: "--squash-sources requires at least one source SHA".to_string(),
132 location: snafu::Location::default(),
133 });
134 }
135
136 let resolved_commit = git_ops.resolve_ref(commit).context(GitSnafu)?;
138
139 let source_ann_pairs = collect_source_annotations(git_ops, &source_shas);
141 let source_annotations: Vec<_> = source_ann_pairs
142 .into_iter()
143 .filter_map(|(_, ann)| ann)
144 .collect();
145 let source_messages = collect_source_messages(git_ops, &source_shas);
146
147 let commit_info = git_ops.commit_info(&resolved_commit).context(GitSnafu)?;
149
150 let ctx = SquashSynthesisContext {
151 squash_commit: resolved_commit.clone(),
152 diff: String::new(), source_annotations,
154 source_messages,
155 squash_message: commit_info.message,
156 };
157
158 let annotation = synthesize_squash_annotation(&ctx);
159
160 let json = serde_json::to_string_pretty(&annotation).context(JsonSnafu)?;
162 git_ops
163 .note_write(&resolved_commit, &json)
164 .context(GitSnafu)?;
165
166 println!("{json}");
167 Ok(())
168}
169
170fn run_amend_migration(git_ops: &CliOps, commit: &str, old_sha: &str) -> Result<()> {
172 let resolved_commit = git_ops.resolve_ref(commit).context(GitSnafu)?;
173
174 let old_note = git_ops.note_read(old_sha).context(GitSnafu)?;
176 let old_json = match old_note {
177 Some(json) => json,
178 None => {
179 return Err(crate::error::ChronicleError::Validation {
180 message: format!("No annotation found for old commit {old_sha}"),
181 location: snafu::Location::default(),
182 });
183 }
184 };
185
186 let old_annotation: crate::schema::v1::Annotation =
187 serde_json::from_str(&old_json).context(JsonSnafu)?;
188
189 let new_info = git_ops.commit_info(&resolved_commit).context(GitSnafu)?;
190
191 let new_diffs = git_ops.diff(&resolved_commit).context(GitSnafu)?;
193 let old_diffs = git_ops.diff(old_sha).context(GitSnafu)?;
194 let new_diff_text = format!("{:?}", new_diffs);
195 let old_diff_text = format!("{:?}", old_diffs);
196 let diff_for_migration = if new_diff_text == old_diff_text {
197 String::new()
198 } else {
199 new_diff_text
200 };
201
202 let ctx = AmendMigrationContext {
203 new_commit: resolved_commit.clone(),
204 new_diff: diff_for_migration,
205 old_annotation,
206 new_message: new_info.message,
207 };
208
209 let annotation = migrate_amend_annotation(&ctx);
210
211 let json = serde_json::to_string_pretty(&annotation).context(JsonSnafu)?;
212 git_ops
213 .note_write(&resolved_commit, &json)
214 .context(GitSnafu)?;
215
216 println!("{json}");
217 Ok(())
218}
219
220fn run_live(git_ops: &CliOps) -> Result<()> {
222 let stdin = std::io::read_to_string(std::io::stdin()).map_err(|e| {
223 crate::error::ChronicleError::Io {
224 source: e,
225 location: snafu::Location::default(),
226 }
227 })?;
228
229 let value: serde_json::Value =
230 serde_json::from_str(&stdin).map_err(|e| crate::error::ChronicleError::Json {
231 source: e,
232 location: snafu::Location::default(),
233 })?;
234
235 if value.get("regions").is_some() {
236 return Err(crate::error::ChronicleError::Validation {
237 message: "v1 annotation format is no longer supported for writing; use v3 format"
238 .to_string(),
239 location: snafu::Location::default(),
240 });
241 }
242
243 let input: crate::annotate::live::LiveInput =
244 serde_json::from_value(value).map_err(|e| crate::error::ChronicleError::Json {
245 source: e,
246 location: snafu::Location::default(),
247 })?;
248
249 let result = crate::annotate::live::handle_annotate_v3(git_ops, input)?;
250 let json =
251 serde_json::to_string_pretty(&result).map_err(|e| crate::error::ChronicleError::Json {
252 source: e,
253 location: snafu::Location::default(),
254 })?;
255 println!("{json}");
256
257 Ok(())
258}