1use std::time::SystemTime;
11
12use anyhow::{Context, Result, anyhow};
13use heddle_git_projection::{git_core::LocalGitIdentity, git_export};
14use objects::{
15 HeddleError, RecoveryDetails,
16 object::{Attribution, StateId},
17 store::ObjectStore,
18};
19use repo::Repository;
20use serde::Serialize;
21use sley::{
22 CommitObject, GitObjectType, IndexWriteOptions, ObjectId as GitObjectId, RefPrecondition,
23 ReferenceTarget, Repository as SleyRepository, plumbing::sley_object::EncodedObject,
24};
25
26#[derive(Clone, Debug, Serialize)]
29pub struct GitCommitPreview {
30 pub message: String,
31 pub files: Vec<String>,
32}
33
34#[derive(Clone, Debug, Serialize)]
36pub struct GitCommitInfo {
37 pub sha: String,
38 pub message: String,
39}
40
41#[derive(Debug)]
45pub struct GitCommitBlocked {
46 pub blockers: Vec<String>,
47}
48
49impl std::fmt::Display for GitCommitBlocked {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(f, "git commit blocked: {}", self.blockers.join("; "))
52 }
53}
54
55impl std::error::Error for GitCommitBlocked {}
56
57pub fn validate_git_state(
67 repo: &Repository,
68 expected_paths: &[String],
69) -> std::result::Result<(), GitCommitBlocked> {
70 let mut blockers = Vec::new();
71 let repo_root = repo.root();
72
73 if !repo_root.join(".git").exists() {
74 blockers.push(format!(
75 "no git repository at {} (--git-commit requires a git overlay)",
76 repo_root.display()
77 ));
78 return Err(GitCommitBlocked { blockers });
79 }
80
81 let git = match SleyRepository::discover(repo_root) {
84 Ok(git) => git,
85 Err(err) => {
86 blockers.push(format!("failed to inspect git repository: {err}"));
87 return Err(GitCommitBlocked { blockers });
88 }
89 };
90 let attached_branch = git
91 .head()
92 .ok()
93 .and_then(|head| head.branch_name().map(str::to_string))
94 .filter(|branch| !branch.is_empty());
95 if attached_branch.is_none() {
96 blockers.push("git HEAD is detached (--git-commit requires an attached branch)".into());
97 }
98
99 let expected: std::collections::HashSet<&str> =
100 expected_paths.iter().map(|p| p.as_str()).collect();
101 let unrelated = match collect_unrelated_git_paths(repo_root, &expected) {
102 Ok(paths) => paths,
103 Err(err) => {
104 blockers.push(format!("failed to inspect git worktree status: {err}"));
105 return Err(GitCommitBlocked { blockers });
106 }
107 };
108
109 if !unrelated.is_empty() {
110 let preview: Vec<String> = unrelated.iter().take(5).cloned().collect();
111 let suffix = if unrelated.len() > preview.len() {
112 format!(" (+{} more)", unrelated.len() - preview.len())
113 } else {
114 String::new()
115 };
116 blockers.push(format!(
117 "{} unrelated uncommitted git change(s) outside the merge: {}{}",
118 unrelated.len(),
119 preview.join(", "),
120 suffix
121 ));
122 }
123
124 if blockers.is_empty() {
125 Ok(())
126 } else {
127 Err(GitCommitBlocked { blockers })
128 }
129}
130
131fn collect_unrelated_git_paths(
132 repo_root: &std::path::Path,
133 expected: &std::collections::HashSet<&str>,
134) -> Result<Vec<String>> {
135 use sley::{ShortStatusOptions, StatusUntrackedMode, StreamControl};
136 let git = SleyRepository::discover(repo_root)
137 .with_context(|| format!("failed to open Git checkout at {}", repo_root.display()))?;
138 let mut unrelated = Vec::new();
139 git.stream_short_status_with_options(
140 ShortStatusOptions {
141 untracked_mode: StatusUntrackedMode::All,
142 ..ShortStatusOptions::default()
143 },
144 |entry| {
145 let path = String::from_utf8_lossy(entry.path).into_owned();
146 if path.is_empty() {
147 return Ok(StreamControl::Continue);
148 }
149 let mut labels = Vec::new();
150 if entry.index == b'?' && entry.worktree == b'?' {
151 labels.push(format!("untracked: {path}"));
152 } else {
153 if entry.index != b' ' && entry.index != b'!' {
154 labels.push(path.clone());
155 }
156 if entry.worktree != b' ' && entry.worktree != b'!' {
157 labels.push(format!("unstaged: {path}"));
158 }
159 }
160 for label in labels {
161 let comparison = label
162 .strip_prefix("unstaged: ")
163 .or_else(|| label.strip_prefix("untracked: "))
164 .unwrap_or(label.as_str());
165 if !expected.contains(comparison) {
166 unrelated.push(label);
167 }
168 }
169 Ok(StreamControl::Continue)
170 },
171 )
172 .with_context(|| {
173 format!(
174 "failed to inspect Git status before commit at {}",
175 repo_root.display()
176 )
177 })?;
178 unrelated.sort();
179 unrelated.dedup();
180 Ok(unrelated)
181}
182
183pub fn build_commit_message(
188 base_message: &str,
189 merge_state_id: &str,
190 attribution: &Attribution,
191) -> String {
192 let subject = base_message.lines().next().unwrap_or(base_message).trim();
193 let mut out = String::new();
194 out.push_str(subject);
195 out.push_str("\n\n");
196 out.push_str(&format!("Heddle merge state: {merge_state_id}\n"));
197 out.push('\n');
198 out.push_str(&format!("Merge-State: {merge_state_id}\n"));
199 let principal_name = attribution.principal.name_lossy();
200 let principal_email = attribution.principal.email_lossy();
201 if principal_name.trim() != "Unknown"
202 && principal_email.trim() != "unknown@example.com"
203 && !principal_name.trim().is_empty()
204 && !principal_email.trim().is_empty()
205 {
206 out.push_str(&format!(
207 "Co-Authored-By: {} <{}>\n",
208 principal_name, principal_email
209 ));
210 }
211 out
212}
213
214pub fn write_git_commit(
216 repo: &Repository,
217 state_id: &StateId,
218 paths: &[String],
219 message: &str,
220 extra_parents: &[String],
221) -> Result<GitCommitInfo> {
222 if paths.is_empty() {
223 return Err(anyhow!(merge_git_commit_empty_advice()));
224 }
225 let repo_root = repo.root();
226 let git = SleyRepository::discover(repo_root)
227 .with_context(|| format!("failed to open Git checkout at {}", repo_root.display()))?;
228 let old_head = git
229 .head()
230 .context("failed to resolve Git HEAD before merge --git-commit")?
231 .oid
232 .context("failed to resolve Git HEAD before merge --git-commit")?;
233 let state = repo
234 .store()
235 .get_state(state_id)?
236 .ok_or_else(|| anyhow!("merge state {} was not found", state_id.short()))?;
237 let identity = heddle_git_projection::git_core::resolve_git_commit_identity(
238 repo_root,
239 &state.attribution.principal,
240 )?;
241 let tree_id = git_export::export_tree(repo, &git, &state.tree).map_err(|err| {
242 anyhow!(merge_git_commit_failed_advice(
243 "writing Git tree",
244 err.to_string()
245 ))
246 })?;
247
248 let mut parents = vec![old_head];
249 for parent in extra_parents {
250 let oid = parent
251 .parse::<GitObjectId>()
252 .with_context(|| format!("invalid extra Git parent '{parent}'"))?;
253 let object = git
254 .read_object(&oid)
255 .with_context(|| format!("extra Git parent '{parent}' was not found"))?;
256 if object.object_type != GitObjectType::Commit {
257 return Err(anyhow!("extra Git parent '{parent}' is not a commit"));
258 }
259 if !parents.contains(&oid) {
260 parents.push(oid);
261 }
262 }
263
264 let seconds = SystemTime::now()
265 .duration_since(SystemTime::UNIX_EPOCH)
266 .map(|duration| duration.as_secs() as i64)
267 .unwrap_or(0);
268 let signature = identity.to_signature(seconds);
269 let commit = CommitObject {
270 tree: tree_id,
271 parents,
272 author: signature.to_ident_bytes(),
273 committer: signature.to_ident_bytes(),
274 encoding: None,
275 message: message.as_bytes().to_vec(),
276 };
277 let commit_id = git
278 .write_object(EncodedObject::new(GitObjectType::Commit, commit.write()))
279 .map_err(|err| {
280 anyhow!(merge_git_commit_failed_advice(
281 "writing Git commit object",
282 err.to_string()
283 ))
284 })?;
285
286 let index = git.index_from_tree(&tree_id).map_err(|err| {
290 anyhow!(merge_git_commit_failed_advice(
291 "writing Git index",
292 err.to_string()
293 ))
294 })?;
295 git.write_index(
296 &index,
297 IndexWriteOptions {
298 fsync: true,
299 validate_checksum: true,
300 },
301 )
302 .map_err(|err| {
303 anyhow!(merge_git_commit_failed_advice(
304 "writing Git index",
305 err.to_string()
306 ))
307 })?;
308
309 update_head_ref(&git, commit_id, old_head, &identity).map_err(|err| {
310 anyhow!(merge_git_commit_failed_advice(
311 "updating Git HEAD",
312 err.to_string()
313 ))
314 })?;
315
316 Ok(GitCommitInfo {
317 sha: commit_id.to_string(),
318 message: message.to_string(),
319 })
320}
321
322fn update_head_ref(
323 git: &SleyRepository,
324 new_head: GitObjectId,
325 old_head: GitObjectId,
326 identity: &LocalGitIdentity,
327) -> Result<()> {
328 let seconds = SystemTime::now()
329 .duration_since(SystemTime::UNIX_EPOCH)
330 .map(|duration| duration.as_secs() as i64)
331 .unwrap_or(0);
332 let head = git.head().context("failed to inspect Git HEAD")?;
333 let ref_name = head
334 .symbolic_target
335 .as_ref()
336 .map(|name| name.as_str().to_string())
337 .unwrap_or_else(|| "HEAD".to_string());
338 let refs = git.references();
339 let mut tx = refs.transaction();
340 tx.update_to(
341 ref_name,
342 ReferenceTarget::Direct(new_head),
343 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old_head)),
344 Some(sley::plumbing::sley_refs::ReflogEntry {
345 old_oid: old_head,
346 new_oid: new_head,
347 committer: identity.to_signature(seconds).to_ident_bytes(),
348 message: b"heddle: merge --git-commit".to_vec(),
349 }),
350 );
351 tx.commit().context("failed to update Git HEAD")?;
352 Ok(())
353}
354
355fn merge_git_commit_empty_advice() -> HeddleError {
356 HeddleError::recovery(RecoveryDetails::safety_refusal(
357 "merge_git_commit_empty",
358 "Merge produced no changed paths; refusing to write an empty Git commit",
359 "Inspect repository state with `heddle status`; rerun without `--git-commit` if no Git commit is needed.",
360 "the merge result has no paths to stage for Git",
361 "--git-commit would create an empty Git commit that does not correspond to landed Heddle paths",
362 "Heddle and Git state were left unchanged by the Git commit writer",
363 ))
364}
365
366fn merge_git_commit_failed_advice(stage: &'static str, detail: String) -> HeddleError {
367 let detail = if detail.trim().is_empty() {
368 "Git did not report a detailed error".to_string()
369 } else {
370 detail
371 };
372 HeddleError::recovery(RecoveryDetails::safety_refusal(
373 "merge_git_commit_failed",
374 format!("{stage} failed while finalizing merge --git-commit: {detail}"),
375 "Resolve the Git checkout issue, then run `heddle capture -m \"...\"` and `heddle commit -m \"...\"`; do not rerun the integration.",
376 format!("{stage} failed after Heddle merge commit coordination started"),
377 "retrying the Heddle merge could duplicate or obscure the already-landed Heddle merge state",
378 "the Heddle merge state is preserved; the Git commit writer did not report a completed commit",
379 ))
380}
381
382#[cfg(test)]
383mod tests {
384 use objects::object::Principal;
385
386 use super::*;
387
388 #[test]
389 fn build_commit_message_has_merge_state_trailer_and_coauthor() {
390 let attribution = Attribution::human(Principal::new("Ada Lovelace", "ada@example.com"));
391 let msg = build_commit_message("Merge thread 'feature'", "abcd1234", &attribution);
392 assert!(msg.starts_with("Merge thread 'feature'\n\n"));
393 assert!(msg.contains("Heddle merge state: abcd1234\n"));
394 assert!(msg.contains("\nMerge-State: abcd1234\n"));
395 assert!(msg.contains("Co-Authored-By: Ada Lovelace <ada@example.com>\n"));
396 }
397
398 #[test]
399 fn build_commit_message_uses_only_first_subject_line() {
400 let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
401 let msg = build_commit_message(
402 "Merge thread 'x'\n\nlonger body\nthat we drop",
403 "deadbeef",
404 &attribution,
405 );
406 assert!(msg.starts_with("Merge thread 'x'\n\n"));
408 assert!(!msg.contains("longer body"));
409 }
410
411 #[test]
412 fn merge_git_commit_empty_uses_typed_advice() {
413 let err = merge_git_commit_empty_advice();
414 let objects::HeddleError::Recovery(details) = err else {
415 panic!("expected recovery error");
416 };
417 assert_eq!(details.kind, "merge_git_commit_empty");
418 assert!(details.error.contains("no changed paths"));
419 assert!(details.would_change.contains("empty Git commit"));
420 }
421
422 #[test]
423 fn merge_git_commit_failure_uses_typed_advice() {
424 let err = merge_git_commit_failed_advice("writing Git index", "index locked".to_string());
425 let objects::HeddleError::Recovery(details) = err else {
426 panic!("expected recovery error");
427 };
428 assert_eq!(details.kind, "merge_git_commit_failed");
429 assert!(details.error.contains("writing Git index"));
430 assert!(details.error.contains("index locked"));
431 assert!(
432 details
433 .preserved
434 .contains("Heddle merge state is preserved")
435 );
436 }
437}