Skip to main content

stackless_git/
lib.rs

1//! Pure-Rust git operations for stackless, backed by `grit-lib`.
2//!
3//! Sole owner of the `grit-lib` dependency. Two consumers build on the
4//! primitives here:
5//!
6//! - `stackless-local` source materialization (ARCHITECTURE.md ยง8): one bare
7//!   cache repo per source URL ([`fetch_bare`]), shared across instances; per
8//!   instance a thin checkout whose objects are borrowed from the cache via
9//!   `objects/info/alternates` ([`checkout_detached`]). grit-lib's `Odb`
10//!   honors that alternates file, so instances duplicate no objects.
11//! - `stackless-vercel` / `stackless-render` cloud prepare: a self-contained
12//!   shallow clone + checkout ([`clone_checkout`]).
13//!
14//! Transport is dispatched by URL scheme: `https`/`http` over grit-lib's
15//! `ureq`-backed smart-HTTP client, and `file://`/local paths over the
16//! local object-transfer path. Credential prompting is non-interactive by
17//! design (grit-lib's helper provider never blocks on a TTY): a missing
18//! credential fails fast so callers can map it to a typed fault.
19
20use 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
37/// Initial branch written into a freshly-initialized repo's `HEAD`. Overwritten
38/// by [`checkout_detached`]; otherwise just the default ref name.
39const DEFAULT_BRANCH: &str = "main";
40
41/// Errors from the git primitives. Callers map these onto their own faults; the
42/// operation (clone/fetch/resolve/checkout) is known at the call site.
43/// Fixed author/committer for dirty snapshots โ€” content-addressed, not time-based.
44const 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/// Resolved git authentication: an optional `GITHUB_TOKEN` override for GitHub
61/// HTTPS repos, falling back to the operator's git credential helpers.
62#[derive(Clone, Debug, Default)]
63pub struct Credentials {
64    github_token: Option<SecretString>,
65}
66
67impl Credentials {
68    /// Read an optional GitHub token from the secrets map or process env.
69    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    /// Build an HTTP client wired with our credential provider, scoped to
80    /// `git_dir` so the operator's git config cascade (credential helpers from
81    /// `gh auth setup-git`, proxies, etc.) is honored.
82    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
97/// Credential provider: inject the `GITHUB_TOKEN` for GitHub HTTPS, otherwise
98/// delegate to the operator's configured git credential helpers.
99struct 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
135/// Ensure `git_dir` is an initialized bare repo, then fetch `refspecs` from
136/// `url` into it. Covers both the initial clone (a fresh bare repo) and a
137/// refresh of an existing one โ€” grit-lib's fetch copies only objects not
138/// already present, so a no-op refresh writes nothing.
139pub 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
153/// Resolve `reference` (branch, tag, or full/abbrev SHA) to a full commit hex,
154/// using objects in `git_dir` and any alternates it points at.
155pub 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
161/// Build a thin instance checkout at `dest`: a non-bare repo whose objects are
162/// borrowed from `cache_git_dir` via `objects/info/alternates`, `HEAD` detached
163/// at `commit`, and the commit's tree written into the working tree.
164///
165/// Re-materialization rebuilds from scratch: any existing checkout at `dest` is
166/// removed first (provably correct against a dirty or stale worktree, and the
167/// cache holds the objects so the rebuild copies nothing across repos).
168pub 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    // Borrow objects from the shared cache.
176    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    // Detached HEAD at the pinned commit (materialize's `observe` reads this).
183    std::fs::write(git_dir.join("HEAD"), format!("{commit}\n"))?;
184
185    // Reopen so the alternates take effect, then check the tree out.
186    checkout_tree(&git_dir, dest, commit)
187}
188
189/// Shallow clone `url` at `reference` and check it out into `dest`. Self-
190/// contained (no shared cache), mirroring `git clone --depth 1 --branch <ref>`.
191/// Snapshot a dirty working tree into instance-owned space at `dest`.
192///
193/// Tracked files (including unstaged modifications), plus untracked
194/// non-ignored files, are written as a synthetic commit whose hash is a
195/// pure content address. Gitlinks (submodules) are omitted. If `dest`
196/// already holds a snapshot at the same content hash, the working tree is
197/// left unchanged.
198pub 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/// Build a working git repo at `path` on branch `main`, creating one commit per
287/// entry in `commits` (each the full set of `(relative_path, contents)` present
288/// at that commit), and return the final HEAD commit hex.
289///
290/// Fixture support for tests that need a source repo to materialize from,
291/// without shelling out to the `git` CLI. A fixed identity and timestamp keep
292/// the resulting commit hashes deterministic. Behind the `test-support` feature
293/// so it stays out of the production surface.
294#[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    // Name <email> <unix-ts> <tz>, in git's author/committer wire format.
302    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
445/// Open the repo at `git_dir` (work tree `work_tree`), resolve `spec` to a
446/// commit, and write its tree into the working tree.
447fn 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
463/// Fetch into `git_dir`, choosing the transport from the URL scheme: local
464/// object-transfer for `file://`/paths, smart-HTTP for `http(s)://`.
465fn 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
483/// For a `file://` or bare local path, the on-disk git directory to fetch from
484/// (`<path>/.git` for a working repo, `<path>` for a bare one). `None` for any
485/// URL carrying a non-local scheme.
486fn 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        // Working repo (or absent โ€” fetch_local then errors clearly).
499        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}