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/// Delete a file from the working directory.
128///
129/// `relative_path` is the repository-relative path (e.g. `src/main.rs`).
130/// Returns an error if the file does not exist or cannot be removed.
131pub 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
137/// Retrieve the content of a file at a specific commit.
138///
139/// Returns the file content as a UTF-8 string. Returns an error if the file
140/// doesn't exist at that commit or isn't valid UTF-8.
141pub 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
166/// Load a complete repository snapshot in one blocking call.
167///
168/// Opens the repository at `path`, runs all eight data-loading operations in
169/// sequence, and returns a [`RepoSnapshot`] containing every field needed to
170/// render the UI.  Both the GUI and TUI call this from their background
171/// threads rather than duplicating the load sequence locally.
172pub 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        // No commits yet, so HEAD is unborn — head_branch is None.
223        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        // Create an initial commit so HEAD points to a branch.
233        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        // git init creates branch "master" by default (unless configured otherwise).
244        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            // Minimal setup: configure user so commit can be created
253            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            // Create initial commit
258            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            // tree and repo both drop here, tree first (reverse declaration order)
267        }
268
269        let snapshot = load_repo_snapshot(dir.path()).unwrap();
270        // At minimum the info should have workdir set
271        assert!(snapshot.info.workdir.is_some());
272        // graph_rows is computed from commits — both should have the same length
273        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}