1use crate::core::GitError;
2use gix::ObjectId;
3use std::path::Path;
4
5pub 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
41pub fn generate_message(component: &str, commit_type: &str, source_summary: &str) -> String {
43 format!("{commit_type}({component}): {source_summary}")
44}
45
46pub 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}