Commit Diff


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' <murilo@ijanc.org>
+
+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' <murilo@ijanc.org>
+#
+# 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' <murilo@ijanc.org>
+.\"
+.\" 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' <murilo@ijanc.org>
+//
+// 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<git_credential_acquire_cb>,
+        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, String> {
+    CString::new(s).map_err(|e| e.to_string())
+}
+
+fn cstring_path(p: &Path) -> Result<CString, String> {
+    CString::new(p.as_os_str().as_bytes()).map_err(|e| e.to_string())
+}
+
+struct CredState {
+    attempt: i32,
+    keys: Vec<CString>,
+}
+
+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<CString> {
+    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<Repo, String> {
+    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<String, String> {
+    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<Vec<u8>, 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<String>,
+    commit: String,
+    date: String,
+}
+
+fn parse_vendor(text: &str) -> Result<Vec<Entry>, String> {
+    let mut out = Vec::new();
+    let mut cur: Option<Entry> = 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<Entry>,
+    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<Vec<Entry>, 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<PathBuf, String> {
+    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<u8>), 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<String> = None;
+    let mut reference: Option<String> = None;
+    let mut file_arg: Option<String> = 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<Entry> = 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] <url> [file]\n       marmita update [file]\n       marmita rm <file>\n       marmita list\n       marmita -V"
+}
+
+fn main() {
+    process::exit(run());
+}
+
+fn run() -> i32 {
+    let args: Vec<String> = 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<String> = 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
+        }
+    }
+}