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 delete_file(workdir: &std::path::Path, relative_path: &str) -> Result<()> {
132 let full_path = workdir.join(relative_path);
133 std::fs::remove_file(&full_path)
134 .with_context(|| format!("failed to delete '{}'", full_path.display()))
135}
136
137pub fn get_file_at_commit(
142 repo: &Repository,
143 oid_str: &str,
144 file_path: &str,
145) -> anyhow::Result<String> {
146 let oid = git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID: {oid_str}"))?;
147 let commit = repo
148 .find_commit(oid)
149 .with_context(|| format!("commit {oid_str} not found"))?;
150 let tree = commit.tree().context("commit has no tree")?;
151
152 let entry = tree
153 .get_path(std::path::Path::new(file_path))
154 .with_context(|| format!("file '{file_path}' not found at commit {oid_str}"))?;
155
156 let blob = repo
157 .find_blob(entry.id())
158 .with_context(|| format!("could not read blob for '{file_path}'"))?;
159
160 let content = std::str::from_utf8(blob.content())
161 .with_context(|| format!("file '{file_path}' is not valid UTF-8"))?;
162
163 Ok(content.to_string())
164}
165
166pub fn load_repo_snapshot(path: &std::path::Path) -> anyhow::Result<super::types::RepoSnapshot> {
173 let mut repo = open_repo(path)?;
174
175 let info = get_repo_info(&repo)?;
176 let branches = crate::features::branches::list_branches(&repo)?;
177 let commits = crate::features::commits::list_commits(&repo, 500)?;
178 let graph_rows = crate::features::graph::build_graph(&commits);
179 let unstaged = crate::features::diff::get_working_dir_diff(&repo)?;
180 let staged = crate::features::diff::get_staged_diff(&repo)?;
181 let remotes = crate::features::remotes::list_remotes(&repo)?;
182 let stashes = crate::features::stash::list_stashes(&mut repo)?;
183
184 Ok(super::types::RepoSnapshot {
185 info,
186 branches,
187 commits,
188 graph_rows,
189 unstaged,
190 staged,
191 stashes,
192 remotes,
193 })
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use tempfile::TempDir;
200
201 #[test]
202 fn init_and_open() {
203 let tmp = TempDir::new().unwrap();
204 let repo = init_repo(tmp.path()).unwrap();
205 assert!(!repo.is_bare());
206
207 let reopened = open_repo(tmp.path()).unwrap();
208 assert_eq!(
209 repo.path().canonicalize().unwrap(),
210 reopened.path().canonicalize().unwrap(),
211 );
212 }
213
214 #[test]
215 fn repo_info_on_fresh_repo() {
216 let tmp = TempDir::new().unwrap();
217 let repo = init_repo(tmp.path()).unwrap();
218 let info = get_repo_info(&repo).unwrap();
219
220 assert!(!info.is_bare);
221 assert_eq!(info.state, RepoState::Clean);
222 assert!(info.head_branch.is_none());
224 assert!(info.workdir.is_some());
225 }
226
227 #[test]
228 fn repo_info_with_commit() {
229 let tmp = TempDir::new().unwrap();
230 let repo = init_repo(tmp.path()).unwrap();
231
232 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
234 let tree_oid = {
235 let mut index = repo.index().unwrap();
236 index.write_tree().unwrap()
237 };
238 let tree = repo.find_tree(tree_oid).unwrap();
239 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
240 .unwrap();
241
242 let info = get_repo_info(&repo).unwrap();
243 assert!(info.head_branch.is_some());
245 }
246
247 #[test]
248 fn load_repo_snapshot_returns_all_fields() {
249 let dir = tempfile::tempdir().unwrap();
250 {
251 let repo = git2::Repository::init(dir.path()).unwrap();
252 let mut config = repo.config().unwrap();
254 config.set_str("user.name", "Test").unwrap();
255 config.set_str("user.email", "test@test.com").unwrap();
256 drop(config);
257 let sig = repo.signature().unwrap();
259 let tree_id = {
260 let mut idx = repo.index().unwrap();
261 idx.write_tree().unwrap()
262 };
263 let tree = repo.find_tree(tree_id).unwrap();
264 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
265 .unwrap();
266 }
268
269 let snapshot = load_repo_snapshot(dir.path()).unwrap();
270 assert!(snapshot.info.workdir.is_some());
272 assert_eq!(snapshot.commits.len(), snapshot.graph_rows.len());
274 }
275
276 fn setup_repo_with_commit() -> (TempDir, Repository) {
277 let tmp = TempDir::new().unwrap();
278 let repo = init_repo(tmp.path()).unwrap();
279 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
280 std::fs::write(tmp.path().join("file.txt"), "hello\n").unwrap();
281 {
282 let mut index = repo.index().unwrap();
283 index.add_path(std::path::Path::new("file.txt")).unwrap();
284 index.write().unwrap();
285 let tree_oid = index.write_tree().unwrap();
286 let tree = repo.find_tree(tree_oid).unwrap();
287 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
288 .unwrap();
289 }
290 (tmp, repo)
291 }
292
293 #[test]
294 fn get_file_at_commit_returns_content() {
295 let (_dir, repo) = setup_repo_with_commit();
296 let head_oid = repo.head().unwrap().target().unwrap().to_string();
297 let content = get_file_at_commit(&repo, &head_oid, "file.txt").unwrap();
298 assert_eq!(content, "hello\n");
299 }
300
301 #[test]
302 fn get_file_at_commit_not_found() {
303 let (_dir, repo) = setup_repo_with_commit();
304 let head_oid = repo.head().unwrap().target().unwrap().to_string();
305 let result = get_file_at_commit(&repo, &head_oid, "nonexistent.txt");
306 assert!(result.is_err());
307 }
308}