Skip to main content

rskit_git/
lib.rs

1//! Composable git repository interfaces backed by libgit2 and the git CLI.
2//!
3//! This crate provides capability-based traits for git operations:
4//! - [`Repository`] and [`Executor`] for core repository access and raw CLI execution
5//! - [`Differ`], [`IgnoreReader`], [`TreeReader`], [`LogReader`], [`Blamer`], and [`Inspector`] for read flows
6//! - [`IndexManager`], [`Committer`], and related write traits for mutating operations
7//! - [`RefManager`], [`RemoteManager`], [`ConfigReader`], and [`Maintainer`] for management
8//!
9//! The default backend uses `git2` (libgit2) for embedded operations and delegates several
10//! core mutating operations (merge, rebase, checkout, reset, stash, cherry-pick, maintenance)
11//! to the `git` CLI binary. **A `git` binary must be available on `PATH` at runtime.**
12//! Network operations (fetch, push) use the embedded `git2` backend.
13//!
14//! # Usage
15//!
16//! ```no_run
17//! use rskit_git::{Differ, Repository, open};
18//!
19//! let repo = open("/path/to/repo").unwrap();
20//! let dirty = repo.is_dirty().unwrap();
21//! let _status = repo.status().unwrap();
22//! assert!(!repo.root().as_os_str().is_empty() || !dirty);
23//! ```
24
25#![warn(missing_docs)]
26
27pub mod auth;
28pub mod cli;
29pub mod core;
30pub mod embedded;
31pub mod error;
32pub mod manage;
33pub mod options;
34pub mod paths;
35pub mod read;
36pub mod repo;
37#[cfg(feature = "testutil")]
38pub mod testutil;
39pub mod types;
40pub mod write;
41
42pub use core::{Executor, Repository};
43pub use error::GitError;
44pub use manage::{ConfigReader, Maintainer, RefManager, RemoteManager};
45pub use options::*;
46pub use paths::{join_repo_path, repo_relative_path};
47pub use read::{Blamer, Differ, IgnoreReader, IndexReader, Inspector, LogReader, TreeReader};
48pub use repo::{Repo, clone, discover, init, init_bare, open};
49pub use rskit_errors::{AppError, AppResult};
50pub use types::*;
51pub use write::{
52    CheckoutManager, CherryPicker, Committer, IndexManager, Merger, Rebaser, Resetter, Stasher,
53};
54
55#[cfg(test)]
56mod tests {
57    use std::fs;
58    use std::path::{Path, PathBuf};
59    use std::time::{SystemTime, UNIX_EPOCH};
60
61    use super::*;
62
63    struct LocalRepo {
64        root: PathBuf,
65        repo: Repo,
66    }
67
68    impl LocalRepo {
69        fn new(name: &str) -> Self {
70            let root = local_workspace(name);
71            let repo = init(&root).expect("initialize local git repository");
72            repo.config_set("user.name", "Test User")
73                .expect("set user.name");
74            repo.config_set("user.email", "test@example.com")
75                .expect("set user.email");
76            Self { root, repo }
77        }
78
79        fn write(&self, path: &str, content: &str) {
80            let full_path = self.root.join(path);
81            if let Some(parent) = full_path.parent() {
82                fs::create_dir_all(parent).expect("create parent directories");
83            }
84            fs::write(full_path, content).expect("write repository file");
85        }
86
87        fn commit_all(&self, message: &str) -> Oid {
88            let paths = self
89                .repo
90                .status()
91                .expect("read status")
92                .into_iter()
93                .map(|entry| entry.path)
94                .collect::<Vec<_>>();
95            let refs = paths.iter().map(String::as_str).collect::<Vec<_>>();
96            if !refs.is_empty() {
97                self.repo.stage(&refs).expect("stage status paths");
98            }
99            self.repo
100                .commit(message, None)
101                .expect("commit staged changes")
102        }
103    }
104
105    impl Drop for LocalRepo {
106        fn drop(&mut self) {
107            let _ = fs::remove_dir_all(&self.root);
108        }
109    }
110
111    fn local_workspace(name: &str) -> PathBuf {
112        let base = Path::new(env!("CARGO_MANIFEST_DIR"))
113            .join("..")
114            .join("..")
115            .join("target")
116            .join("rskit-git-tests");
117        fs::create_dir_all(&base).expect("create local test workspace");
118        let unique = SystemTime::now()
119            .duration_since(UNIX_EPOCH)
120            .expect("system time after epoch")
121            .as_nanos();
122        let root = base.join(format!("{name}-{}-{unique}", std::process::id()));
123        let _ = fs::remove_dir_all(&root);
124        fs::create_dir_all(&root).expect("create test repo directory");
125        root
126    }
127
128    #[test]
129    fn repository_facade_manages_refs_config_and_remotes_locally() {
130        let local = LocalRepo::new("manage");
131        local.write("README.md", "# local\n");
132        let initial = local.commit_all("initial commit");
133
134        local
135            .repo
136            .create_branch("feature", "HEAD")
137            .expect("create branch");
138        local
139            .repo
140            .create_tag("v1.0.0", "HEAD", "release notes")
141            .expect("create annotated tag");
142        local
143            .repo
144            .create_tag("v1.0.1", "HEAD", "")
145            .expect("create lightweight tag");
146        local
147            .repo
148            .exec(&[
149                "remote",
150                "add",
151                "origin",
152                &format!("file://{}", local.root.display()),
153            ])
154            .expect("add local remote");
155
156        let branches = local
157            .repo
158            .list_branches(BranchFilter::All)
159            .expect("list branches");
160        assert!(branches.iter().any(|branch| branch.name == "feature"));
161
162        let tags = local.repo.list_tags().expect("list tags");
163        let annotated = tags
164            .iter()
165            .find(|tag| tag.name == "v1.0.0")
166            .expect("annotated tag exists");
167        assert_eq!(annotated.message, "release notes");
168        assert_eq!(annotated.target, initial);
169        assert!(
170            tags.iter()
171                .any(|tag| tag.name == "v1.0.1" && tag.message.is_empty())
172        );
173
174        let remotes = local.repo.list_remotes().expect("list remotes");
175        assert_eq!(remotes[0].name, "origin");
176        assert!(remotes[0].url.starts_with("file://"));
177
178        assert_eq!(
179            local.repo.config_get("user.email").expect("get config"),
180            "test@example.com"
181        );
182        let missing = local
183            .repo
184            .config_get("rskit.missing")
185            .expect_err("missing config maps to error");
186        assert_eq!(missing.code(), rskit_errors::ErrorCode::NotFound);
187
188        let duplicate = local
189            .repo
190            .create_branch("feature", "HEAD")
191            .expect_err("duplicate branch maps to conflict");
192        assert_eq!(duplicate.code(), rskit_errors::ErrorCode::AlreadyExists);
193    }
194
195    #[test]
196    fn repository_facade_handles_index_checkout_stash_and_clean() {
197        let local = LocalRepo::new("write");
198        local.write("tracked.txt", "one\n");
199        local.commit_all("initial commit");
200
201        local.write("tracked.txt", "two\n");
202        local
203            .repo
204            .stage(&["tracked.txt"])
205            .expect("stage tracked file");
206        assert_eq!(
207            local.repo.staged_entries().expect("list staged entries")[0].state,
208            EntryState::Staged
209        );
210
211        local
212            .repo
213            .reset("HEAD", ResetMode::Mixed)
214            .expect("mixed reset");
215        assert!(
216            local
217                .repo
218                .staged_entries()
219                .expect("list staged entries after reset")
220                .is_empty()
221        );
222
223        local
224            .repo
225            .checkout_files(&["tracked.txt"])
226            .expect("restore tracked file");
227        assert_eq!(
228            fs::read_to_string(local.root.join("tracked.txt")).expect("read restored file"),
229            "one\n"
230        );
231
232        local.write("tracked.txt", "stashed\n");
233        let stash_oid = local.repo.stash("work in progress").expect("create stash");
234        assert!(!stash_oid.is_zero());
235        let stashes = local.repo.stash_list().expect("list stashes");
236        assert_eq!(stashes[0].index, 0);
237        assert!(stashes[0].message.contains("work in progress"));
238        local.repo.stash_pop().expect("pop stash");
239        assert_eq!(
240            fs::read_to_string(local.root.join("tracked.txt")).expect("read popped file"),
241            "stashed\n"
242        );
243
244        local.write("scratch/file.txt", "remove me\n");
245        let dry_run = local
246            .repo
247            .clean(Some(&CleanOptions {
248                directories: true,
249                ..Default::default()
250            }))
251            .expect("dry-run clean");
252        assert!(dry_run.iter().any(|path| path == "scratch/"));
253        local
254            .repo
255            .clean(Some(&CleanOptions {
256                directories: true,
257                force: true,
258                ..Default::default()
259            }))
260            .expect("force clean");
261        assert!(!local.root.join("scratch").exists());
262    }
263
264    #[test]
265    fn commits_support_explicit_signatures_and_amend_without_signing() {
266        let local = LocalRepo::new("commit-options");
267        local.write("file.txt", "one\n");
268        local.repo.stage(&["file.txt"]).expect("stage file");
269        let signature = Signature {
270            name: "Before Epoch".to_string(),
271            email: "before@example.com".to_string(),
272            when: UNIX_EPOCH - std::time::Duration::from_secs(60),
273        };
274        let first = local
275            .repo
276            .commit(
277                "initial",
278                Some(&CommitOptions {
279                    author: Some(signature.clone()),
280                    committer: Some(signature),
281                    ..Default::default()
282                }),
283            )
284            .expect("commit with explicit signature");
285        assert!(!first.is_zero());
286
287        local.write("file.txt", "two\n");
288        local.repo.stage(&["file.txt"]).expect("stage amendment");
289        let amended = local
290            .repo
291            .commit(
292                "amended",
293                Some(&CommitOptions {
294                    amend: true,
295                    ..Default::default()
296                }),
297            )
298            .expect("amend commit");
299        assert_ne!(first, amended);
300
301        let signing = local
302            .repo
303            .commit(
304                "signed",
305                Some(&CommitOptions {
306                    sign: true,
307                    ..Default::default()
308                }),
309            )
310            .expect_err("signing is unsupported");
311        assert_eq!(signing.code(), rskit_errors::ErrorCode::InvalidInput);
312    }
313}