Skip to main content

verbs/merge/
git_commit.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Optional git-commit coordination for `heddle merge --git-commit`.
3//!
4//! Closes the heddle-vs-git divergence at merge time: when the user
5//! opts in, after a successful (non-preview, non-conflict) heddle merge
6//! we also write a git commit on top of HEAD, staging the paths the
7//! merge introduced. The default (`--git-commit` not set) is preserved
8//! — heddle state advances and git is unaware.
9
10use 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/// Outcome of `--git-commit --preview` — what *would* be committed if
27/// the merge ran for real.
28#[derive(Clone, Debug, Serialize)]
29pub struct GitCommitPreview {
30    pub message: String,
31    pub files: Vec<String>,
32}
33
34/// Outcome of a real `--git-commit` write.
35#[derive(Clone, Debug, Serialize)]
36pub struct GitCommitInfo {
37    pub sha: String,
38    pub message: String,
39}
40
41/// Reasons the `--git-commit` request can't proceed. Surfaced via the
42/// merge output's `blockers` list with `status: "blocked"`, matching
43/// the schema settled by item 1.1.
44#[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
57/// Validate that git is in a state where we can safely write a merge
58/// commit. The merge has already enforced a clean *heddle* worktree;
59/// here we additionally enforce that the only uncommitted git changes
60/// are the ones the merge just produced (or, in preview mode, the ones
61/// the merge would touch).
62///
63/// `expected_paths` is the set of paths the merge will/did write — any
64/// other uncommitted git change is "unrelated" and blocks the
65/// `--git-commit` flow rather than getting silently swept up.
66pub 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    // Detached HEAD blocks the commit — a merge commit on a detached
82    // HEAD would be unreachable once HEAD moves.
83    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
183/// Build the commit message. Body includes the heddle merge state ID
184/// so post-merge audits can join git ↔ heddle. Trailers carry the
185/// `Merge-State` change-id and a `Co-Authored-By` for the merge
186/// attribution.
187pub 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
214/// Write a Git checkpoint commit for the landed Heddle merge state.
215pub 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    // Keep the checkout index aligned with the committed tree. This is
287    // the native equivalent of `git add <merge paths>` followed by
288    // `git commit`: after HEAD moves, `git status` should be clean.
289    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        // Subject line should be just the first line.
407        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}