Fix CI and Documentation issues

This commit is contained in:
Levi Neuwirth 2026-05-04 10:19:19 -04:00
parent c8d0d67615
commit 291eb0fd8d
9 changed files with 826 additions and 161 deletions

View File

@ -15,31 +15,49 @@
---
--- Two accepted shapes:
---
--- - Table with positional address at `[1]`: `{ "github:owner/repo", version = "^1.0.0" }`.
--- The `version` field defaults to `"*"` (any tag) when omitted. The
--- `install_project` variant additionally **requires** `project_root = "..."`
--- (no default; see the field doc below).
--- - Shorthand string `"github:owner/repo@^1.0.0"`. The separator is the **last** `@` in the
--- string, so addresses containing an `@` (SSH shorthand `git@host:path`) parse correctly.
--- The shorthand form is not accepted by `install_project` (no place to put `project_root`).
--- - Table with positional address at `[1]`. The pin is one of three
--- mutually-exclusive fields:
--- - `version = "<semver>"` (default; constrains to a tag).
--- - `branch = "<name>"` (HEAD of the named branch at install time).
--- - `commit = "<sha>"` (specific revision; full or partial SHA).
--- Specifying more than one of these errors with a "must specify
--- exactly one" message naming every conflicting field. With none
--- specified, the pin defaults to `version = "*"` (any tag).
--- The `install_project` variant additionally **requires**
--- `project_root = "..."` (no default).
--- - Shorthand string `"github:owner/repo@^1.0.0"`. **Version-pin only.**
--- The separator is the **last** `@` in the string, so addresses
--- containing an `@` (SSH shorthand `git@host:path`) parse correctly.
--- Branch and commit pins must use the table form. The shorthand
--- form is also not accepted by `install_project` (no place to put
--- `project_root`).
---
--- @class PackageInstallSpec
--- @field [1] string Positional address (e.g., `"github:owner/repo"`).
--- @field address string|nil Alternative to the positional `[1]`.
--- @field version string|nil Semver constraint (e.g., `"^1.0.0"`, `"=1.2.3"`, `"*"`). Defaults to `"*"`.
--- @field version string|nil Semver constraint (e.g., `"^1.0.0"`, `"=1.2.3"`, `"*"`). Mutually exclusive with `branch` / `commit`. Defaults to `"*"` when none of the three are specified.
--- @field branch string|nil Branch name (e.g., `"main"`). The install resolves the branch's HEAD at install time; not reproducible across time. Mutually exclusive with `version` / `commit`.
--- @field commit string|nil Commit SHA (full or partial). The install pins to that exact revision. Mutually exclusive with `version` / `branch`.
--- @field project_root string|nil `install_project` only: REQUIRED project root. Absolute paths used as-is. Relative paths resolve against the directory of the loading `init.lua` (not against CWD). Common patterns: `os.getenv("PMACS_PROJECT")`, or a literal subdirectory like `"."` for "alongside this init.lua".
--- The pin info on an [`InstalledPackage`].
---
--- @class InstalledPackagePin
--- @field kind "version"|"branch"|"commit" Which pin kind the user supplied.
--- @field value string The user-supplied value: the semver constraint (e.g. `"^1.0.0"`), the branch name (e.g. `"main"`), or the commit SHA. Echoes the field on `PackageInstallSpec` exactly.
--- A successful-install record returned by `install` and listed by `installed`.
---
--- @class InstalledPackage
--- @field name string Package name from `pmacs.toml` (e.g., `"samplepkg"` or `"user/samplepkg"`).
--- @field version string Semver of the resolved tag (canonical numeric form, e.g., `"1.2.3"`).
--- @field tag string The tag that was matched (e.g., `"v1.2.3"`).
--- @field version string Manifest-declared version of the installed snapshot (canonical semver, e.g. `"1.2.3"`).
--- @field tag string Resolution descriptor: matched tag (`"v1.2.3"`) for version pins, `"branch:<name>"` for branch pins, `"commit:<short-sha>"` for commit pins. Always non-empty.
--- @field commit string 40-character commit SHA of the installed snapshot.
--- @field install_path string Absolute on-disk install directory.
--- @field entry string Absolute path to the package's `entry` Lua module.
--- @field scope "user"|"project" Which scope the package was installed under.
--- @field summary string One-line description from the manifest.
--- @field pin InstalledPackagePin Structured pin info (the user's request, distinct from the resolved descriptor).
local pmacs = pmacs or {}
pmacs.packages = pmacs.packages or {}

View File

@ -19,12 +19,47 @@ pmacs.packages.install {
}
```
The shorthand string form is also accepted:
The shorthand string form is also accepted (version pins only):
```lua
pmacs.packages.install "github:owner/repo@^1.0.0"
```
### Pin kinds: `version`, `branch`, `commit`
Each install pins exactly one revision. The spec table chooses the
pin via one of three mutually-exclusive fields:
```lua
-- Highest semver tag matching the constraint. Recommended default.
pmacs.packages.install { "github:owner/repo", version = "^1.0.0" }
-- HEAD of the named branch at install time. Not reproducible across
-- time --- the upstream's branch HEAD moves --- so use sparingly.
pmacs.packages.install { "github:owner/repo", branch = "main" }
-- Exact commit. Reproducible: the same SHA always installs the same
-- snapshot. Useful for pinning to a known-good state before the
-- upstream has tagged a release.
pmacs.packages.install { "github:owner/repo", commit = "abc1234" }
```
Mutual exclusion is enforced: a spec table with two of these fields
errors with a "must specify exactly one" message naming every
conflicting field. With none of the three, the pin defaults to
`version = "*"` (any tag).
The shorthand string form (`"address@^1.0"`) is **version-pin only**.
Branch and commit pins must use the table form because there is no
unambiguous sigil that distinguishes a branch named "main" from a
malformed semver constraint without surprising users.
For version pins, pmacs additionally cross-checks that the
manifest's `version` field at the matched tag satisfies the user's
constraint, catching upstreams whose tag and `pmacs.toml` disagree.
Branch and commit pins skip that check (the user explicitly asked
for that revision regardless of what the manifest says).
## `pmacs.packages.install_project { ... }` — project scope
Installs to `<project_root>/.pmacs/packages/<basename>/`. Project
@ -105,12 +140,77 @@ field, since it has no implicit project context.
The change will be relaxation, not breakage: code that explicitly
passes `project_root` keeps working unchanged.
## How `require` resolution works
pmacs uses Lua's standard require machinery, augmented at install
time:
1. **Path-based search.** Each install prepends
`<install_root>/?.lua;<install_root>/?/init.lua` to
`package.path`. Packages with the conventional layout
(`<basename>.lua` or `<basename>/init.lua`) resolve via this
path with no further machinery — exactly as a hand-written Lua
project would.
2. **Custom searcher.** When the path-based search misses (e.g.
the manifest declares `entry = "main.lua"` or `entry =
"lib/foo.lua"`), a custom searcher pmacs registered in
`package.searchers` (Lua 5.4) / `package.loaders` (LuaJIT and
Lua 5.1) consults the install roster, finds the matching
package by basename, and returns a loader for the exact entry
path declared in the manifest.
The searcher iterates the roster in install order, most-recent
first, so a project-scope install of a basename overrides a prior
user-scope install of the same basename — mirroring the
"newer-installs-prepend-to-path" semantics of the path-based
search.
When `require` cannot find a name through any searcher, the
combined error message names every searcher's contribution; the
custom searcher's contribution looks like:
```
no installed pmacs package named 'whatever'
```
so a user with a typo can spot it without digging into pmacs's
internals.
## `pmacs.packages.installed()`
Returns an array of records describing every package installed
during the current init pass. Each record has the same shape as
`install`'s return value (`name`, `version`, `commit`,
`install_path`, `entry`, `scope`, `summary`).
`install`'s return value:
```lua
{
name = "samplepkg", -- manifest's name
version = "1.0.0", -- manifest's declared version
tag = "v1.0.0", -- resolution descriptor (see below)
commit = "abc...", -- full SHA of the installed snapshot
install_path = "...",
entry = "...",
scope = "user", -- or "project"
summary = "...",
pin = { -- structured user request
kind = "version", -- or "branch" or "commit"
value = "^1.0.0", -- echoes the spec field exactly
},
}
```
The `tag` field is a stable, non-empty descriptor:
- For version pins: the matched tag (`"v1.2.3"`).
- For branch pins: `"branch:<name>"`.
- For commit pins: `"commit:<short-sha>"`.
The `pin` table is the source of truth for "what did the user
request." The flat fields (`tag`, `version`, `commit`) record the
resolution. They differ for branch/commit pins, where the resolved
commit is what got installed but the user's request was the branch
name or SHA prefix.
## `pmacs.packages.update(...)`

View File

@ -29,12 +29,16 @@
//! later release will make it configurable.
use std::collections::VecDeque;
use std::io::{Read, Write};
use std::io::{self, Read, Write};
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex, mpsc};
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
};
use std::thread;
use std::time::Duration;
@ -210,7 +214,8 @@ impl From<TransportError> for AttachError {
///
/// Each transport builds its own `AttachIo` from the primitives it has
/// available. The local-socket transport (M5.5g) clones a `UnixStream`
/// three ways and uses `shutdown(Read)` for the kick. The SSH
/// three ways and uses a kick-aware reader plus socket shutdown for
/// the kick. The SSH
/// transport (M5.7e) takes a child process's `stdout` and `stdin`
/// halves and uses `SIGTERM` to the child for the kick.
pub(crate) struct AttachIo {
@ -229,10 +234,9 @@ pub(crate) struct AttachIo {
/// The reader thread is presumed to be blocked on a read; this
/// wakes it.
///
/// Implementations may be destructive (SSH transport `SIGTERM`s
/// the child) or non-destructive (local-socket transport calls
/// `shutdown(Read)`). Callers do not distinguish — by the time
/// the kick runs, the pump has already decided to exit.
/// Implementations may be destructive. Callers do not distinguish
/// — by the time the kick runs, the pump has already decided to
/// exit.
pub kick: Box<dyn FnOnce() + Send>,
}
@ -318,21 +322,83 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
/// Build an [`AttachIo`] for a connected `UnixStream`.
///
/// The kick clones a third handle for `shutdown(Read)`. Cloning may
/// The kick sets a shared flag and clones a third handle for
/// `shutdown(Both)`. Cloning may
/// fail (rare — the kernel is out of file descriptors), in which
/// case the caller propagates the error before raw mode engages.
fn build_local_socket_io(stream: UnixStream) -> Result<AttachIo, std::io::Error> {
let reader = stream.try_clone()?;
reader.set_nonblocking(true)?;
let kick_handle = stream.try_clone()?;
let kicked = Arc::new(AtomicBool::new(false));
let reader_kicked = Arc::clone(&kicked);
Ok(AttachIo {
reader: Box::new(reader),
reader: Box::new(KickAwareUnixReader {
stream: reader,
kicked: reader_kicked,
}),
writer: Box::new(stream),
kick: Box::new(move || {
let _ = kick_handle.shutdown(Shutdown::Read);
kicked.store(true, Ordering::SeqCst);
let _ = kick_handle.shutdown(Shutdown::Both);
}),
})
}
/// Non-blocking poll-based reader with a kick flag.
///
/// # Wake semantics
///
/// This reader has two cooperating wake paths, only one of which is
/// load-bearing:
///
/// 1. **Atomic flag (correctness):** the reader runs a non-blocking
/// poll loop with a 10ms sleep between iterations. After the kick
/// sets `kicked`, the next loop iteration observes it and returns
/// `Ok(0)`. Worst-case wake latency is one poll cycle (~10ms).
/// This path is platform-independent and is the mechanism the
/// caller relies on for correctness.
///
/// 2. **`shutdown(Both)` on a sibling clone (best-effort speedup):**
/// if the reader happens to be inside `self.stream.read()` when
/// the kick fires, and the platform honors cross-clone shutdown
/// wakes, the read returns `Ok(0)` immediately and the loop
/// skips its sleep. This path is **not** load-bearing — Unix
/// socket cross-clone shutdown semantics are not portably
/// guaranteed, and any wake it provides is a bonus on top of
/// path 1.
///
/// In other words: the atomic flag wakes the reader; the shutdown
/// just shaves up to ~10ms off the wake when the platform plays
/// along. Tests asserting wake bounds should treat the budget as
/// "≤ one poll cycle plus scheduler jitter," not as a measure of
/// shutdown latency.
struct KickAwareUnixReader {
stream: UnixStream,
kicked: Arc<AtomicBool>,
}
impl Read for KickAwareUnixReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
loop {
match self.stream.read(buf) {
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
if self.kicked.load(Ordering::SeqCst) {
return Ok(0);
}
thread::sleep(Duration::from_millis(10));
}
other => return other,
}
}
}
}
fn build_capabilities() -> FrontendCapabilities {
// The v0.1 TUI implements all of these; we report them honestly
// so the daemon doesn't strip features that work fine.
@ -487,7 +553,7 @@ pub(crate) fn run_attach_pair(
// reader needs `kick` to wake.
//
// 2. `kick()` — wake the reader thread, by any means necessary
// (per the kick contract). For local-socket: `shutdown(Read)`.
// (per the kick contract). For local-socket: `shutdown(Both)`.
// For SSH: a watchdog that SIGTERMs the child if the EOF
// cascade hasn't reached the reader within the watchdog's
// grace period.
@ -1350,6 +1416,27 @@ mod tests {
}
}
/// Test-only `Read` wrapper that flips an `AtomicBool` whenever
/// its inner reader is called. Used to synchronize the test
/// against "the reader thread has entered its read call" without
/// resorting to wall-clock sleeps.
///
/// External wrapper by design: production types stay free of
/// test-only hooks. The signal fires on every `read` call (not
/// just the first); the test only cares about observing it
/// transition once, so cheap repeated stores are harmless.
struct EnteredReadSignaler<R: Read> {
inner: R,
entered: Arc<AtomicBool>,
}
impl<R: Read> Read for EnteredReadSignaler<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.entered.store(true, Ordering::Release);
self.inner.read(buf)
}
}
/// Build an `AttachIo` with a kick that increments `counter`.
fn pipe_io_with_counting_kick(socket: UnixStream, counter: Arc<AtomicUsize>) -> AttachIo {
let reader = socket.try_clone().expect("try_clone reader");
@ -1459,7 +1546,34 @@ mod tests {
}
#[test]
fn kick_wakes_blocked_reader_within_one_second() {
fn kick_wakes_blocked_reader() {
// What this test asserts: after `kick()` fires, the reader
// thread terminates. That is, the kick mechanism wakes a
// reader that would otherwise wait on the socket forever.
//
// What this test does NOT assert: a specific wake latency.
// The 5s join bound is intentionally generous so that CI
// scheduler jitter (heavily loaded VM hosts can stall threads
// for hundreds of ms cumulatively) does not turn a correctness
// test into a flake. The steady-state wake budget per
// `KickAwareUnixReader`'s contract is ~10ms (one poll cycle),
// but observing that bound under timing pressure is not what
// this test is for. **Do not tighten the 5s bound back toward
// 1s on the grounds that 5s is much larger than the
// steady-state budget** — the steady-state budget is not what
// is being tested. A real bug (kick mechanism is broken, the
// reader runs forever) hits this bound; CI jitter does not
// come close.
//
// Synchronization: rather than guessing how long the reader
// thread takes to start with `thread::sleep`, the test wraps
// the reader in an `EnteredReadSignaler` that flips an
// `AtomicBool` when the inner reader is first called. The
// test spins on that flag (bounded) so kick fires only once
// we know the reader is actively reading from the socket.
// No wall-clock guesses; no test-only paths in production
// types.
// Hold the daemon side so the kick is the only thing that can
// wake the reader. If we let the daemon side close, the reader
// sees EOF naturally and we'd be testing nothing.
@ -1471,11 +1585,25 @@ mod tests {
kick,
} = io;
let (tx, _rx) = mpsc::channel::<InstanceMessage>();
let reader_handle = thread::spawn(move || run_reader(reader, tx));
let entered = Arc::new(AtomicBool::new(false));
let signaling_reader: Box<dyn Read + Send> = Box::new(EnteredReadSignaler {
inner: reader,
entered: Arc::clone(&entered),
});
// Let the reader actually start blocking on its read.
thread::sleep(Duration::from_millis(50));
let (tx, _rx) = mpsc::channel::<InstanceMessage>();
let reader_handle = thread::spawn(move || run_reader(signaling_reader, tx));
// Wait for the reader to enter its read call. Bounded so a
// never-spawning reader fails the test instead of hanging.
let entry_deadline = Instant::now() + Duration::from_secs(1);
while !entered.load(Ordering::Acquire) {
assert!(
Instant::now() < entry_deadline,
"reader thread did not enter its read call within 1s",
);
thread::sleep(Duration::from_millis(1));
}
kick();
@ -1487,8 +1615,8 @@ mod tests {
let _ = done_tx.send(());
});
done_rx
.recv_timeout(Duration::from_secs(1))
.expect("reader thread must exit within 1s after kick");
.recv_timeout(Duration::from_secs(5))
.expect("reader thread must exit within 5s after kick");
}
#[test]

View File

@ -702,11 +702,6 @@ impl Buffer {
MarkGravity::Right => new_end,
}
}
} else if pos < end {
match mark.gravity {
MarkGravity::Left => start,
MarkGravity::Right => new_end,
}
} else {
match mark.gravity {
MarkGravity::Left => start,

View File

@ -59,7 +59,8 @@ use crate::hook::{Hook, HookRegistry};
use crate::key::{display_sequence, parse_sequence};
use crate::keymap_stack::KeymapStack;
use crate::packages::{
Address, Fetcher, InstallError, InstallScope, InstallSpec, InstalledPackage, Installer,
Address, Fetcher, InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage,
Installer,
};
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
use crate::rope::Range;
@ -626,6 +627,21 @@ pub enum BindingError {
)]
InstallSpecMissingAddress,
/// A `pmacs.packages.install{...}` spec table specified more than
/// one of `version`, `branch`, `commit`. Each install must pin
/// exactly one revision; combining pin kinds is ambiguous (which
/// one wins?). The error message names every conflicting field
/// the spec actually carried.
#[error(
"pmacs.packages.install: spec must specify exactly one of \
`version`, `branch`, or `commit`; got: {fields}"
)]
InstallSpecConflictingPins {
/// Comma-separated list of the offending field names, in
/// the order they appeared on the table.
fields: String,
},
/// `install_project` was called without an explicit
/// `project_root` field. The CWD-fallback was removed because at
/// init time CWD is whatever directory the user happened to
@ -1744,6 +1760,7 @@ fn install_instance_show_binding(lua: &Lua, registry: &SharedRegistry) -> mlua::
})
}
#[allow(clippy::too_many_lines)]
fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<Table> {
let buffer = lua.create_table()?;
@ -1873,7 +1890,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
let handle = StyleOverlayHandleLua {
spans: Arc::clone(&spans),
};
attach_style_overlay_to_visible_windows(lua, id.0, spans);
attach_style_overlay_to_visible_windows(lua, id.0, &spans);
Ok(handle)
},
)?,
@ -1885,7 +1902,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
"attach_style_overlay",
lua.create_function(
move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| {
attach_style_overlay_to_visible_windows(lua, id.0, Arc::clone(&handle.spans));
attach_style_overlay_to_visible_windows(lua, id.0, &handle.spans);
Ok(())
},
)?,
@ -1912,7 +1929,7 @@ fn parse_mark_gravity(opts: Option<&Table>) -> mlua::Result<MarkGravity> {
fn attach_style_overlay_to_visible_windows(
lua: &Lua,
buffer_id: BufferId,
spans: crate::overlay::SharedBufferStyleSpans,
spans: &crate::overlay::SharedBufferStyleSpans,
) {
let Some(core) = lua.app_data_ref::<SharedCore>() else {
return;
@ -1921,7 +1938,7 @@ fn attach_style_overlay_to_visible_windows(
for win in core.windows.values_mut() {
if win.buffer_id == buffer_id {
win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new(
Arc::clone(&spans),
Arc::clone(spans),
)));
}
}
@ -2055,7 +2072,7 @@ fn install_packages_module(lua: &Lua) -> mlua::Result<Table> {
}
/// Register a custom searcher in `package.searchers` (Lua 5.4) /
/// `package.loaders` (Lua 5.1, LuaJIT) that consults the
/// `package.loaders` (Lua 5.1, `LuaJIT`) that consults the
/// [`InstalledPackages`] roster at require time.
///
/// # Why
@ -2089,7 +2106,7 @@ fn install_packages_module(lua: &Lua) -> mlua::Result<Table> {
///
/// # 5.1 vs 5.4 names
///
/// Lua 5.1 / LuaJIT exposes the searcher list as `package.loaders`;
/// Lua 5.1 / `LuaJIT` exposes the searcher list as `package.loaders`;
/// Lua 5.2+ renamed it to `package.searchers`. Both are tables of
/// functions with the same callback shape. We probe `searchers`
/// first and fall back to `loaders` so the same code works under
@ -2101,8 +2118,7 @@ fn register_package_searcher(lua: &Lua) -> mlua::Result<()> {
None => package.get::<Table>("loaders")?,
};
let searcher = lua.create_function(
|lua, name: String| -> mlua::Result<mlua::Value> {
let searcher = lua.create_function(|lua, name: String| -> mlua::Result<mlua::Value> {
let Some(slot) = lua.app_data_ref::<InstalledPackages>() else {
// Slot uninstalled (shouldn't happen under
// production wiring, but a defensive nil keeps
@ -2123,7 +2139,7 @@ fn register_package_searcher(lua: &Lua) -> mlua::Result<()> {
// Searcher convention: a non-function return
// is treated as "not found, here's why" and
// appended to the require error message.
let s = lua.create_string(&format!(
let s = lua.create_string(format!(
"\n\tinstalled pmacs package '{name}' \
entry `{}` could not be read: {e}",
entry.display()
@ -2132,20 +2148,14 @@ fn register_package_searcher(lua: &Lua) -> mlua::Result<()> {
}
};
let chunk_name = format!("@{}", entry.display());
let func = lua
.load(&bytes)
.set_name(&chunk_name)
.into_function()?;
let func = lua.load(&bytes).set_name(&chunk_name).into_function()?;
return Ok(mlua::Value::Function(func));
}
// No installed package matches. Return a string so Lua
// appends our reason to the aggregate require error.
let s = lua.create_string(&format!(
"\n\tno installed pmacs package named '{name}'"
))?;
let s = lua.create_string(format!("\n\tno installed pmacs package named '{name}'"))?;
Ok(mlua::Value::String(s))
},
)?;
})?;
// Append to the searcher list. Lua tables are 1-indexed; the
// new searcher runs after every existing searcher (preload,
@ -2184,18 +2194,10 @@ fn parse_lua_install_spec(value: &Value) -> mlua::Result<InstallSpec> {
}
},
};
let version_str: String = t
.get::<String>("version")
.unwrap_or_else(|_| "*".to_string());
let address = Address::parse(&address_str)
.map_err(|e| mlua::Error::external(BindingError::from(InstallError::Address(e))))?;
let version = semver::VersionReq::parse(&version_str).map_err(|e| {
mlua::Error::external(BindingError::from(InstallError::InvalidVersionReq {
value: version_str,
cause: e.to_string(),
}))
})?;
Ok(InstallSpec { address, version })
let pin = parse_install_pin(t)?;
Ok(InstallSpec { address, pin })
}
other => Err(mlua::Error::external(BindingError::InstallSpecWrongType {
got: other.type_name().to_string(),
@ -2203,6 +2205,57 @@ fn parse_lua_install_spec(value: &Value) -> mlua::Result<InstallSpec> {
}
}
/// Parse the pin fields from a `pmacs.packages.install{...}` table.
///
/// A spec table may carry exactly one of:
/// - `version = "<semver constraint>"` (e.g. `"^1.0.0"`, `"=2.3.4"`).
/// - `branch = "<branch name>"` (e.g. `"main"`).
/// - `commit = "<sha>"` (full or partial; the fetcher accepts either).
///
/// If none are present the pin defaults to `version = "*"` (any
/// tag). If two or more are present the parse fails with
/// [`BindingError::InstallSpecConflictingPins`] naming every field
/// that conflicted.
fn parse_install_pin(t: &Table) -> mlua::Result<InstallPin> {
let version: Option<String> = t.get::<Option<String>>("version").unwrap_or(None);
let branch: Option<String> = t.get::<Option<String>>("branch").unwrap_or(None);
let commit: Option<String> = t.get::<Option<String>>("commit").unwrap_or(None);
let mut present: Vec<&'static str> = Vec::new();
let version = version.filter(|s| !s.is_empty());
let branch = branch.filter(|s| !s.is_empty());
let commit = commit.filter(|s| !s.is_empty());
if version.is_some() {
present.push("version");
}
if branch.is_some() {
present.push("branch");
}
if commit.is_some() {
present.push("commit");
}
if present.len() > 1 {
return Err(mlua::Error::external(
BindingError::InstallSpecConflictingPins {
fields: present.join(", "),
},
));
}
if let Some(b) = branch {
return Ok(InstallPin::Branch(b));
}
if let Some(c) = commit {
return Ok(InstallPin::Commit(c));
}
let value = version.unwrap_or_else(|| "*".to_string());
let req = semver::VersionReq::parse(&value).map_err(|e| {
mlua::Error::external(BindingError::from(InstallError::InvalidVersionReq {
value,
cause: e.to_string(),
}))
})?;
Ok(InstallPin::Version(req))
}
/// Read the required `project_root = "..."` field from the table form
/// of `install_project`'s spec.
///
@ -2374,6 +2427,15 @@ fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result<T
},
)?;
t.set("summary", pkg.manifest.summary.as_str())?;
// Structured pin info: `{ kind = "version"|"branch"|"commit", value = <user-supplied string> }`.
// Existing flat fields (`tag`, `version`, `commit`) remain
// populated for backward-compatible introspection; the `pin`
// table is the source of truth for "what did the user request",
// distinct from "what got resolved".
let pin_table = lua.create_table_with_capacity(0, 2)?;
pin_table.set("kind", pkg.pin.kind())?;
pin_table.set("value", pkg.pin.value())?;
t.set("pin", pin_table)?;
Ok(t)
}

View File

@ -104,13 +104,71 @@ fn xdg_data_root() -> Result<PathBuf, InstallError> {
// InstallSpec
// ---------------------------------------------------------------------------
/// A normalized install request: where to fetch and what version to pick.
/// What the user pinned the install to. Mutually exclusive on the
/// Lua side: a spec table may carry exactly one of `version`,
/// `branch`, or `commit`.
///
/// # Why three kinds
///
/// - [`Self::Version`] is the default, recommended path: the
/// installer picks the highest semver tag matching the constraint
/// and validates the manifest's declared version against the same
/// constraint. Lockfile reproduction (M7.6) records the resolved
/// commit so a later install at the same constraint yields the
/// same revision.
/// - [`Self::Branch`] follows a moving target. Each install
/// re-resolves the branch's HEAD; the install is *not*
/// reproducible across time. Useful for development against an
/// upstream's `main` or for a private package whose semver
/// discipline is not yet established.
/// - [`Self::Commit`] freezes the install at a specific revision.
/// Useful for pinning to a known-good state before the upstream
/// has tagged a release, or for reproducing a colleague's
/// environment exactly without semver drift.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum InstallPin {
/// Highest semver tag satisfying the constraint.
Version(VersionReq),
/// HEAD of the named branch at install time.
Branch(String),
/// Specific commit (full or partial SHA; the fetcher accepts
/// either via `git rev-parse`).
Commit(String),
}
impl InstallPin {
/// Stable string discriminator used at the Lua boundary
/// (`installed_package_to_lua`) and in error messages.
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Version(_) => "version",
Self::Branch(_) => "branch",
Self::Commit(_) => "commit",
}
}
/// User-supplied value as a string: the constraint for
/// [`Self::Version`], the branch name for [`Self::Branch`], the
/// SHA for [`Self::Commit`].
#[must_use]
pub fn value(&self) -> String {
match self {
Self::Version(req) => req.to_string(),
Self::Branch(b) => b.clone(),
Self::Commit(c) => c.clone(),
}
}
}
/// A normalized install request: where to fetch and how to pin the
/// revision.
#[derive(Debug, Clone)]
pub struct InstallSpec {
/// Address (resolved via `Address::parse`).
pub address: Address,
/// Semver constraint to match against the upstream's tags.
pub version: VersionReq,
/// What the user pinned the install to. See [`InstallPin`].
pub pin: InstallPin,
}
impl InstallSpec {
@ -126,6 +184,14 @@ impl InstallSpec {
/// has no `@` separator and [`InstallError::Address`] /
/// [`InstallError::InvalidVersionReq`] for the underlying parse
/// failures.
///
/// The shorthand string form is **version-pin only**. Branch and
/// commit pins must use the Lua table form
/// (`{ "addr", branch = "..." }` / `{ "addr", commit = "..." }`)
/// because there is no concise sigil that disambiguates a
/// branch/commit value from a semver constraint without
/// surprising users (`@main` could be a branch named "main" or
/// a malformed semver --- ambiguous).
pub fn parse_shorthand(s: &str) -> Result<Self, InstallError> {
let (addr, ver) =
s.rsplit_once('@')
@ -142,7 +208,10 @@ impl InstallSpec {
value: ver.to_string(),
cause: e.to_string(),
})?;
Ok(Self { address, version })
Ok(Self {
address,
pin: InstallPin::Version(version),
})
}
}
@ -159,12 +228,26 @@ pub struct InstalledPackage {
pub install_path: PathBuf,
/// 40-char commit hash of the installed snapshot.
pub commit: String,
/// The tag that was matched (e.g., `v1.0.0` or `1.0.0`).
/// A descriptor of what was installed:
/// - For [`InstallPin::Version`]: the matched tag, e.g. `"v1.0.0"`.
/// - For [`InstallPin::Branch`]: `"branch:<name>"`.
/// - For [`InstallPin::Commit`]: `"commit:<short-sha>"`.
///
/// Always non-empty so Lua callers can use it as a stable
/// "what got installed" label without nil-checking.
pub tag: String,
/// The semver value parsed from `tag` (canonical numeric form).
/// Semver version of the installed snapshot. For
/// [`InstallPin::Version`] this is the version parsed from the
/// matched tag; for [`InstallPin::Branch`] / [`InstallPin::Commit`]
/// it falls back to `manifest.version` (the package's declared
/// version at the resolved revision).
pub version: Version,
/// The install scope this package was installed under.
pub scope: InstallScope,
/// What the user originally pinned this install to. Useful for
/// lockfile generation (M7.6) and for surfacing to the Lua
/// `installed()` snapshot.
pub pin: InstallPin,
}
impl InstalledPackage {
@ -243,33 +326,54 @@ impl Installer {
}
/// Install one package. See module docs for the step-by-step flow.
#[allow(clippy::too_many_lines)]
pub fn install(&self, spec: &InstallSpec) -> Result<InstalledPackage, InstallError> {
let url = spec.address.to_git_url();
let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?;
// 2. Pick best matching tag.
// Resolve the user's pin to a concrete (commit, tag-descriptor)
// pair. The descriptor is what we display to users in the
// `tag` field of the resulting `InstalledPackage`.
let (commit, tag_descriptor) = match &spec.pin {
InstallPin::Version(req) => {
let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?;
let chosen =
best_match(&tags, &spec.version).ok_or_else(|| InstallError::NoMatchingVersion {
best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion {
address: url.clone(),
req: spec.version.to_string(),
req: req.to_string(),
available: tags.clone(),
})?;
// 3. Resolve to commit.
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
.map_err(InstallError::Fetch)?;
(commit, chosen.tag)
}
InstallPin::Branch(name) => {
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Branch(name.clone()))
.map_err(InstallError::Fetch)?;
(commit, format!("branch:{name}"))
}
InstallPin::Commit(sha) => {
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Commit(sha.clone()))
.map_err(InstallError::Fetch)?;
let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
(commit, format!("commit:{short}"))
}
};
// 4. Read manifest at this commit so we know the install dir name.
// Read the manifest at this commit so we know the install dir name.
let manifest_bytes = self
.fetcher
.show_blob(&bare, &commit, "pmacs.toml")
.map_err(|e| match e {
FetchError::GitInvocation { stderr, .. } => InstallError::ManifestMissing {
address: url.clone(),
tag: chosen.tag.clone(),
tag: tag_descriptor.clone(),
cause: stderr,
},
other => InstallError::Fetch(other),
@ -277,42 +381,43 @@ impl Installer {
let manifest_str =
std::str::from_utf8(&manifest_bytes).map_err(|_| InstallError::ManifestNotUtf8 {
address: url.clone(),
tag: chosen.tag.clone(),
tag: tag_descriptor.clone(),
})?;
let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?;
// Refuse to install a package whose `pmacs_required` constraint
// does not match the running pmacs version. The manifest field
// is a hard contract, not advisory: if a package declares
// `pmacs_required = ">=2.0.0"` and we're 1.x, the package is
// free to call APIs we do not yet expose, and the failure
// would manifest as a runtime Lua traceback rather than a
// typed install-time error.
// does not match the running pmacs version. Applies to every
// pin kind: a package's declared API requirements are
// independent of how the user pinned the revision.
let running_pmacs = running_pmacs_version();
if !manifest.pmacs_required.matches(&running_pmacs) {
return Err(InstallError::PmacsVersionIncompatible {
address: url.clone(),
tag: chosen.tag.clone(),
tag: tag_descriptor.clone(),
required: manifest.pmacs_required.to_string(),
running: running_pmacs.to_string(),
});
}
// Sanity: the manifest's `version` should equal the resolved tag.
// We don't reject mismatches (some upstreams version-tag asymmetrically),
// but we do require it to satisfy the requested constraint. The tag
// already satisfies the constraint (we chose it that way), so the
// strict check is on the manifest.
if !spec.version.matches(&manifest.version) {
// For version pins only: cross-check that the manifest's
// declared version satisfies the constraint. The matched tag
// already satisfies it (we chose it that way); the strict
// check is on the manifest, which catches packages whose tag
// and pmacs.toml version disagree. Branch and commit pins
// skip this check --- the user explicitly asked for that
// revision regardless of what the manifest says.
if let InstallPin::Version(req) = &spec.pin {
if !req.matches(&manifest.version) {
return Err(InstallError::ManifestVersionMismatch {
address: url.clone(),
tag: chosen.tag.clone(),
tag: tag_descriptor.clone(),
manifest_version: manifest.version.to_string(),
req: spec.version.to_string(),
req: req.to_string(),
});
}
}
// 5. Archive + extract.
// Archive + extract.
let install_root = self.install_root()?;
let basename = package_basename(manifest.name.as_str());
let install_path = install_root.join(basename);
@ -326,12 +431,13 @@ impl Installer {
match existing {
Some(prev) if prev == commit => {
return Ok(InstalledPackage {
version: manifest.version.clone(),
manifest,
install_path,
commit,
tag: chosen.tag,
version: chosen.version,
tag: tag_descriptor,
scope: self.scope.clone(),
pin: spec.pin.clone(),
});
}
_ => {
@ -361,12 +467,13 @@ impl Installer {
write_install_marker(&install_path, &commit)?;
Ok(InstalledPackage {
version: manifest.version.clone(),
manifest,
install_path,
commit,
tag: chosen.tag,
version: chosen.version,
tag: tag_descriptor,
scope: self.scope.clone(),
pin: spec.pin.clone(),
})
}
}
@ -782,7 +889,10 @@ exports = ["samplepkg"]
fn shorthand_parses_github_address_with_caret_constraint() {
let s = InstallSpec::parse_shorthand("github:user/repo@^1.0.0").unwrap();
assert!(matches!(s.address, Address::Github { .. }));
assert_eq!(s.version.to_string(), "^1.0.0");
match &s.pin {
InstallPin::Version(req) => assert_eq!(req.to_string(), "^1.0.0"),
other => panic!("expected Version pin, got {other:?}"),
}
}
#[test]
@ -790,7 +900,10 @@ exports = ["samplepkg"]
// SSH shorthand: `git:git@host:path`. The `@` in `git@host`
// must not be confused with the version separator.
let s = InstallSpec::parse_shorthand("git:git@host:path/repo.git@=1.2.3").unwrap();
assert_eq!(s.version.to_string(), "=1.2.3");
match &s.pin {
InstallPin::Version(req) => assert_eq!(req.to_string(), "=1.2.3"),
other => panic!("expected Version pin, got {other:?}"),
}
if let Address::Url(u) = s.address {
assert_eq!(u, "git@host:path/repo.git");
} else {
@ -876,7 +989,7 @@ exports = ["samplepkg"]
let spec = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse("^1.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse("^1.0").unwrap()),
};
let installed = installer.install(&spec).unwrap();
@ -918,7 +1031,7 @@ exports = ["samplepkg"]
let spec = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.0.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
};
let installed = installer.install(&spec).unwrap();
assert_eq!(installed.tag, "v1.0.0");
@ -933,7 +1046,7 @@ exports = ["samplepkg"]
let spec = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse(">=2.0.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse(">=2.0.0").unwrap()),
};
let err = installer.install(&spec).unwrap_err();
match err {
@ -953,7 +1066,7 @@ exports = ["samplepkg"]
let spec = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.0.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
};
let first = installer.install(&spec).unwrap();
// Drop a sentinel; idempotent re-install should not blow it away.
@ -976,14 +1089,14 @@ exports = ["samplepkg"]
// First install at 1.0.0.
let spec_v1 = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.0.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
};
installer.install(&spec_v1).unwrap();
// Second install at 1.1.0 to the same install path: refuse.
let spec_v2 = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse("=1.1.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse("=1.1.0").unwrap()),
};
let err = installer.install(&spec_v2).unwrap_err();
assert!(matches!(err, InstallError::AlreadyInstalled { .. }));
@ -1057,7 +1170,7 @@ exports = ["samplepkg"]
let spec = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse("^1.0.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
};
match installer.install(&spec).unwrap_err() {
@ -1086,7 +1199,7 @@ exports = ["samplepkg"]
let spec = InstallSpec {
address: Address::Url(file_url(&bare)),
version: VersionReq::parse("^1.0.0").unwrap(),
pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
};
installer
.install(&spec)

View File

@ -18,5 +18,7 @@ pub mod manifest;
pub use address::{Address, AddressError};
pub use fetcher::{FetchError, Fetcher, RefSpec};
pub use installer::{InstallError, InstallScope, InstallSpec, InstalledPackage, Installer};
pub use installer::{
InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, Installer,
};
pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName};

View File

@ -30,9 +30,13 @@
//! marker is `"\n[<basename(argv[0])> exited with code N]\n"`.
use pmacs::editor::EditorState;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
static PUMP_TEST_LOCK: Mutex<()> = Mutex::new(());
/// Locate a shell binary for tests that require one. Returns the
/// resolved path or `None` if the shell is neither at `PMACS_TEST_<NAME>`
/// nor on `PATH`. Per-test selective skipping (rather than skipping
@ -80,6 +84,7 @@ fn run(chunk: &str) {
/// `poll_until` pattern but routes through `tick_processes` so the M6.5
/// after-tick contract is exercised end-to-end.
fn run_with_pump(setup_chunk: &str, predicate_chunk: &str, timeout_ms: u64) {
let _guard = PUMP_TEST_LOCK.lock().expect("pump test lock");
let mut editor = EditorState::new();
editor
.lua_host
@ -328,7 +333,6 @@ fn m6_5_exit_marker_uses_basename_with_leading_newline() {
/// running-state (process must have started before we type) and then
/// on history matching the expected output.
fn run_shell_smoke_test(shell_path: &std::path::Path, argv_extra: &[&str]) {
use std::fmt::Write as _;
let mut argv_lua = String::new();
write!(&mut argv_lua, r#""{}""#, shell_path.display()).unwrap();
for a in argv_extra {

View File

@ -40,11 +40,7 @@ use tempfile::TempDir;
/// (relative to the package root) with the supplied Lua body.
/// Returns `(tempdir, bare_path)` --- the tempdir owns both the work
/// tree and the bare clone.
fn make_package_with_entry(
name: &str,
entry_path: &str,
entry_body: &str,
) -> (TempDir, PathBuf) {
fn make_package_with_entry(name: &str, entry_path: &str, entry_body: &str) -> (TempDir, PathBuf) {
let td = tempfile::tempdir().expect("tempdir");
let work = td.path().join("work");
let bare = td.path().join("upstream.git");
@ -595,3 +591,250 @@ fn searcher_misses_for_unknown_name_with_pmacs_specific_message() {
"error must mention the pmacs searcher's contribution: {msg}"
);
}
// ---------------------------------------------------------------------------
// Reviewer-flagged item 11: branch/commit install pins.
// ---------------------------------------------------------------------------
//
// The fetcher already supports `RefSpec::Branch` and `RefSpec::Commit`;
// item 11 is the Lua-surface plumbing that exposes those resolutions
// to user init.lua. The acceptance shape: a spec table with
// `branch = "..."` or `commit = "..."` (instead of `version = "..."`)
// installs that exact revision. The two are mutually exclusive --- a
// table with both must error.
/// Build a sample-package bare repo with two tagged versions plus a
/// `feature` branch carrying a third commit. Returns
/// `(tempdir, bare_path, feature_branch_commit_sha)`. Every field
/// caller may need to verify a branch/commit pin came out of the
/// install path correctly.
fn make_branched_sample_package(name: &str) -> (TempDir, PathBuf, String) {
let td = tempfile::tempdir().expect("tempdir");
let work = td.path().join("work");
let bare = td.path().join("upstream.git");
run_git(&[
OsStr::new("init"),
OsStr::new("--initial-branch=main"),
work.as_os_str(),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.email"),
OsStr::new("test@example.com"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.name"),
OsStr::new("Tester"),
]);
// First tagged release on `main`.
write_manifest(&work, name, "1.0.0");
std::fs::write(work.join("init.lua"), b"return { from = 'main@v1.0.0' }\n")
.expect("write init");
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new("v1.0.0"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("tag"),
OsStr::new("v1.0.0"),
]);
// Branch off `main` and commit a different init.lua. The branch
// remains untagged --- only a branch ref points at it.
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("checkout"),
OsStr::new("-b"),
OsStr::new("feature"),
]);
std::fs::write(
work.join("init.lua"),
b"return { from = 'feature-branch' }\n",
)
.expect("write feature init");
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new("feature work"),
]);
let feature_sha = git_rev_parse_head(&work);
// Bare clone after both refs exist.
run_git(&[
OsStr::new("clone"),
OsStr::new("--bare"),
work.as_os_str(),
bare.as_os_str(),
]);
(td, bare, feature_sha)
}
fn write_manifest(work: &Path, name: &str, version: &str) {
let manifest = format!(
"name = \"{name}\"\n\
version = \"{version}\"\n\
summary = \"acceptance fixture\"\n\
pmacs_required = \">= 0.1.0\"\n\
entry = \"init.lua\"\n\
exports = [\"{name}\"]\n"
);
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
}
fn git_rev_parse_head(work: &Path) -> String {
let out = Command::new("git")
.arg("-C")
.arg(work)
.arg("rev-parse")
.arg("HEAD")
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()
.expect("git rev-parse spawn");
assert!(out.status.success(), "git rev-parse failed");
String::from_utf8(out.stdout)
.expect("rev-parse stdout utf8")
.trim()
.to_string()
}
#[test]
fn install_with_branch_pin_uses_branch_head() {
let (_pkg_td, bare, _feature_sha) = make_branched_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{
"git:{url}",
branch = "feature",
}}
assert(installed.pin.kind == "branch",
"pin.kind must be branch, got " .. tostring(installed.pin.kind))
assert(installed.pin.value == "feature",
"pin.value must echo the branch name, got " .. tostring(installed.pin.value))
assert(installed.tag == "branch:feature",
"tag descriptor must be branch:feature, got " .. tostring(installed.tag))
local mod = require("samplepkg")
assert(mod.from == "feature-branch",
"module body must come from the feature branch's init.lua, got " .. tostring(mod.from))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("branch pin install failed: {e}");
});
}
#[test]
fn install_with_commit_pin_uses_exact_revision() {
let (_pkg_td, bare, feature_sha) = make_branched_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{
"git:{url}",
commit = "{feature_sha}",
}}
assert(installed.pin.kind == "commit",
"pin.kind must be commit, got " .. tostring(installed.pin.kind))
assert(installed.pin.value == "{feature_sha}",
"pin.value must echo the SHA, got " .. tostring(installed.pin.value))
assert(installed.commit == "{feature_sha}",
"resolved commit must equal the pinned SHA, got " .. tostring(installed.commit))
assert(installed.tag:sub(1, 7) == "commit:",
"tag descriptor must start with commit:, got " .. tostring(installed.tag))
local mod = require("samplepkg")
assert(mod.from == "feature-branch",
"module body must come from the pinned commit, got " .. tostring(mod.from))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("commit pin install failed: {e}");
});
}
#[test]
fn install_with_conflicting_pins_errors_with_field_list() {
// Specifying more than one pin is ambiguous (which one wins?).
// The error must name every conflicting field so the user can
// see which to keep without re-reading the docs.
let (_pkg_td, bare, _) = make_branched_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
pmacs.packages.install {{
"git:{url}",
version = "^1.0.0",
branch = "feature",
}}
"#
);
let err = host
.eval(Some("test"), &script)
.expect_err("conflicting pins must error");
let msg = err.to_string();
assert!(
msg.contains("version") && msg.contains("branch"),
"error must name both conflicting fields: {msg}"
);
assert!(
msg.contains("exactly one"),
"error must explain the mutual-exclusion rule: {msg}"
);
}
#[test]
fn install_with_default_version_pin_when_no_pin_field_supplied() {
// The reviewer's wording: existing default is `version = "*"`.
// This test pins that contract: a spec table with no
// version/branch/commit field defaults to "any tag".
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{ "git:{url}" }}
assert(installed.pin.kind == "version",
"default pin must be a version pin, got " .. tostring(installed.pin.kind))
assert(installed.pin.value == "*",
"default constraint must be `*`, got " .. tostring(installed.pin.value))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("default-pin install failed: {e}");
});
}