commit 909f9a264cd944a07663229da8c7a3bcea3b8669 from: Murilo Ijanc date: Fri Apr 24 22:28:12 2026 UTC initial import of marmita, a single-file dependency manager marmita vendors a single source file from a remote git repository at a pinned commit and records the result in a plain-text vendor/VENDOR manifest. Subcommands are add, update, rm and list. All git operations go through libgit2 via an FFI binding declared inline in marmita.rs; SSH credentials come from the running agent or, as a fallback, from the default identity files under ~/.ssh. commit - /dev/null commit + 909f9a264cd944a07663229da8c7a3bcea3b8669 blob - /dev/null blob + 378eac25d311703f3f2cd456d8036da525cd0366 (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1 @@ +build blob - /dev/null blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644) --- /dev/null +++ .rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 blob - /dev/null blob + 32f3292e31bbd5acf75b7671b679e9017828078c (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2026 Murilo Ijanc' + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. blob - /dev/null blob + 4a765bf4dd432492a6f3f3baefb1147ec711d754 (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,59 @@ +# +# Copyright (c) 2026 Murilo Ijanc' +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +RUSTC ?= $(shell rustup which rustc 2>/dev/null || which rustc) +RUSTFLAGS ?= -C opt-level=2 -C strip=symbols +VERSION = 0.1.0 +PREFIX ?= /usr/local +MANDIR ?= $(PREFIX)/share/man + +BUILD = build +BIN = $(BUILD)/marmita +MAIN = marmita.rs + +CLIPPY ?= $(shell rustup which clippy-driver 2>/dev/null) +RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null) + +.PHONY: all clean install ci fmt-check clippy + +all: $(BIN) + +$(BIN): $(MAIN) + mkdir -p $(BUILD) + MARMITA_VERSION=$(VERSION) TMPDIR=/tmp $(RUSTC) --edition 2024 \ + --crate-type bin --crate-name marmita $(RUSTFLAGS) \ + -C link-arg=-lgit2 \ + -o $@ $< + +clean: + rm -rf $(BUILD) + +install: $(BIN) + install -d $(PREFIX)/bin $(MANDIR)/man1 + install -m 755 $(BIN) $(PREFIX)/bin/marmita + install -m 644 marmita.1 $(MANDIR)/man1/marmita.1 + +fmt-check: + $(RUSTFMT) --edition 2024 --check $(MAIN) + +clippy: + MARMITA_VERSION=$(VERSION) TMPDIR=/tmp $(CLIPPY) --edition 2024 \ + --crate-type bin --crate-name marmita \ + -C link-arg=-lgit2 \ + -W clippy::all -o /tmp/marmita.clippy $(MAIN) + @rm -f /tmp/marmita.clippy + +ci: fmt-check clippy $(BIN) blob - /dev/null blob + 008db1e0e2be7b37b6a8589a7f9c04b3b4879956 (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,83 @@ +marmita - vendor single-file source dependencies from git +========================================================== +marmita is a minimal binary that vendors a single source file +from a remote git repository at a pinned commit, recording the +result in a plain-text manifest. + + +Requirements +------------ +In order to build marmita you need rustc and libgit2. + + +Installation +------------ +Edit Makefile to match your local setup (marmita is installed +into the /usr/local/bin namespace by default). + +Afterwards enter the following command to build and install +marmita: + + make clean install + + +Running marmita +--------------- +Add a dependency at the latest commit: + + marmita add ssh://ijanc@ijanc.org/json/jackson + +Pin to a tag or branch: + + marmita add -r v1.3.0 ssh://ijanc@ijanc.org/json/jackson + +Refresh every dependency: + + marmita update + +Refresh one dependency: + + marmita update http.rs + +Remove a dependency: + + marmita rm http.rs + +List every recorded dependency: + + marmita list + +Print the version: + + marmita -V + + +Manifest +-------- +marmita stores its bookkeeping in vendor/VENDOR. Each entry is +a header line with the file name followed by tab-indented +attribute lines, separated by blank lines: + + jackson.rs + origin: ssh://ijanc@ijanc.org/json/jackson + ref: v1.3.0 + commit: a4e84f6d2f21d8a5f28d17a38ed9968251325714 + date: 2026-04-18 + +The commit field is the source of truth for what is vendored. +The ref field is optional and only present when add was given +a tag or branch with -r. + +The manifest is plain text and may be edited by hand; running +marmita update afterwards is the recommended way to apply the +changes to the working copy. + + +Download +-------- + got clone ssh://ijanc@ijanc.org/marmita + + +License +------- +ISC -- see LICENSE. blob - /dev/null blob + 6e01cb8f3bf9e634986dbd6be1ad1d01233fd424 (mode 644) --- /dev/null +++ marmita.1 @@ -0,0 +1,189 @@ +.\" +.\" Copyright (c) 2026 Murilo Ijanc' +.\" +.\" Permission to use, copy, modify, and/or distribute this software for any +.\" purpose with or without fee is hereby granted, provided that the above +.\" copyright notice and this permission notice appear in all copies. +.\" +.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +.\" +.Dd $Mdocdate: April 24 2026 $ +.Dt MARMITA 1 +.Os +.Sh NAME +.Nm marmita +.Nd vendor single-file source dependencies from git +.Sh SYNOPSIS +.Nm marmita +.Fl V +.Nm marmita +.Cm add +.Op Fl r Ar ref +.Ar url +.Op Ar file +.Nm marmita +.Cm update +.Op Ar file +.Nm marmita +.Cm rm +.Ar file +.Nm marmita +.Cm list +.Sh DESCRIPTION +.Nm +manages single-file source dependencies vendored from remote git +repositories. +Each dependency is one source file copied into +.Pa vendor/ +at a pinned commit, recorded in a plain-text +.Pa vendor/VENDOR +manifest. +.Pp +.Nm +operates on the +.Pa vendor/ +directory of the current working directory and uses +.Xr libgit2 3 +for all clone and object lookup operations. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl V +Print the version and exit. +.El +.Pp +The commands are as follows: +.Bl -tag -width Ds +.It Cm add Op Fl r Ar ref Ar url Op Ar file +Clone +.Ar url +into a temporary directory, resolve +.Ar ref +.Pq default Li HEAD +to a commit, copy +.Ar file +from that commit into +.Pa vendor/ , +and record an entry in +.Pa vendor/VENDOR . +.Pp +If +.Ar file +is omitted, it is deduced from the last path component of +.Ar url +with a +.Li .rs +suffix +.Pq for example Li ssh://host/path/jackson No becomes Li jackson.rs . +.Pp +.Ar ref +may be a tag, branch name, or commit object id. +When a tag or branch is given it is recorded in the manifest as +.Li ref: +alongside the resolved +.Li commit: +oid; raw object ids are recorded only as +.Li commit: . +.It Cm update Op Ar file +Refresh one entry, or every entry when +.Ar file +is omitted. +For each entry, +.Nm +re-clones the +.Li origin: , +re-resolves the recorded +.Li ref: +.Pq or Li HEAD No when no ref is recorded , +overwrites the local file with the new content, and updates +.Li commit: +and +.Li date: . +Entries pinned to a raw commit object id are skipped. +.It Cm rm Ar file +Remove +.Ar file +from +.Pa vendor/ +and delete the matching entry from +.Pa vendor/VENDOR . +.It Cm list +Print every entry in +.Pa vendor/VENDOR +to standard output, one per line. +.El +.Sh MANIFEST +.Pa vendor/VENDOR +is a plain-text manifest. +Each entry is a header line containing the file name followed by +tab-indented +.Li key:\etvalue +attribute lines. +Entries are separated by blank lines: +.Bd -literal -offset indent +jackson.rs + origin: ssh://ijanc@ijanc.org/json/jackson + ref: v1.3.0 + commit: a4e84f6d2f21d8a5f28d17a38ed9968251325714 + date: 2026-04-18 + +http.rs + origin: ssh://ijanc@ijanc.org/http + commit: ebd3a89fa7acc0d377dff1cb734a8ab2282153d5 + date: 2026-04-24 +.Ed +.Pp +The +.Li commit: +field is the source of truth for what is vendored. +The +.Li ref: +field is optional and only present when +.Cm add +was invoked with a tag or branch via +.Fl r ; +it is read by +.Cm update +to know what to follow on the remote. +.Sh EXIT STATUS +.Nm +exits 0 on success and non-zero on failure. +A diagnostic message is written to standard error. +.Sh EXAMPLES +Add the +.Li jackson.rs +library at the latest commit on the default branch: +.Bd -literal -offset indent +$ marmita add ssh://ijanc@ijanc.org/json/jackson +.Ed +.Pp +Pin to a specific tag: +.Bd -literal -offset indent +$ marmita add -r v1.3.0 ssh://ijanc@ijanc.org/json/jackson +.Ed +.Pp +Refresh every dependency: +.Bd -literal -offset indent +$ marmita update +.Ed +.Pp +Refresh just one: +.Bd -literal -offset indent +$ marmita update http.rs +.Ed +.Pp +Remove a dependency: +.Bd -literal -offset indent +$ marmita rm http.rs +.Ed +.Sh SEE ALSO +.Xr git 1 , +.Xr libgit2 3 +.Sh AUTHORS +.An Murilo Ijanc' Aq Mt murilo@ijanc.org blob - /dev/null blob + 3052036d179adf94e91e509edee5c4300de5ebb2 (mode 644) --- /dev/null +++ marmita.rs @@ -0,0 +1,785 @@ +// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et : +// +// Copyright (c) 2026 Murilo Ijanc' +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +use std::{ + env, + ffi::{CStr, CString}, + fs, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + process, ptr, slice, + time::{SystemTime, UNIX_EPOCH}, +}; + +const VENDOR_DIR: &str = "vendor"; +const VENDOR_FILE: &str = "vendor/VENDOR"; +const TRAILER: &str = + "Managed by marmita(1). Use 'marmita update' to refresh.\n"; + +////////////////////////////////////////////////////////////////////////////// +// FFI (libgit2) +////////////////////////////////////////////////////////////////////////////// + +#[allow(non_camel_case_types)] +mod ffi { + use std::os::raw::{c_char, c_int, c_uchar, c_uint, c_void}; + + pub const GIT_OID_RAWSZ: usize = 20; + pub const GIT_OBJECT_BLOB: c_int = 3; + + pub const GIT_CLONE_OPTIONS_VERSION: c_uint = 1; + + pub const GIT_PASSTHROUGH: c_int = -30; + pub const GIT_CREDENTIAL_SSH_KEY: c_uint = 1 << 1; + pub const GIT_CREDENTIAL_USERNAME: c_uint = 1 << 5; + + #[repr(C)] + pub struct git_oid { + pub id: [c_uchar; GIT_OID_RAWSZ], + } + + pub enum git_repository {} + pub enum git_object {} + pub enum git_blob {} + pub enum git_credential {} + + #[repr(C)] + pub struct git_error { + pub message: *const c_char, + pub klass: c_int, + } + + #[repr(C)] + pub struct git_strarray { + pub strings: *mut *mut c_char, + pub count: usize, + } + + // Layout taken from libgit2 1.9 git2/checkout.h. All callback fields + // are typed as raw pointers since marmita never sets them. + #[repr(C)] + pub struct git_checkout_options { + pub version: c_uint, + pub checkout_strategy: c_uint, + pub disable_filters: c_int, + pub dir_mode: c_uint, + pub file_mode: c_uint, + pub file_open_flags: c_int, + pub notify_flags: c_uint, + pub notify_cb: *mut c_void, + pub notify_payload: *mut c_void, + pub progress_cb: *mut c_void, + pub progress_payload: *mut c_void, + pub paths: git_strarray, + pub baseline: *mut c_void, + pub baseline_index: *mut c_void, + pub target_directory: *const c_char, + pub ancestor_label: *const c_char, + pub our_label: *const c_char, + pub their_label: *const c_char, + pub perfdata_cb: *mut c_void, + pub perfdata_payload: *mut c_void, + } + + pub type git_credential_acquire_cb = unsafe extern "C" fn( + out: *mut *mut git_credential, + url: *const c_char, + username_from_url: *const c_char, + allowed_types: c_uint, + payload: *mut c_void, + ) -> c_int; + + // Layout taken from libgit2 1.9 git2/remote.h. The deprecated + // update_tips and resolve_url fields are present because the system + // libgit2 is not built with GIT_DEPRECATE_HARD. + #[repr(C)] + pub struct git_remote_callbacks { + pub version: c_uint, + pub sideband_progress: *mut c_void, + pub completion: *mut c_void, + pub credentials: Option, + pub certificate_check: *mut c_void, + pub transfer_progress: *mut c_void, + pub update_tips: *mut c_void, + pub pack_progress: *mut c_void, + pub push_transfer_progress: *mut c_void, + pub push_update_reference: *mut c_void, + pub push_negotiation: *mut c_void, + pub transport: *mut c_void, + pub remote_ready: *mut c_void, + pub payload: *mut c_void, + pub resolve_url: *mut c_void, + pub update_refs: *mut c_void, + } + + #[repr(C)] + pub struct git_proxy_options { + pub version: c_uint, + pub typ: c_int, + pub url: *const c_char, + pub credentials: *mut c_void, + pub certificate_check: *mut c_void, + pub payload: *mut c_void, + } + + #[repr(C)] + pub struct git_fetch_options { + pub version: c_int, + pub callbacks: git_remote_callbacks, + pub prune: c_int, + pub update_fetchhead: c_uint, + pub download_tags: c_int, + pub proxy_opts: git_proxy_options, + pub depth: c_int, + pub follow_redirects: c_int, + pub custom_headers: git_strarray, + } + + #[repr(C)] + pub struct git_clone_options { + pub version: c_uint, + pub checkout_opts: git_checkout_options, + pub fetch_opts: git_fetch_options, + pub bare: c_int, + pub local: c_int, + pub checkout_branch: *const c_char, + pub repository_cb: *mut c_void, + pub repository_cb_payload: *mut c_void, + pub remote_cb: *mut c_void, + pub remote_cb_payload: *mut c_void, + } + + #[link(name = "git2")] + unsafe extern "C" { + pub fn git_libgit2_init() -> c_int; + pub fn git_libgit2_shutdown() -> c_int; + + pub fn git_clone( + out: *mut *mut git_repository, + url: *const c_char, + path: *const c_char, + opts: *const git_clone_options, + ) -> c_int; + pub fn git_clone_options_init( + opts: *mut git_clone_options, + version: c_uint, + ) -> c_int; + pub fn git_repository_free(repo: *mut git_repository); + + pub fn git_revparse_single( + out: *mut *mut git_object, + repo: *mut git_repository, + spec: *const c_char, + ) -> c_int; + pub fn git_object_id(obj: *const git_object) -> *const git_oid; + pub fn git_object_type(obj: *const git_object) -> c_int; + pub fn git_object_free(obj: *mut git_object); + + pub fn git_oid_tostr( + out: *mut c_char, + n: usize, + oid: *const git_oid, + ) -> *const c_char; + + pub fn git_blob_rawcontent(blob: *const git_blob) -> *const c_void; + pub fn git_blob_rawsize(blob: *const git_blob) -> i64; + + pub fn git_credential_ssh_key_from_agent( + out: *mut *mut git_credential, + username: *const c_char, + ) -> c_int; + pub fn git_credential_ssh_key_new( + out: *mut *mut git_credential, + username: *const c_char, + publickey: *const c_char, + privatekey: *const c_char, + passphrase: *const c_char, + ) -> c_int; + pub fn git_credential_username_new( + out: *mut *mut git_credential, + username: *const c_char, + ) -> c_int; + + pub fn git_error_last() -> *const git_error; + } +} + +////////////////////////////////////////////////////////////////////////////// +// libgit2 wrapper +////////////////////////////////////////////////////////////////////////////// + +struct Repo(*mut ffi::git_repository); + +impl Drop for Repo { + fn drop(&mut self) { + unsafe { ffi::git_repository_free(self.0) }; + } +} + +fn git_init() -> Result<(), String> { + let rc = unsafe { ffi::git_libgit2_init() }; + if rc < 0 { + return Err(format!("libgit2 init: {}", last_err())); + } + Ok(()) +} + +fn git_shutdown() { + unsafe { ffi::git_libgit2_shutdown() }; +} + +fn last_err() -> String { + unsafe { + let e = ffi::git_error_last(); + if e.is_null() || (*e).message.is_null() { + return "unknown libgit2 error".to_string(); + } + CStr::from_ptr((*e).message).to_string_lossy().into_owned() + } +} + +fn cstring(s: &str) -> Result { + CString::new(s).map_err(|e| e.to_string()) +} + +fn cstring_path(p: &Path) -> Result { + CString::new(p.as_os_str().as_bytes()).map_err(|e| e.to_string()) +} + +struct CredState { + attempt: i32, + keys: Vec, +} + +unsafe extern "C" fn cred_cb( + out: *mut *mut ffi::git_credential, + _url: *const std::os::raw::c_char, + username_from_url: *const std::os::raw::c_char, + allowed_types: std::os::raw::c_uint, + payload: *mut std::os::raw::c_void, +) -> std::os::raw::c_int { + if allowed_types & ffi::GIT_CREDENTIAL_USERNAME != 0 { + return unsafe { + ffi::git_credential_username_new(out, username_from_url) + }; + } + if allowed_types & ffi::GIT_CREDENTIAL_SSH_KEY == 0 { + return ffi::GIT_PASSTHROUGH; + } + let state = unsafe { &mut *(payload as *mut CredState) }; + let attempt = state.attempt; + state.attempt += 1; + if attempt == 0 { + return unsafe { + ffi::git_credential_ssh_key_from_agent(out, username_from_url) + }; + } + let idx = (attempt - 1) as usize; + if idx >= state.keys.len() { + return -1; + } + unsafe { + ffi::git_credential_ssh_key_new( + out, + username_from_url, + ptr::null(), + state.keys[idx].as_ptr(), + ptr::null(), + ) + } +} + +fn ssh_keyfiles() -> Vec { + let home = match env::var("HOME") { + Ok(h) => h, + Err(_) => return Vec::new(), + }; + ["id_ed25519", "id_rsa", "id_ecdsa"] + .iter() + .filter_map(|name| { + let path = format!("{home}/.ssh/{name}"); + if Path::new(&path).exists() { + CString::new(path).ok() + } else { + None + } + }) + .collect() +} + +fn clone(url: &str, path: &Path) -> Result { + let url_c = cstring(url)?; + let path_c = cstring_path(path)?; + let mut opts: ffi::git_clone_options = unsafe { std::mem::zeroed() }; + let rc = unsafe { + ffi::git_clone_options_init(&mut opts, ffi::GIT_CLONE_OPTIONS_VERSION) + }; + if rc != 0 { + return Err(format!("clone init: {}", last_err())); + } + let mut state = CredState { + attempt: 0, + keys: ssh_keyfiles(), + }; + opts.fetch_opts.callbacks.payload = + &mut state as *mut _ as *mut std::os::raw::c_void; + opts.fetch_opts.callbacks.credentials = Some(cred_cb); + let mut repo: *mut ffi::git_repository = ptr::null_mut(); + let rc = unsafe { + ffi::git_clone(&mut repo, url_c.as_ptr(), path_c.as_ptr(), &opts) + }; + if rc != 0 { + return Err(format!("clone {url}: {}", last_err())); + } + Ok(Repo(repo)) +} + +fn resolve_commit(repo: &Repo, spec: &str) -> Result { + let peeled = format!("{spec}^{{commit}}"); + let s_c = cstring(&peeled)?; + let mut obj: *mut ffi::git_object = ptr::null_mut(); + let rc = + unsafe { ffi::git_revparse_single(&mut obj, repo.0, s_c.as_ptr()) }; + if rc != 0 { + return Err(format!("resolve {spec}: {}", last_err())); + } + let oid = unsafe { ffi::git_object_id(obj) }; + let mut buf = [0u8; 41]; + unsafe { + ffi::git_oid_tostr(buf.as_mut_ptr() as *mut i8, buf.len(), oid); + ffi::git_object_free(obj); + } + let nul = buf.iter().position(|&b| b == 0).unwrap_or(40); + std::str::from_utf8(&buf[..nul]) + .map(|s| s.to_string()) + .map_err(|e| e.to_string()) +} + +fn read_blob(repo: &Repo, commit: &str, file: &str) -> Result, String> { + let spec = format!("{commit}:{file}"); + let s_c = cstring(&spec)?; + let mut obj: *mut ffi::git_object = ptr::null_mut(); + let rc = + unsafe { ffi::git_revparse_single(&mut obj, repo.0, s_c.as_ptr()) }; + if rc != 0 { + return Err(format!("lookup {file}: {}", last_err())); + } + let ty = unsafe { ffi::git_object_type(obj) }; + if ty != ffi::GIT_OBJECT_BLOB { + unsafe { ffi::git_object_free(obj) }; + return Err(format!("{file} is not a regular file at {commit}")); + } + let blob = obj as *mut ffi::git_blob; + let size = unsafe { ffi::git_blob_rawsize(blob) } as usize; + let data = unsafe { ffi::git_blob_rawcontent(blob) } as *const u8; + let bytes = unsafe { slice::from_raw_parts(data, size) }.to_vec(); + unsafe { ffi::git_object_free(obj) }; + Ok(bytes) +} + +////////////////////////////////////////////////////////////////////////////// +// VENDOR manifest +////////////////////////////////////////////////////////////////////////////// + +#[derive(Clone)] +struct Entry { + file: String, + origin: String, + reference: Option, + commit: String, + date: String, +} + +fn parse_vendor(text: &str) -> Result, String> { + let mut out = Vec::new(); + let mut cur: Option = None; + for (n, raw) in text.lines().enumerate() { + let lineno = n + 1; + if raw.trim().is_empty() { + if let Some(e) = cur.take() { + push_entry(&mut out, e, lineno)?; + } + continue; + } + if !raw.starts_with(|c: char| c.is_whitespace()) { + // Header line. Anything that does not look like an entry + // header (e.g. trailing prose) ends the manifest body. + if let Some(e) = cur.take() { + push_entry(&mut out, e, lineno)?; + } + let name = raw.trim(); + if name.contains(char::is_whitespace) { + // Free-form line; stop parsing entries here. + break; + } + cur = Some(Entry { + file: name.to_string(), + origin: String::new(), + reference: None, + commit: String::new(), + date: String::new(), + }); + continue; + } + let line = raw.trim_start(); + let (key, val) = match line.split_once(':') { + Some((k, v)) => (k.trim(), v.trim()), + None => { + return Err(format!( + "{VENDOR_FILE}:{lineno}: expected 'key: value'" + )); + } + }; + let e = cur.as_mut().ok_or_else(|| { + format!("{VENDOR_FILE}:{lineno}: attribute outside entry") + })?; + match key { + "origin" => e.origin = val.to_string(), + "ref" => e.reference = Some(val.to_string()), + "commit" => e.commit = val.to_string(), + "date" => e.date = val.to_string(), + _ => { + return Err(format!( + "{VENDOR_FILE}:{lineno}: unknown attribute '{key}'" + )); + } + } + } + if let Some(e) = cur.take() { + push_entry(&mut out, e, 0)?; + } + Ok(out) +} + +fn push_entry( + out: &mut Vec, + e: Entry, + lineno: usize, +) -> Result<(), String> { + if e.origin.is_empty() || e.commit.is_empty() || e.date.is_empty() { + return Err(format!( + "{}:{}: entry '{}' is missing origin/commit/date", + VENDOR_FILE, lineno, e.file + )); + } + out.push(e); + Ok(()) +} + +fn format_vendor(entries: &[Entry]) -> String { + let mut s = String::new(); + for e in entries { + s.push_str(&e.file); + s.push('\n'); + s.push_str(&format!("\torigin:\t{}\n", e.origin)); + if let Some(r) = &e.reference { + s.push_str(&format!("\tref:\t{r}\n")); + } + s.push_str(&format!("\tcommit:\t{}\n", e.commit)); + s.push_str(&format!("\tdate:\t{}\n", e.date)); + s.push('\n'); + } + s.push_str(TRAILER); + s +} + +fn read_manifest() -> Result, String> { + match fs::read_to_string(VENDOR_FILE) { + Ok(t) => parse_vendor(&t), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(format!("read {VENDOR_FILE}: {e}")), + } +} + +fn write_manifest(entries: &mut [Entry]) -> Result<(), String> { + entries.sort_by(|a, b| a.file.cmp(&b.file)); + fs::create_dir_all(VENDOR_DIR) + .map_err(|e| format!("mkdir {VENDOR_DIR}: {e}"))?; + fs::write(VENDOR_FILE, format_vendor(entries)) + .map_err(|e| format!("write {VENDOR_FILE}: {e}")) +} + +////////////////////////////////////////////////////////////////////////////// +// Helpers +////////////////////////////////////////////////////////////////////////////// + +fn deduce_filename(url: &str) -> String { + let last = url.rsplit(['/', ':']).next().unwrap_or(url); + let stem = last.strip_suffix(".git").unwrap_or(last); + format!("{stem}.rs") +} + +fn looks_like_oid(s: &str) -> bool { + s.len() >= 7 && s.len() <= 40 && s.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn today_utc() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + let days = secs.div_euclid(86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}") +} + +// Howard Hinnant's days_from_civil inverse. See +// https://howardhinnant.github.io/date_algorithms.html#civil_from_days +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +fn make_tempdir() -> Result { + let pid = process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let p = env::temp_dir().join(format!("marmita.{pid}.{nanos}")); + if p.exists() { + fs::remove_dir_all(&p) + .map_err(|e| format!("clean tempdir {p:?}: {e}"))?; + } + Ok(p) +} + +fn fetch_file( + url: &str, + spec: &str, + file: &str, +) -> Result<(String, Vec), String> { + let tmp = make_tempdir()?; + let result = (|| { + let repo = clone(url, &tmp)?; + let commit = resolve_commit(&repo, spec)?; + let bytes = read_blob(&repo, &commit, file)?; + Ok((commit, bytes)) + })(); + let _ = fs::remove_dir_all(&tmp); + result +} + +////////////////////////////////////////////////////////////////////////////// +// Commands +////////////////////////////////////////////////////////////////////////////// + +fn cmd_add(args: &[String]) -> Result<(), String> { + let mut url: Option = None; + let mut reference: Option = None; + let mut file_arg: Option = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-r" => { + i += 1; + if i >= args.len() { + return Err("-r requires an argument".to_string()); + } + reference = Some(args[i].clone()); + } + other if url.is_none() => url = Some(other.to_string()), + other if file_arg.is_none() => file_arg = Some(other.to_string()), + _ => return Err(usage_str().to_string()), + } + i += 1; + } + let url = url.ok_or_else(|| usage_str().to_string())?; + let file = file_arg.unwrap_or_else(|| deduce_filename(&url)); + let spec = reference.as_deref().unwrap_or("HEAD"); + + let (commit, bytes) = fetch_file(&url, spec, &file)?; + + fs::create_dir_all(VENDOR_DIR) + .map_err(|e| format!("mkdir {VENDOR_DIR}: {e}"))?; + let dest = Path::new(VENDOR_DIR).join(&file); + fs::write(&dest, &bytes) + .map_err(|e| format!("write {}: {e}", dest.display()))?; + + let mut entries: Vec = read_manifest()? + .into_iter() + .filter(|e| e.file != file) + .collect(); + let stored_ref = match &reference { + Some(r) if !looks_like_oid(r) => Some(r.clone()), + _ => None, + }; + entries.push(Entry { + file: file.clone(), + origin: url, + reference: stored_ref, + commit: commit.clone(), + date: today_utc(), + }); + write_manifest(&mut entries)?; + + println!("added {file} ({})", short(&commit)); + Ok(()) +} + +fn cmd_update(args: &[String]) -> Result<(), String> { + if args.len() > 1 { + return Err(usage_str().to_string()); + } + let mut entries = read_manifest()?; + if entries.is_empty() { + return Err(format!("{VENDOR_FILE}: no entries")); + } + let target = args.first().cloned(); + let mut touched = 0usize; + for e in entries.iter_mut() { + if let Some(t) = &target + && &e.file != t + { + continue; + } + let spec = match &e.reference { + Some(r) => r.clone(), + None => { + if target.is_some() { + return Err(format!( + "{}: pinned to commit, no ref to follow", + e.file + )); + } + eprintln!( + "marmita: skip {} (pinned to commit, no ref)", + e.file + ); + continue; + } + }; + let (commit, bytes) = fetch_file(&e.origin, &spec, &e.file)?; + let dest = Path::new(VENDOR_DIR).join(&e.file); + fs::write(&dest, &bytes) + .map_err(|err| format!("write {}: {err}", dest.display()))?; + let changed = e.commit != commit; + e.commit = commit.clone(); + e.date = today_utc(); + touched += 1; + if changed { + println!("updated {} -> {}", e.file, short(&commit)); + } else { + println!("up to date {} ({})", e.file, short(&commit)); + } + } + if let Some(t) = target + && touched == 0 + { + return Err(format!("{t}: no such entry")); + } + write_manifest(&mut entries)?; + Ok(()) +} + +fn cmd_rm(args: &[String]) -> Result<(), String> { + if args.len() != 1 { + return Err(usage_str().to_string()); + } + let file = &args[0]; + let mut entries = read_manifest()?; + let before = entries.len(); + entries.retain(|e| &e.file != file); + if entries.len() == before { + return Err(format!("{file}: no such entry")); + } + let path = Path::new(VENDOR_DIR).join(file); + match fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("remove {}: {e}", path.display())), + } + write_manifest(&mut entries)?; + println!("removed {file}"); + Ok(()) +} + +fn cmd_list(args: &[String]) -> Result<(), String> { + if !args.is_empty() { + return Err(usage_str().to_string()); + } + let entries = read_manifest()?; + for e in &entries { + let r = e.reference.as_deref().unwrap_or("-"); + println!( + "{}\t{}\t{}\t{}\t{}", + e.file, + short(&e.commit), + r, + e.date, + e.origin, + ); + } + Ok(()) +} + +fn short(commit: &str) -> &str { + &commit[..commit.len().min(12)] +} + +////////////////////////////////////////////////////////////////////////////// +// Entry point +////////////////////////////////////////////////////////////////////////////// + +fn usage_str() -> &'static str { + "usage: marmita add [-r ref] [file]\n marmita update [file]\n marmita rm \n marmita list\n marmita -V" +} + +fn main() { + process::exit(run()); +} + +fn run() -> i32 { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("{}", usage_str()); + return 1; + } + if args[1] == "-V" { + println!("marmita {}", env!("MARMITA_VERSION")); + return 0; + } + if let Err(e) = git_init() { + eprintln!("marmita: {e}"); + return 2; + } + let rest: Vec = args[2..].to_vec(); + let result = match args[1].as_str() { + "add" => cmd_add(&rest), + "update" => cmd_update(&rest), + "rm" => cmd_rm(&rest), + "list" => cmd_list(&rest), + other => Err(format!("unknown command '{other}'\n{}", usage_str())), + }; + git_shutdown(); + match result { + Ok(()) => 0, + Err(e) => { + eprintln!("marmita: {e}"); + 1 + } + } +}