1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use grit_lib::config::ConfigSet;
24use grit_lib::credentials::{Credential, CredentialProvider, HelperCredentialProvider};
25use grit_lib::error::Result as GritResult;
26use grit_lib::fetch::NoProgress;
27use grit_lib::repo::{self, Repository};
28use grit_lib::rev_parse::{peel_to_tree, resolve_revision_as_commit_without_index_dwim};
29use grit_lib::transfer::{self, FetchOptions};
30use grit_lib::transport::http::http_fetch;
31use grit_lib::transport::http::ureq_client::UreqHttpClient;
32use secrecy::{ExposeSecret, SecretString};
33
34const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN";
35const GITHUB_TOKEN_USER: &str = "x-access-token";
36
37const DEFAULT_BRANCH: &str = "main";
40
41const SNAPSHOT_IDENT: &str = "stackless-dirty <dirty@stackless.local> 1700000000 +0000";
45
46#[derive(Debug, thiserror::Error)]
47pub enum GitError {
48 #[error(
49 "unsupported repository URL scheme: {0} (only https/http and local/file paths are supported)"
50 )]
51 UnsupportedScheme(String),
52 #[error("{path} is not a git working tree")]
53 InvalidWorkTree { path: PathBuf },
54 #[error(transparent)]
55 Lib(#[from] grit_lib::error::Error),
56 #[error(transparent)]
57 Io(#[from] std::io::Error),
58}
59
60#[derive(Clone, Debug, Default)]
63pub struct Credentials {
64 github_token: Option<SecretString>,
65}
66
67impl Credentials {
68 pub fn from_secrets(secrets: &BTreeMap<String, String>) -> Self {
70 let raw = secrets
71 .get(GITHUB_TOKEN_ENV)
72 .cloned()
73 .or_else(|| std::env::var(GITHUB_TOKEN_ENV).ok());
74 Self {
75 github_token: raw.map(SecretString::from),
76 }
77 }
78
79 fn http_client(&self, git_dir: &Path) -> UreqHttpClient {
83 let config = ConfigSet::load(Some(git_dir), true).unwrap_or_else(|_| ConfigSet::new());
84 let provider = StacklessCredentials {
85 github_token: self.github_token.clone(),
86 helper: HelperCredentialProvider::new(config),
87 };
88 UreqHttpClient::with_credentials(Box::new(provider))
89 }
90
91 #[cfg(test)]
92 fn has_github_token(&self) -> bool {
93 self.github_token.is_some()
94 }
95}
96
97struct StacklessCredentials {
100 github_token: Option<SecretString>,
101 helper: HelperCredentialProvider,
102}
103
104impl CredentialProvider for StacklessCredentials {
105 fn fill(&self, input: &Credential) -> GritResult<Credential> {
106 if let Some(token) = &self.github_token
107 && is_github_https(input)
108 {
109 let mut cred = input.clone();
110 cred.username = Some(GITHUB_TOKEN_USER.to_owned());
111 cred.password = Some(token.expose_secret().to_owned());
112 return Ok(cred);
113 }
114 self.helper.fill(input)
115 }
116
117 fn approve(&self, cred: &Credential) -> GritResult<()> {
118 self.helper.approve(cred)
119 }
120
121 fn reject(&self, cred: &Credential) -> GritResult<()> {
122 self.helper.reject(cred)
123 }
124}
125
126fn is_github_https(cred: &Credential) -> bool {
127 let host = cred
128 .host
129 .as_deref()
130 .map(|h| h.split(':').next().unwrap_or(h));
131 cred.protocol.as_deref() == Some("https")
132 && matches!(host, Some("github.com") | Some("www.github.com"))
133}
134
135pub fn fetch_bare(
140 git_dir: &Path,
141 url: &str,
142 refspecs: &[&str],
143 depth: Option<u32>,
144 creds: &Credentials,
145) -> Result<(), GitError> {
146 if !git_dir.join("objects").is_dir() {
147 repo::init_bare_clone_minimal(git_dir, DEFAULT_BRANCH, "files")?;
148 }
149 let opts = fetch_options(refspecs, depth);
150 fetch_dispatch(git_dir, url, &opts, creds)
151}
152
153pub fn resolve_commit(git_dir: &Path, reference: &str) -> Result<String, GitError> {
156 let repo = Repository::open(git_dir, None)?;
157 let oid = resolve_revision_as_commit_without_index_dwim(&repo, reference)?;
158 Ok(oid.to_string())
159}
160
161pub fn checkout_detached(dest: &Path, cache_git_dir: &Path, commit: &str) -> Result<(), GitError> {
169 if dest.exists() {
170 std::fs::remove_dir_all(dest)?;
171 }
172 repo::init_repository(dest, false, DEFAULT_BRANCH, None, "files")?;
173 let git_dir = dest.join(".git");
174
175 let info = git_dir.join("objects/info");
177 std::fs::create_dir_all(&info)?;
178 std::fs::write(
179 info.join("alternates"),
180 format!("{}\n", cache_git_dir.join("objects").display()),
181 )?;
182 std::fs::write(git_dir.join("HEAD"), format!("{commit}\n"))?;
184
185 checkout_tree(&git_dir, dest, commit)
187}
188
189pub fn snapshot_worktree(dest: &Path, work_tree: &Path) -> Result<String, GitError> {
199 use grit_lib::index::{Index, entry_from_stat};
200 use grit_lib::objects::{CommitData, ObjectKind, serialize_commit};
201 use grit_lib::odb::Odb;
202 use grit_lib::write_tree::write_tree_from_index;
203
204 let work_tree = work_tree.canonicalize()?;
205 if !work_tree.join(".git").exists() {
206 return Err(GitError::InvalidWorkTree {
207 path: work_tree.clone(),
208 });
209 }
210
211 let src_repo = Repository::discover(Some(&work_tree))?;
212 let src_index = match Index::load(&src_repo.index_path()) {
213 Ok(index) => index,
214 Err(grit_lib::error::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
215 Index::new()
216 }
217 Err(err) => return Err(GitError::Lib(err)),
218 };
219
220 let files = collect_dirty_files(&src_repo, &src_index, &work_tree)?;
221
222 if !dest.join(".git").is_dir() {
223 repo::init_repository(dest, false, DEFAULT_BRANCH, None, "files")?;
224 }
225 let dest_git = dest.join(".git");
226 let dest_repo = Repository::open(&dest_git, Some(dest))?;
227 let dest_odb = Odb::new(&dest_git.join("objects"));
228
229 let old_tree = read_head_tree(&dest_repo)?;
230
231 let mut snap_index = Index::new();
232 for file in &files {
233 let bytes = read_worktree_bytes(&file.abs, file.mode)?;
234 let oid = dest_odb.write(ObjectKind::Blob, &bytes)?;
235 snap_index.add_or_replace(entry_from_stat(
236 &file.abs,
237 file.rel.as_bytes(),
238 oid,
239 file.mode,
240 )?);
241 }
242 snap_index.sort();
243 let tree = write_tree_from_index(&dest_odb, &snap_index, "")?;
244
245 let commit = CommitData {
246 tree,
247 parents: Vec::new(),
248 author: SNAPSHOT_IDENT.to_owned(),
249 committer: SNAPSHOT_IDENT.to_owned(),
250 author_raw: Vec::new(),
251 committer_raw: Vec::new(),
252 encoding: None,
253 message: "stackless dirty snapshot\n".into(),
254 raw_message: None,
255 };
256 let commit_oid = dest_odb.write(ObjectKind::Commit, &serialize_commit(&commit))?;
257 let commit_hex = commit_oid.to_string();
258
259 if read_detached_head(&dest_git)? == Some(commit_hex.clone()) {
260 return Ok(commit_hex);
261 }
262
263 let dest_repo = Repository::open(&dest_git, Some(dest))?;
264 grit_lib::porcelain::checkout::checkout_between_trees(&dest_repo, old_tree.as_ref(), &tree)?;
265 std::fs::write(dest_git.join("HEAD"), format!("{commit_hex}\n"))?;
266 Ok(commit_hex)
267}
268
269pub fn clone_checkout(
270 url: &str,
271 reference: &str,
272 dest: &Path,
273 creds: &Credentials,
274) -> Result<(), GitError> {
275 repo::init_repository(dest, false, reference, None, "files")?;
276 let git_dir = dest.join(".git");
277 let refspecs = [
278 format!("+refs/heads/{reference}:refs/heads/{reference}"),
279 format!("+refs/tags/{reference}:refs/tags/{reference}"),
280 ];
281 let opts = fetch_options(&refspecs, Some(1));
282 fetch_dispatch(&git_dir, url, &opts, creds)?;
283 checkout_tree(&git_dir, dest, reference)
284}
285
286#[cfg(feature = "test-support")]
295pub fn build_repo(path: &Path, commits: &[&[(&str, &str)]]) -> Result<String, GitError> {
296 use grit_lib::index::{Index, MODE_REGULAR, entry_from_stat};
297 use grit_lib::objects::{CommitData, ObjectId, ObjectKind, serialize_commit};
298 use grit_lib::odb::Odb;
299 use grit_lib::write_tree::write_tree_from_index;
300
301 const IDENT: &str = "stackless-test <test@stackless.local> 1700000000 +0000";
303
304 repo::init_repository(path, false, DEFAULT_BRANCH, None, "files")?;
305 let git_dir = path.join(".git");
306 let odb = Odb::new(&git_dir.join("objects"));
307
308 let mut parent: Option<ObjectId> = None;
309 let mut head = ObjectId::zero();
310 for (i, files) in commits.iter().enumerate() {
311 let mut index = Index::new();
312 for (rel, contents) in files.iter() {
313 let abs = path.join(rel);
314 if let Some(dir) = abs.parent() {
315 std::fs::create_dir_all(dir)?;
316 }
317 std::fs::write(&abs, contents)?;
318 let oid = odb.write(ObjectKind::Blob, contents.as_bytes())?;
319 index.add_or_replace(entry_from_stat(&abs, rel.as_bytes(), oid, MODE_REGULAR)?);
320 }
321 index.sort();
322 let tree = write_tree_from_index(&odb, &index, "")?;
323 let commit = CommitData {
324 tree,
325 parents: parent.into_iter().collect(),
326 author: IDENT.to_owned(),
327 committer: IDENT.to_owned(),
328 author_raw: Vec::new(),
329 committer_raw: Vec::new(),
330 encoding: None,
331 message: format!("commit {}\n", i + 1),
332 raw_message: None,
333 };
334 head = odb.write(ObjectKind::Commit, &serialize_commit(&commit))?;
335 parent = Some(head);
336 }
337 grit_lib::refs::write_ref(&git_dir, "refs/heads/main", &head)?;
338 Ok(head.to_string())
339}
340
341struct DirtyFile {
342 rel: String,
343 abs: PathBuf,
344 mode: u32,
345}
346
347fn collect_dirty_files(
348 repo: &Repository,
349 index: &grit_lib::index::Index,
350 work_tree: &Path,
351) -> Result<Vec<DirtyFile>, GitError> {
352 use grit_lib::index::{MODE_GITLINK, MODE_REGULAR};
353 use grit_lib::porcelain::status::{IgnoredMode, collect_untracked_and_ignored};
354 use std::collections::BTreeMap;
355
356 let mut files: BTreeMap<String, DirtyFile> = BTreeMap::new();
357
358 for entry in &index.entries {
359 if entry.stage() != 0 || entry.mode == MODE_GITLINK {
360 continue;
361 }
362 let rel = String::from_utf8_lossy(&entry.path).to_string();
363 let abs = work_tree.join(&rel);
364 if std::fs::symlink_metadata(&abs).is_err() {
365 continue;
366 }
367 let mode = mode_from_path(&abs, entry.mode)?;
368 files.insert(rel.clone(), DirtyFile { rel, abs, mode });
369 }
370
371 let (untracked, _) =
372 collect_untracked_and_ignored(repo, index, work_tree, IgnoredMode::No, true, &[])?;
373 for rel in untracked {
374 let abs = work_tree.join(&rel);
375 let Ok(meta) = std::fs::symlink_metadata(&abs) else {
376 continue;
377 };
378 if meta.is_dir() {
379 continue;
380 }
381 let mode = mode_from_path(&abs, MODE_REGULAR)?;
382 files.insert(rel.clone(), DirtyFile { rel, abs, mode });
383 }
384
385 Ok(files.into_values().collect())
386}
387
388fn mode_from_path(abs: &Path, index_mode: u32) -> Result<u32, GitError> {
389 use grit_lib::index::{MODE_SYMLINK, normalize_mode};
390
391 if index_mode == MODE_SYMLINK {
392 return Ok(MODE_SYMLINK);
393 }
394 let meta = std::fs::symlink_metadata(abs)?;
395 #[cfg(unix)]
396 {
397 use std::os::unix::fs::MetadataExt;
398 Ok(normalize_mode(meta.mode()))
399 }
400 #[cfg(not(unix))]
401 {
402 use grit_lib::index::{MODE_EXECUTABLE, MODE_REGULAR};
403 if meta.file_type().is_symlink() {
404 Ok(MODE_SYMLINK)
405 } else if meta.permissions().readonly() {
406 Ok(MODE_REGULAR)
407 } else {
408 Ok(MODE_EXECUTABLE)
409 }
410 }
411}
412
413fn read_worktree_bytes(abs: &Path, mode: u32) -> Result<Vec<u8>, GitError> {
414 use grit_lib::index::MODE_SYMLINK;
415
416 if mode == MODE_SYMLINK {
417 let target = std::fs::read_link(abs)?;
418 return Ok(target.as_os_str().as_encoded_bytes().to_vec());
419 }
420 Ok(std::fs::read(abs)?)
421}
422
423fn read_detached_head(git_dir: &Path) -> Result<Option<String>, GitError> {
424 let head = match std::fs::read_to_string(git_dir.join("HEAD")) {
425 Ok(text) => text,
426 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
427 Err(err) => return Err(GitError::Io(err)),
428 };
429 let trimmed = head.trim();
430 if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
431 Ok(Some(trimmed.to_owned()))
432 } else {
433 Ok(None)
434 }
435}
436
437fn read_head_tree(repo: &Repository) -> Result<Option<grit_lib::objects::ObjectId>, GitError> {
438 let Some(head) = read_detached_head(&repo.git_dir)? else {
439 return Ok(None);
440 };
441 let commit = resolve_revision_as_commit_without_index_dwim(repo, &head)?;
442 Ok(Some(peel_to_tree(repo, commit)?))
443}
444
445fn checkout_tree(git_dir: &Path, work_tree: &Path, spec: &str) -> Result<(), GitError> {
448 let repo = Repository::open(git_dir, Some(work_tree))?;
449 let commit = resolve_revision_as_commit_without_index_dwim(&repo, spec)?;
450 let tree = peel_to_tree(&repo, commit)?;
451 grit_lib::porcelain::checkout::checkout_between_trees(&repo, None, &tree)?;
452 Ok(())
453}
454
455fn fetch_options<S: AsRef<str>>(refspecs: &[S], depth: Option<u32>) -> FetchOptions {
456 FetchOptions {
457 refspecs: refspecs.iter().map(|s| s.as_ref().to_owned()).collect(),
458 depth,
459 ..FetchOptions::default()
460 }
461}
462
463fn fetch_dispatch(
466 git_dir: &Path,
467 url: &str,
468 opts: &FetchOptions,
469 creds: &Credentials,
470) -> Result<(), GitError> {
471 if let Some(remote_git_dir) = local_repo_path(url) {
472 transfer::fetch_local(git_dir, &remote_git_dir, opts)?;
473 return Ok(());
474 }
475 if url.starts_with("https://") || url.starts_with("http://") {
476 let client = creds.http_client(git_dir);
477 http_fetch(&client, git_dir, url, opts, &mut NoProgress)?;
478 return Ok(());
479 }
480 Err(GitError::UnsupportedScheme(url.to_owned()))
481}
482
483fn local_repo_path(url: &str) -> Option<PathBuf> {
487 let raw = if let Some(rest) = url.strip_prefix("file://") {
488 rest
489 } else if url.contains("://") {
490 return None;
491 } else {
492 url
493 };
494 let path = Path::new(raw);
495 if path.join("objects").is_dir() {
496 Some(path.to_path_buf())
497 } else {
498 Some(path.join(".git"))
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 fn cred(protocol: &str, host: &str) -> Credential {
508 Credential {
509 protocol: Some(protocol.to_owned()),
510 host: Some(host.to_owned()),
511 ..Credential::parse("")
512 }
513 }
514
515 #[test]
516 fn wraps_token_from_secrets_map() {
517 let mut secrets = BTreeMap::new();
518 secrets.insert(GITHUB_TOKEN_ENV.to_owned(), "ghp_test".to_owned());
519 assert!(Credentials::from_secrets(&secrets).has_github_token());
520 }
521
522 #[test]
523 fn github_https_host_detection() {
524 assert!(is_github_https(&cred("https", "github.com")));
525 assert!(is_github_https(&cred("https", "www.github.com")));
526 assert!(is_github_https(&cred("https", "github.com:443")));
527 assert!(!is_github_https(&cred("https", "gitlab.com")));
528 assert!(!is_github_https(&cred("http", "github.com")));
529 }
530
531 #[test]
532 fn non_local_scheme_has_no_local_path() {
533 assert!(local_repo_path("https://github.com/o/r").is_none());
534 assert!(local_repo_path("ssh://git@host/r").is_none());
535 assert_eq!(
536 local_repo_path("file:///tmp/x"),
537 Some(PathBuf::from("/tmp/x/.git"))
538 );
539 }
540}