Skip to main content

git_atomic/git/
commit.rs

1use crate::core::GitError;
2use gix::ObjectId;
3use std::path::Path;
4
5/// Build a partial tree containing only the specified files from a source tree.
6pub fn build_partial_tree(
7    repo: &gix::Repository,
8    source_tree: &gix::Tree<'_>,
9    files: &[&Path],
10) -> Result<ObjectId, GitError> {
11    let mut editor = repo
12        .edit_tree(gix::hash::ObjectId::empty_tree(repo.object_hash()))
13        .map_err(|e| GitError::Operation(format!("create tree editor: {e}")))?;
14
15    for file in files {
16        let path_str = file
17            .to_str()
18            .ok_or_else(|| GitError::Operation(format!("non-UTF8 path: {}", file.display())))?;
19
20        let entry = source_tree
21            .lookup_entry_by_path(path_str)
22            .map_err(|e| GitError::TreeEntryNotFound {
23                path: format!("{}: {e}", file.display()),
24            })?
25            .ok_or_else(|| GitError::TreeEntryNotFound {
26                path: file.display().to_string(),
27            })?;
28
29        editor
30            .upsert(path_str, entry.mode().kind(), entry.object_id())
31            .map_err(|e| GitError::Operation(format!("upsert tree entry: {e}")))?;
32    }
33
34    let tree_id = editor
35        .write()
36        .map_err(|e| GitError::Operation(format!("write tree: {e}")))?;
37
38    Ok(tree_id.detach())
39}
40
41/// Generate a conventional commit message for a component.
42pub fn generate_message(component: &str, commit_type: &str, source_summary: &str) -> String {
43    format!("{commit_type}({component}): {source_summary}")
44}
45
46/// Create a commit object on the repository (does NOT update any ref).
47pub fn create_commit(
48    repo: &gix::Repository,
49    tree_id: ObjectId,
50    parent_id: ObjectId,
51    message: &str,
52    source_author: gix::actor::SignatureRef<'_>,
53) -> Result<ObjectId, GitError> {
54    let committer_ref = repo
55        .committer()
56        .transpose()
57        .map_err(|e| GitError::Operation(format!("get committer: {e}")))?
58        .ok_or_else(|| GitError::Operation("no committer configured".into()))?;
59
60    let commit = gix::objs::Commit {
61        tree: tree_id,
62        parents: vec![parent_id].into(),
63        author: source_author.into(),
64        committer: committer_ref.into(),
65        encoding: None,
66        message: message.into(),
67        extra_headers: vec![],
68    };
69
70    let id = repo
71        .write_object(&commit)
72        .map_err(|e| GitError::Operation(format!("write commit: {e}")))?;
73
74    Ok(id.detach())
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::process::Command;
81
82    fn git(dir: &std::path::Path, args: &[&str]) -> String {
83        let out = Command::new("git")
84            .args(args)
85            .current_dir(dir)
86            .output()
87            .unwrap();
88        String::from_utf8_lossy(&out.stdout).trim().to_string()
89    }
90
91    fn setup_repo(dir: &std::path::Path) {
92        git(dir, &["init", "-b", "main"]);
93        git(dir, &["config", "user.email", "test@test.com"]);
94        git(dir, &["config", "user.name", "Test"]);
95        std::fs::create_dir_all(dir.join("src/ui")).unwrap();
96        std::fs::create_dir_all(dir.join("src/api")).unwrap();
97        std::fs::write(dir.join("src/ui/app.ts"), "// app").unwrap();
98        std::fs::write(dir.join("src/api/handler.rs"), "// handler").unwrap();
99        git(dir, &["add", "."]);
100        git(dir, &["commit", "-m", "add files"]);
101    }
102
103    #[test]
104    fn partial_tree_contains_only_selected_files() {
105        let dir = tempfile::tempdir().unwrap();
106        setup_repo(dir.path());
107
108        let repo = crate::git::open_repo(dir.path()).unwrap();
109        let head = crate::git::resolve_commit(&repo, "HEAD").unwrap();
110        let commit = repo.find_commit(head).unwrap();
111        let source_tree = commit.tree().unwrap();
112
113        let files = [Path::new("src/ui/app.ts")];
114        let file_refs: Vec<&Path> = files.to_vec();
115        let tree_id = build_partial_tree(&repo, &source_tree, &file_refs).unwrap();
116
117        let tree = repo.find_tree(tree_id).unwrap();
118        assert!(
119            tree.lookup_entry_by_path("src/ui/app.ts")
120                .unwrap()
121                .is_some()
122        );
123        assert!(
124            tree.lookup_entry_by_path("src/api/handler.rs")
125                .unwrap()
126                .is_none()
127        );
128    }
129
130    #[test]
131    fn generate_message_format() {
132        assert_eq!(
133            generate_message("frontend", "feat", "add login page"),
134            "feat(frontend): add login page"
135        );
136    }
137
138    #[test]
139    fn create_commit_works() {
140        let dir = tempfile::tempdir().unwrap();
141        setup_repo(dir.path());
142
143        let repo = crate::git::open_repo(dir.path()).unwrap();
144        let head = crate::git::resolve_commit(&repo, "HEAD").unwrap();
145        let commit = repo.find_commit(head).unwrap();
146        let source_tree = commit.tree().unwrap();
147        let author = commit.author().unwrap();
148
149        let files = [Path::new("src/ui/app.ts")];
150        let file_refs: Vec<&Path> = files.to_vec();
151        let tree_id = build_partial_tree(&repo, &source_tree, &file_refs).unwrap();
152
153        let commit_id =
154            create_commit(&repo, tree_id, head, "feat(ui): test commit", author).unwrap();
155
156        let new_commit = repo.find_commit(commit_id).unwrap();
157        assert_eq!(
158            new_commit.message_raw_sloppy().to_string(),
159            "feat(ui): test commit"
160        );
161    }
162
163    #[test]
164    fn missing_entry_errors() {
165        let dir = tempfile::tempdir().unwrap();
166        setup_repo(dir.path());
167
168        let repo = crate::git::open_repo(dir.path()).unwrap();
169        let head = crate::git::resolve_commit(&repo, "HEAD").unwrap();
170        let commit = repo.find_commit(head).unwrap();
171        let source_tree = commit.tree().unwrap();
172
173        let files = [Path::new("nonexistent.txt")];
174        let file_refs: Vec<&Path> = files.to_vec();
175        assert!(build_partial_tree(&repo, &source_tree, &file_refs).is_err());
176    }
177}