gitkraft_core/features/repo/
ops.rs1use std::path::Path;
4
5use anyhow::{Context, Result};
6use git2::Repository;
7
8use super::types::{RepoInfo, RepoState};
9
10pub fn open_repo(path: &Path) -> Result<Repository> {
15 Repository::discover(path)
16 .with_context(|| format!("failed to open repository at {}", path.display()))
17}
18
19pub fn init_repo(path: &Path) -> Result<Repository> {
21 Repository::init(path)
22 .with_context(|| format!("failed to init repository at {}", path.display()))
23}
24
25pub fn clone_repo(url: &str, path: &Path) -> Result<Repository> {
31 Repository::clone(url, path)
32 .with_context(|| format!("failed to clone '{url}' into {}", path.display()))
33}
34
35pub fn get_repo_info(repo: &Repository) -> Result<RepoInfo> {
37 let path = repo.path().to_path_buf();
38 let workdir = repo.workdir().map(|p| p.to_path_buf());
39 let is_bare = repo.is_bare();
40 let state: RepoState = repo.state().into();
41
42 let head_branch = repo.head().ok().and_then(|reference| {
43 if reference.is_branch() {
44 reference.shorthand().map(String::from)
45 } else {
46 reference.target().map(|oid| {
48 let s = oid.to_string();
49 s[..7.min(s.len())].to_string()
50 })
51 }
52 });
53
54 Ok(RepoInfo {
55 path,
56 workdir,
57 head_branch,
58 is_bare,
59 state,
60 })
61}
62
63pub fn checkout_commit_detached(repo: &Repository, oid_str: &str) -> Result<()> {
65 let oid = git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID: {oid_str}"))?;
66 let commit = repo
67 .find_commit(oid)
68 .with_context(|| format!("commit {oid_str} not found"))?;
69 repo.set_head_detached(oid)
70 .context("failed to detach HEAD")?;
71 repo.checkout_tree(
72 commit.as_object(),
73 Some(git2::build::CheckoutBuilder::new().force()),
74 )
75 .context("failed to checkout commit tree")?;
76 Ok(())
77}
78
79pub fn revert_commit(workdir: &std::path::Path, oid_str: &str) -> Result<()> {
81 let output = std::process::Command::new("git")
82 .current_dir(workdir)
83 .args(["revert", "--no-edit", oid_str])
84 .output()
85 .context("failed to spawn git")?;
86 if !output.status.success() {
87 let stderr = String::from_utf8_lossy(&output.stderr);
88 anyhow::bail!("{}", stderr.trim());
89 }
90 Ok(())
91}
92
93pub fn cherry_pick_commit(workdir: &std::path::Path, oid_str: &str) -> anyhow::Result<()> {
95 let output = std::process::Command::new("git")
96 .current_dir(workdir)
97 .args(["cherry-pick", oid_str])
98 .output()
99 .context("failed to spawn git")?;
100 if !output.status.success() {
101 let stderr = String::from_utf8_lossy(&output.stderr);
102 anyhow::bail!("{}", stderr.trim());
103 }
104 Ok(())
105}
106
107pub fn reset_to_commit(workdir: &std::path::Path, oid_str: &str, mode: &str) -> Result<()> {
114 let flag = format!("--{mode}");
115 let output = std::process::Command::new("git")
116 .current_dir(workdir)
117 .args(["reset", &flag, oid_str])
118 .output()
119 .context("failed to spawn git")?;
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr);
122 anyhow::bail!("{}", stderr.trim());
123 }
124 Ok(())
125}
126
127pub fn get_file_at_commit(
132 repo: &Repository,
133 oid_str: &str,
134 file_path: &str,
135) -> anyhow::Result<String> {
136 let oid = git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID: {oid_str}"))?;
137 let commit = repo
138 .find_commit(oid)
139 .with_context(|| format!("commit {oid_str} not found"))?;
140 let tree = commit.tree().context("commit has no tree")?;
141
142 let entry = tree
143 .get_path(std::path::Path::new(file_path))
144 .with_context(|| format!("file '{file_path}' not found at commit {oid_str}"))?;
145
146 let blob = repo
147 .find_blob(entry.id())
148 .with_context(|| format!("could not read blob for '{file_path}'"))?;
149
150 let content = std::str::from_utf8(blob.content())
151 .with_context(|| format!("file '{file_path}' is not valid UTF-8"))?;
152
153 Ok(content.to_string())
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use tempfile::TempDir;
160
161 #[test]
162 fn init_and_open() {
163 let tmp = TempDir::new().unwrap();
164 let repo = init_repo(tmp.path()).unwrap();
165 assert!(!repo.is_bare());
166
167 let reopened = open_repo(tmp.path()).unwrap();
168 assert_eq!(
169 repo.path().canonicalize().unwrap(),
170 reopened.path().canonicalize().unwrap(),
171 );
172 }
173
174 #[test]
175 fn repo_info_on_fresh_repo() {
176 let tmp = TempDir::new().unwrap();
177 let repo = init_repo(tmp.path()).unwrap();
178 let info = get_repo_info(&repo).unwrap();
179
180 assert!(!info.is_bare);
181 assert_eq!(info.state, RepoState::Clean);
182 assert!(info.head_branch.is_none());
184 assert!(info.workdir.is_some());
185 }
186
187 #[test]
188 fn repo_info_with_commit() {
189 let tmp = TempDir::new().unwrap();
190 let repo = init_repo(tmp.path()).unwrap();
191
192 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
194 let tree_oid = {
195 let mut index = repo.index().unwrap();
196 index.write_tree().unwrap()
197 };
198 let tree = repo.find_tree(tree_oid).unwrap();
199 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
200 .unwrap();
201
202 let info = get_repo_info(&repo).unwrap();
203 assert!(info.head_branch.is_some());
205 }
206
207 fn setup_repo_with_commit() -> (TempDir, Repository) {
208 let tmp = TempDir::new().unwrap();
209 let repo = init_repo(tmp.path()).unwrap();
210 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
211 std::fs::write(tmp.path().join("file.txt"), "hello\n").unwrap();
212 {
213 let mut index = repo.index().unwrap();
214 index.add_path(std::path::Path::new("file.txt")).unwrap();
215 index.write().unwrap();
216 let tree_oid = index.write_tree().unwrap();
217 let tree = repo.find_tree(tree_oid).unwrap();
218 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
219 .unwrap();
220 }
221 (tmp, repo)
222 }
223
224 #[test]
225 fn get_file_at_commit_returns_content() {
226 let (_dir, repo) = setup_repo_with_commit();
227 let head_oid = repo.head().unwrap().target().unwrap().to_string();
228 let content = get_file_at_commit(&repo, &head_oid, "file.txt").unwrap();
229 assert_eq!(content, "hello\n");
230 }
231
232 #[test]
233 fn get_file_at_commit_not_found() {
234 let (_dir, repo) = setup_repo_with_commit();
235 let head_oid = repo.head().unwrap().target().unwrap().to_string();
236 let result = get_file_at_commit(&repo, &head_oid, "nonexistent.txt");
237 assert!(result.is_err());
238 }
239}