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/// Reset the current branch to a specific commit.
94///
95/// `mode` must be one of `"soft"`, `"mixed"`, or `"hard"`:
96/// - **soft**  — moves HEAD; staged + working-directory changes are kept.
97/// - **mixed** — moves HEAD and unstages changes; working directory is kept.
98/// - **hard**  — moves HEAD and discards all uncommitted changes permanently.
99pub fn reset_to_commit(workdir: &std::path::Path, oid_str: &str, mode: &str) -> Result<()> {
100    let flag = format!("--{mode}");
101    let output = std::process::Command::new("git")
102        .current_dir(workdir)
103        .args(["reset", &flag, oid_str])
104        .output()
105        .context("failed to spawn git")?;
106    if !output.status.success() {
107        let stderr = String::from_utf8_lossy(&output.stderr);
108        anyhow::bail!("{}", stderr.trim());
109    }
110    Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use tempfile::TempDir;
117
118    #[test]
119    fn init_and_open() {
120        let tmp = TempDir::new().unwrap();
121        let repo = init_repo(tmp.path()).unwrap();
122        assert!(!repo.is_bare());
123
124        let reopened = open_repo(tmp.path()).unwrap();
125        assert_eq!(
126            repo.path().canonicalize().unwrap(),
127            reopened.path().canonicalize().unwrap(),
128        );
129    }
130
131    #[test]
132    fn repo_info_on_fresh_repo() {
133        let tmp = TempDir::new().unwrap();
134        let repo = init_repo(tmp.path()).unwrap();
135        let info = get_repo_info(&repo).unwrap();
136
137        assert!(!info.is_bare);
138        assert_eq!(info.state, RepoState::Clean);
139        // No commits yet, so HEAD is unborn — head_branch is None.
140        assert!(info.head_branch.is_none());
141        assert!(info.workdir.is_some());
142    }
143
144    #[test]
145    fn repo_info_with_commit() {
146        let tmp = TempDir::new().unwrap();
147        let repo = init_repo(tmp.path()).unwrap();
148
149        // Create an initial commit so HEAD points to a branch.
150        let sig = git2::Signature::now("Test", "test@test.com").unwrap();
151        let tree_oid = {
152            let mut index = repo.index().unwrap();
153            index.write_tree().unwrap()
154        };
155        let tree = repo.find_tree(tree_oid).unwrap();
156        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
157            .unwrap();
158
159        let info = get_repo_info(&repo).unwrap();
160        // git init creates branch "master" by default (unless configured otherwise).
161        assert!(info.head_branch.is_some());
162    }
163}