Skip to main content

gitkraft_core/features/repo/
ops.rs

1//! Repository-level operations — open, init, clone, and inspect.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use git2::Repository;
7
8use super::types::{RepoInfo, RepoState};
9
10/// Open an existing repository at `path`.
11///
12/// Uses [`Repository::discover`] so it works when `path` is any directory
13/// inside the work-tree (it will walk upwards to find `.git`).
14pub fn open_repo(path: &Path) -> Result<Repository> {
15    Repository::discover(path)
16        .with_context(|| format!("failed to open repository at {}", path.display()))
17}
18
19/// Initialise a brand-new repository at `path`.
20pub fn init_repo(path: &Path) -> Result<Repository> {
21    Repository::init(path)
22        .with_context(|| format!("failed to init repository at {}", path.display()))
23}
24
25/// Clone a remote repository from `url` into `path`.
26///
27/// This performs a plain HTTPS/SSH clone.  Authentication is **not** configured
28/// here — it will work for public repos and fail for private ones that require
29/// credentials.
30pub 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
35/// Gather high-level information about an already-opened repository.
36pub 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            // Detached HEAD — show the short OID instead
47            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
63/// Checkout a specific commit by OID, leaving HEAD in detached state.
64pub 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
79/// Revert a commit by OID using `git revert --no-edit`.
80pub 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
93/// Cherry-pick a commit by OID onto the current branch.
94pub 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
107/// Reset the current branch to a specific commit.
108///
109/// `mode` must be one of `"soft"`, `"mixed"`, or `"hard"`:
110/// - **soft**  — moves HEAD; staged + working-directory changes are kept.
111/// - **mixed** — moves HEAD and unstages changes; working directory is kept.
112/// - **hard**  — moves HEAD and discards all uncommitted changes permanently.
113pub 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
127/// Retrieve the content of a file at a specific commit.
128///
129/// Returns the file content as a UTF-8 string. Returns an error if the file
130/// doesn't exist at that commit or isn't valid UTF-8.
131pub 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        // No commits yet, so HEAD is unborn — head_branch is None.
183        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        // Create an initial commit so HEAD points to a branch.
193        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        // git init creates branch "master" by default (unless configured otherwise).
204        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}