1#![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}