Fix CI and Documentation issues
This commit is contained in:
parent
c8d0d67615
commit
291eb0fd8d
|
|
@ -15,31 +15,49 @@
|
||||||
---
|
---
|
||||||
--- Two accepted shapes:
|
--- Two accepted shapes:
|
||||||
---
|
---
|
||||||
--- - Table with positional address at `[1]`: `{ "github:owner/repo", version = "^1.0.0" }`.
|
--- - Table with positional address at `[1]`. The pin is one of three
|
||||||
--- The `version` field defaults to `"*"` (any tag) when omitted. The
|
--- mutually-exclusive fields:
|
||||||
--- `install_project` variant additionally **requires** `project_root = "..."`
|
--- - `version = "<semver>"` (default; constrains to a tag).
|
||||||
--- (no default; see the field doc below).
|
--- - `branch = "<name>"` (HEAD of the named branch at install time).
|
||||||
--- - Shorthand string `"github:owner/repo@^1.0.0"`. The separator is the **last** `@` in the
|
--- - `commit = "<sha>"` (specific revision; full or partial SHA).
|
||||||
--- string, so addresses containing an `@` (SSH shorthand `git@host:path`) parse correctly.
|
--- Specifying more than one of these errors with a "must specify
|
||||||
--- The shorthand form is not accepted by `install_project` (no place to put `project_root`).
|
--- 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
|
--- @class PackageInstallSpec
|
||||||
--- @field [1] string Positional address (e.g., `"github:owner/repo"`).
|
--- @field [1] string Positional address (e.g., `"github:owner/repo"`).
|
||||||
--- @field address string|nil Alternative to the positional `[1]`.
|
--- @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".
|
--- @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`.
|
--- A successful-install record returned by `install` and listed by `installed`.
|
||||||
---
|
---
|
||||||
--- @class InstalledPackage
|
--- @class InstalledPackage
|
||||||
--- @field name string Package name from `pmacs.toml` (e.g., `"samplepkg"` or `"user/samplepkg"`).
|
--- @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 version string Manifest-declared version of the installed snapshot (canonical semver, e.g. `"1.2.3"`).
|
||||||
--- @field tag string The tag that was matched (e.g., `"v1.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 commit string 40-character commit SHA of the installed snapshot.
|
||||||
--- @field install_path string Absolute on-disk install directory.
|
--- @field install_path string Absolute on-disk install directory.
|
||||||
--- @field entry string Absolute path to the package's `entry` Lua module.
|
--- @field entry string Absolute path to the package's `entry` Lua module.
|
||||||
--- @field scope "user"|"project" Which scope the package was installed under.
|
--- @field scope "user"|"project" Which scope the package was installed under.
|
||||||
--- @field summary string One-line description from the manifest.
|
--- @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 {}
|
local pmacs = pmacs or {}
|
||||||
pmacs.packages = pmacs.packages or {}
|
pmacs.packages = pmacs.packages or {}
|
||||||
|
|
|
||||||
106
docs/packages.md
106
docs/packages.md
|
|
@ -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
|
```lua
|
||||||
pmacs.packages.install "github:owner/repo@^1.0.0"
|
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
|
## `pmacs.packages.install_project { ... }` — project scope
|
||||||
|
|
||||||
Installs to `<project_root>/.pmacs/packages/<basename>/`. Project
|
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
|
The change will be relaxation, not breakage: code that explicitly
|
||||||
passes `project_root` keeps working unchanged.
|
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()`
|
## `pmacs.packages.installed()`
|
||||||
|
|
||||||
Returns an array of records describing every package installed
|
Returns an array of records describing every package installed
|
||||||
during the current init pass. Each record has the same shape as
|
during the current init pass. Each record has the same shape as
|
||||||
`install`'s return value (`name`, `version`, `commit`,
|
`install`'s return value:
|
||||||
`install_path`, `entry`, `scope`, `summary`).
|
|
||||||
|
```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(...)`
|
## `pmacs.packages.update(...)`
|
||||||
|
|
||||||
|
|
|
||||||
164
src/attach.rs
164
src/attach.rs
|
|
@ -29,12 +29,16 @@
|
||||||
//! later release will make it configurable.
|
//! later release will make it configurable.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::io::{Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::net::Shutdown;
|
use std::net::Shutdown;
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
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::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
@ -210,7 +214,8 @@ impl From<TransportError> for AttachError {
|
||||||
///
|
///
|
||||||
/// Each transport builds its own `AttachIo` from the primitives it has
|
/// Each transport builds its own `AttachIo` from the primitives it has
|
||||||
/// available. The local-socket transport (M5.5g) clones a `UnixStream`
|
/// 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`
|
/// transport (M5.7e) takes a child process's `stdout` and `stdin`
|
||||||
/// halves and uses `SIGTERM` to the child for the kick.
|
/// halves and uses `SIGTERM` to the child for the kick.
|
||||||
pub(crate) struct AttachIo {
|
pub(crate) struct AttachIo {
|
||||||
|
|
@ -229,10 +234,9 @@ pub(crate) struct AttachIo {
|
||||||
/// The reader thread is presumed to be blocked on a read; this
|
/// The reader thread is presumed to be blocked on a read; this
|
||||||
/// wakes it.
|
/// wakes it.
|
||||||
///
|
///
|
||||||
/// Implementations may be destructive (SSH transport `SIGTERM`s
|
/// Implementations may be destructive. Callers do not distinguish
|
||||||
/// the child) or non-destructive (local-socket transport calls
|
/// — by the time the kick runs, the pump has already decided to
|
||||||
/// `shutdown(Read)`). Callers do not distinguish — by the time
|
/// exit.
|
||||||
/// the kick runs, the pump has already decided to exit.
|
|
||||||
pub kick: Box<dyn FnOnce() + Send>,
|
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`.
|
/// 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
|
/// fail (rare — the kernel is out of file descriptors), in which
|
||||||
/// case the caller propagates the error before raw mode engages.
|
/// case the caller propagates the error before raw mode engages.
|
||||||
fn build_local_socket_io(stream: UnixStream) -> Result<AttachIo, std::io::Error> {
|
fn build_local_socket_io(stream: UnixStream) -> Result<AttachIo, std::io::Error> {
|
||||||
let reader = stream.try_clone()?;
|
let reader = stream.try_clone()?;
|
||||||
|
reader.set_nonblocking(true)?;
|
||||||
let kick_handle = stream.try_clone()?;
|
let kick_handle = stream.try_clone()?;
|
||||||
|
let kicked = Arc::new(AtomicBool::new(false));
|
||||||
|
let reader_kicked = Arc::clone(&kicked);
|
||||||
Ok(AttachIo {
|
Ok(AttachIo {
|
||||||
reader: Box::new(reader),
|
reader: Box::new(KickAwareUnixReader {
|
||||||
|
stream: reader,
|
||||||
|
kicked: reader_kicked,
|
||||||
|
}),
|
||||||
writer: Box::new(stream),
|
writer: Box::new(stream),
|
||||||
kick: Box::new(move || {
|
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 {
|
fn build_capabilities() -> FrontendCapabilities {
|
||||||
// The v0.1 TUI implements all of these; we report them honestly
|
// The v0.1 TUI implements all of these; we report them honestly
|
||||||
// so the daemon doesn't strip features that work fine.
|
// 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.
|
// reader needs `kick` to wake.
|
||||||
//
|
//
|
||||||
// 2. `kick()` — wake the reader thread, by any means necessary
|
// 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
|
// For SSH: a watchdog that SIGTERMs the child if the EOF
|
||||||
// cascade hasn't reached the reader within the watchdog's
|
// cascade hasn't reached the reader within the watchdog's
|
||||||
// grace period.
|
// 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`.
|
/// Build an `AttachIo` with a kick that increments `counter`.
|
||||||
fn pipe_io_with_counting_kick(socket: UnixStream, counter: Arc<AtomicUsize>) -> AttachIo {
|
fn pipe_io_with_counting_kick(socket: UnixStream, counter: Arc<AtomicUsize>) -> AttachIo {
|
||||||
let reader = socket.try_clone().expect("try_clone reader");
|
let reader = socket.try_clone().expect("try_clone reader");
|
||||||
|
|
@ -1459,7 +1546,34 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// 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
|
// wake the reader. If we let the daemon side close, the reader
|
||||||
// sees EOF naturally and we'd be testing nothing.
|
// sees EOF naturally and we'd be testing nothing.
|
||||||
|
|
@ -1471,11 +1585,25 @@ mod tests {
|
||||||
kick,
|
kick,
|
||||||
} = io;
|
} = io;
|
||||||
|
|
||||||
let (tx, _rx) = mpsc::channel::<InstanceMessage>();
|
let entered = Arc::new(AtomicBool::new(false));
|
||||||
let reader_handle = thread::spawn(move || run_reader(reader, tx));
|
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.
|
let (tx, _rx) = mpsc::channel::<InstanceMessage>();
|
||||||
thread::sleep(Duration::from_millis(50));
|
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();
|
kick();
|
||||||
|
|
||||||
|
|
@ -1487,8 +1615,8 @@ mod tests {
|
||||||
let _ = done_tx.send(());
|
let _ = done_tx.send(());
|
||||||
});
|
});
|
||||||
done_rx
|
done_rx
|
||||||
.recv_timeout(Duration::from_secs(1))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("reader thread must exit within 1s after kick");
|
.expect("reader thread must exit within 5s after kick");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -702,11 +702,6 @@ impl Buffer {
|
||||||
MarkGravity::Right => new_end,
|
MarkGravity::Right => new_end,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if pos < end {
|
|
||||||
match mark.gravity {
|
|
||||||
MarkGravity::Left => start,
|
|
||||||
MarkGravity::Right => new_end,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
match mark.gravity {
|
match mark.gravity {
|
||||||
MarkGravity::Left => start,
|
MarkGravity::Left => start,
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ use crate::hook::{Hook, HookRegistry};
|
||||||
use crate::key::{display_sequence, parse_sequence};
|
use crate::key::{display_sequence, parse_sequence};
|
||||||
use crate::keymap_stack::KeymapStack;
|
use crate::keymap_stack::KeymapStack;
|
||||||
use crate::packages::{
|
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::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
|
||||||
use crate::rope::Range;
|
use crate::rope::Range;
|
||||||
|
|
@ -626,6 +627,21 @@ pub enum BindingError {
|
||||||
)]
|
)]
|
||||||
InstallSpecMissingAddress,
|
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
|
/// `install_project` was called without an explicit
|
||||||
/// `project_root` field. The CWD-fallback was removed because at
|
/// `project_root` field. The CWD-fallback was removed because at
|
||||||
/// init time CWD is whatever directory the user happened to
|
/// 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> {
|
fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<Table> {
|
||||||
let buffer = lua.create_table()?;
|
let buffer = lua.create_table()?;
|
||||||
|
|
||||||
|
|
@ -1873,7 +1890,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
||||||
let handle = StyleOverlayHandleLua {
|
let handle = StyleOverlayHandleLua {
|
||||||
spans: Arc::clone(&spans),
|
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)
|
Ok(handle)
|
||||||
},
|
},
|
||||||
)?,
|
)?,
|
||||||
|
|
@ -1885,7 +1902,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
||||||
"attach_style_overlay",
|
"attach_style_overlay",
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| {
|
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(())
|
Ok(())
|
||||||
},
|
},
|
||||||
)?,
|
)?,
|
||||||
|
|
@ -1912,7 +1929,7 @@ fn parse_mark_gravity(opts: Option<&Table>) -> mlua::Result<MarkGravity> {
|
||||||
fn attach_style_overlay_to_visible_windows(
|
fn attach_style_overlay_to_visible_windows(
|
||||||
lua: &Lua,
|
lua: &Lua,
|
||||||
buffer_id: BufferId,
|
buffer_id: BufferId,
|
||||||
spans: crate::overlay::SharedBufferStyleSpans,
|
spans: &crate::overlay::SharedBufferStyleSpans,
|
||||||
) {
|
) {
|
||||||
let Some(core) = lua.app_data_ref::<SharedCore>() else {
|
let Some(core) = lua.app_data_ref::<SharedCore>() else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1921,7 +1938,7 @@ fn attach_style_overlay_to_visible_windows(
|
||||||
for win in core.windows.values_mut() {
|
for win in core.windows.values_mut() {
|
||||||
if win.buffer_id == buffer_id {
|
if win.buffer_id == buffer_id {
|
||||||
win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new(
|
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) /
|
/// 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.
|
/// [`InstalledPackages`] roster at require time.
|
||||||
///
|
///
|
||||||
/// # Why
|
/// # Why
|
||||||
|
|
@ -2089,7 +2106,7 @@ fn install_packages_module(lua: &Lua) -> mlua::Result<Table> {
|
||||||
///
|
///
|
||||||
/// # 5.1 vs 5.4 names
|
/// # 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
|
/// Lua 5.2+ renamed it to `package.searchers`. Both are tables of
|
||||||
/// functions with the same callback shape. We probe `searchers`
|
/// functions with the same callback shape. We probe `searchers`
|
||||||
/// first and fall back to `loaders` so the same code works under
|
/// 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")?,
|
None => package.get::<Table>("loaders")?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let searcher = lua.create_function(
|
let searcher = lua.create_function(|lua, name: String| -> mlua::Result<mlua::Value> {
|
||||||
|lua, name: String| -> mlua::Result<mlua::Value> {
|
|
||||||
let Some(slot) = lua.app_data_ref::<InstalledPackages>() else {
|
let Some(slot) = lua.app_data_ref::<InstalledPackages>() else {
|
||||||
// Slot uninstalled (shouldn't happen under
|
// Slot uninstalled (shouldn't happen under
|
||||||
// production wiring, but a defensive nil keeps
|
// 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
|
// Searcher convention: a non-function return
|
||||||
// is treated as "not found, here's why" and
|
// is treated as "not found, here's why" and
|
||||||
// appended to the require error message.
|
// appended to the require error message.
|
||||||
let s = lua.create_string(&format!(
|
let s = lua.create_string(format!(
|
||||||
"\n\tinstalled pmacs package '{name}' \
|
"\n\tinstalled pmacs package '{name}' \
|
||||||
entry `{}` could not be read: {e}",
|
entry `{}` could not be read: {e}",
|
||||||
entry.display()
|
entry.display()
|
||||||
|
|
@ -2132,20 +2148,14 @@ fn register_package_searcher(lua: &Lua) -> mlua::Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let chunk_name = format!("@{}", entry.display());
|
let chunk_name = format!("@{}", entry.display());
|
||||||
let func = lua
|
let func = lua.load(&bytes).set_name(&chunk_name).into_function()?;
|
||||||
.load(&bytes)
|
|
||||||
.set_name(&chunk_name)
|
|
||||||
.into_function()?;
|
|
||||||
return Ok(mlua::Value::Function(func));
|
return Ok(mlua::Value::Function(func));
|
||||||
}
|
}
|
||||||
// No installed package matches. Return a string so Lua
|
// No installed package matches. Return a string so Lua
|
||||||
// appends our reason to the aggregate require error.
|
// appends our reason to the aggregate require error.
|
||||||
let s = lua.create_string(&format!(
|
let s = lua.create_string(format!("\n\tno installed pmacs package named '{name}'"))?;
|
||||||
"\n\tno installed pmacs package named '{name}'"
|
|
||||||
))?;
|
|
||||||
Ok(mlua::Value::String(s))
|
Ok(mlua::Value::String(s))
|
||||||
},
|
})?;
|
||||||
)?;
|
|
||||||
|
|
||||||
// Append to the searcher list. Lua tables are 1-indexed; the
|
// Append to the searcher list. Lua tables are 1-indexed; the
|
||||||
// new searcher runs after every existing searcher (preload,
|
// 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)
|
let address = Address::parse(&address_str)
|
||||||
.map_err(|e| mlua::Error::external(BindingError::from(InstallError::Address(e))))?;
|
.map_err(|e| mlua::Error::external(BindingError::from(InstallError::Address(e))))?;
|
||||||
let version = semver::VersionReq::parse(&version_str).map_err(|e| {
|
let pin = parse_install_pin(t)?;
|
||||||
mlua::Error::external(BindingError::from(InstallError::InvalidVersionReq {
|
Ok(InstallSpec { address, pin })
|
||||||
value: version_str,
|
|
||||||
cause: e.to_string(),
|
|
||||||
}))
|
|
||||||
})?;
|
|
||||||
Ok(InstallSpec { address, version })
|
|
||||||
}
|
}
|
||||||
other => Err(mlua::Error::external(BindingError::InstallSpecWrongType {
|
other => Err(mlua::Error::external(BindingError::InstallSpecWrongType {
|
||||||
got: other.type_name().to_string(),
|
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
|
/// Read the required `project_root = "..."` field from the table form
|
||||||
/// of `install_project`'s spec.
|
/// 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())?;
|
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)
|
Ok(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,13 +104,71 @@ fn xdg_data_root() -> Result<PathBuf, InstallError> {
|
||||||
// InstallSpec
|
// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InstallSpec {
|
pub struct InstallSpec {
|
||||||
/// Address (resolved via `Address::parse`).
|
/// Address (resolved via `Address::parse`).
|
||||||
pub address: Address,
|
pub address: Address,
|
||||||
/// Semver constraint to match against the upstream's tags.
|
/// What the user pinned the install to. See [`InstallPin`].
|
||||||
pub version: VersionReq,
|
pub pin: InstallPin,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstallSpec {
|
impl InstallSpec {
|
||||||
|
|
@ -126,6 +184,14 @@ impl InstallSpec {
|
||||||
/// has no `@` separator and [`InstallError::Address`] /
|
/// has no `@` separator and [`InstallError::Address`] /
|
||||||
/// [`InstallError::InvalidVersionReq`] for the underlying parse
|
/// [`InstallError::InvalidVersionReq`] for the underlying parse
|
||||||
/// failures.
|
/// 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> {
|
pub fn parse_shorthand(s: &str) -> Result<Self, InstallError> {
|
||||||
let (addr, ver) =
|
let (addr, ver) =
|
||||||
s.rsplit_once('@')
|
s.rsplit_once('@')
|
||||||
|
|
@ -142,7 +208,10 @@ impl InstallSpec {
|
||||||
value: ver.to_string(),
|
value: ver.to_string(),
|
||||||
cause: e.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,
|
pub install_path: PathBuf,
|
||||||
/// 40-char commit hash of the installed snapshot.
|
/// 40-char commit hash of the installed snapshot.
|
||||||
pub commit: String,
|
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,
|
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,
|
pub version: Version,
|
||||||
/// The install scope this package was installed under.
|
/// The install scope this package was installed under.
|
||||||
pub scope: InstallScope,
|
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 {
|
impl InstalledPackage {
|
||||||
|
|
@ -243,33 +326,54 @@ impl Installer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install one package. See module docs for the step-by-step flow.
|
/// 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> {
|
pub fn install(&self, spec: &InstallSpec) -> Result<InstalledPackage, InstallError> {
|
||||||
let url = spec.address.to_git_url();
|
let url = spec.address.to_git_url();
|
||||||
let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?;
|
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 tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?;
|
||||||
let chosen =
|
let chosen =
|
||||||
best_match(&tags, &spec.version).ok_or_else(|| InstallError::NoMatchingVersion {
|
best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion {
|
||||||
address: url.clone(),
|
address: url.clone(),
|
||||||
req: spec.version.to_string(),
|
req: req.to_string(),
|
||||||
available: tags.clone(),
|
available: tags.clone(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 3. Resolve to commit.
|
|
||||||
let commit = self
|
let commit = self
|
||||||
.fetcher
|
.fetcher
|
||||||
.resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
|
.resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
|
||||||
.map_err(InstallError::Fetch)?;
|
.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
|
let manifest_bytes = self
|
||||||
.fetcher
|
.fetcher
|
||||||
.show_blob(&bare, &commit, "pmacs.toml")
|
.show_blob(&bare, &commit, "pmacs.toml")
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
FetchError::GitInvocation { stderr, .. } => InstallError::ManifestMissing {
|
FetchError::GitInvocation { stderr, .. } => InstallError::ManifestMissing {
|
||||||
address: url.clone(),
|
address: url.clone(),
|
||||||
tag: chosen.tag.clone(),
|
tag: tag_descriptor.clone(),
|
||||||
cause: stderr,
|
cause: stderr,
|
||||||
},
|
},
|
||||||
other => InstallError::Fetch(other),
|
other => InstallError::Fetch(other),
|
||||||
|
|
@ -277,42 +381,43 @@ impl Installer {
|
||||||
let manifest_str =
|
let manifest_str =
|
||||||
std::str::from_utf8(&manifest_bytes).map_err(|_| InstallError::ManifestNotUtf8 {
|
std::str::from_utf8(&manifest_bytes).map_err(|_| InstallError::ManifestNotUtf8 {
|
||||||
address: url.clone(),
|
address: url.clone(),
|
||||||
tag: chosen.tag.clone(),
|
tag: tag_descriptor.clone(),
|
||||||
})?;
|
})?;
|
||||||
let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?;
|
let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?;
|
||||||
|
|
||||||
// Refuse to install a package whose `pmacs_required` constraint
|
// Refuse to install a package whose `pmacs_required` constraint
|
||||||
// does not match the running pmacs version. The manifest field
|
// does not match the running pmacs version. Applies to every
|
||||||
// is a hard contract, not advisory: if a package declares
|
// pin kind: a package's declared API requirements are
|
||||||
// `pmacs_required = ">=2.0.0"` and we're 1.x, the package is
|
// independent of how the user pinned the revision.
|
||||||
// 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.
|
|
||||||
let running_pmacs = running_pmacs_version();
|
let running_pmacs = running_pmacs_version();
|
||||||
if !manifest.pmacs_required.matches(&running_pmacs) {
|
if !manifest.pmacs_required.matches(&running_pmacs) {
|
||||||
return Err(InstallError::PmacsVersionIncompatible {
|
return Err(InstallError::PmacsVersionIncompatible {
|
||||||
address: url.clone(),
|
address: url.clone(),
|
||||||
tag: chosen.tag.clone(),
|
tag: tag_descriptor.clone(),
|
||||||
required: manifest.pmacs_required.to_string(),
|
required: manifest.pmacs_required.to_string(),
|
||||||
running: running_pmacs.to_string(),
|
running: running_pmacs.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanity: the manifest's `version` should equal the resolved tag.
|
// For version pins only: cross-check that the manifest's
|
||||||
// We don't reject mismatches (some upstreams version-tag asymmetrically),
|
// declared version satisfies the constraint. The matched tag
|
||||||
// but we do require it to satisfy the requested constraint. The tag
|
// already satisfies it (we chose it that way); the strict
|
||||||
// already satisfies the constraint (we chose it that way), so the
|
// check is on the manifest, which catches packages whose tag
|
||||||
// strict check is on the manifest.
|
// and pmacs.toml version disagree. Branch and commit pins
|
||||||
if !spec.version.matches(&manifest.version) {
|
// 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 {
|
return Err(InstallError::ManifestVersionMismatch {
|
||||||
address: url.clone(),
|
address: url.clone(),
|
||||||
tag: chosen.tag.clone(),
|
tag: tag_descriptor.clone(),
|
||||||
manifest_version: manifest.version.to_string(),
|
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 install_root = self.install_root()?;
|
||||||
let basename = package_basename(manifest.name.as_str());
|
let basename = package_basename(manifest.name.as_str());
|
||||||
let install_path = install_root.join(basename);
|
let install_path = install_root.join(basename);
|
||||||
|
|
@ -326,12 +431,13 @@ impl Installer {
|
||||||
match existing {
|
match existing {
|
||||||
Some(prev) if prev == commit => {
|
Some(prev) if prev == commit => {
|
||||||
return Ok(InstalledPackage {
|
return Ok(InstalledPackage {
|
||||||
|
version: manifest.version.clone(),
|
||||||
manifest,
|
manifest,
|
||||||
install_path,
|
install_path,
|
||||||
commit,
|
commit,
|
||||||
tag: chosen.tag,
|
tag: tag_descriptor,
|
||||||
version: chosen.version,
|
|
||||||
scope: self.scope.clone(),
|
scope: self.scope.clone(),
|
||||||
|
pin: spec.pin.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
@ -361,12 +467,13 @@ impl Installer {
|
||||||
write_install_marker(&install_path, &commit)?;
|
write_install_marker(&install_path, &commit)?;
|
||||||
|
|
||||||
Ok(InstalledPackage {
|
Ok(InstalledPackage {
|
||||||
|
version: manifest.version.clone(),
|
||||||
manifest,
|
manifest,
|
||||||
install_path,
|
install_path,
|
||||||
commit,
|
commit,
|
||||||
tag: chosen.tag,
|
tag: tag_descriptor,
|
||||||
version: chosen.version,
|
|
||||||
scope: self.scope.clone(),
|
scope: self.scope.clone(),
|
||||||
|
pin: spec.pin.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -782,7 +889,10 @@ exports = ["samplepkg"]
|
||||||
fn shorthand_parses_github_address_with_caret_constraint() {
|
fn shorthand_parses_github_address_with_caret_constraint() {
|
||||||
let s = InstallSpec::parse_shorthand("github:user/repo@^1.0.0").unwrap();
|
let s = InstallSpec::parse_shorthand("github:user/repo@^1.0.0").unwrap();
|
||||||
assert!(matches!(s.address, Address::Github { .. }));
|
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]
|
#[test]
|
||||||
|
|
@ -790,7 +900,10 @@ exports = ["samplepkg"]
|
||||||
// SSH shorthand: `git:git@host:path`. The `@` in `git@host`
|
// SSH shorthand: `git:git@host:path`. The `@` in `git@host`
|
||||||
// must not be confused with the version separator.
|
// must not be confused with the version separator.
|
||||||
let s = InstallSpec::parse_shorthand("git:git@host:path/repo.git@=1.2.3").unwrap();
|
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 {
|
if let Address::Url(u) = s.address {
|
||||||
assert_eq!(u, "git@host:path/repo.git");
|
assert_eq!(u, "git@host:path/repo.git");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -876,7 +989,7 @@ exports = ["samplepkg"]
|
||||||
|
|
||||||
let spec = InstallSpec {
|
let spec = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
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();
|
let installed = installer.install(&spec).unwrap();
|
||||||
|
|
||||||
|
|
@ -918,7 +1031,7 @@ exports = ["samplepkg"]
|
||||||
|
|
||||||
let spec = InstallSpec {
|
let spec = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
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();
|
let installed = installer.install(&spec).unwrap();
|
||||||
assert_eq!(installed.tag, "v1.0.0");
|
assert_eq!(installed.tag, "v1.0.0");
|
||||||
|
|
@ -933,7 +1046,7 @@ exports = ["samplepkg"]
|
||||||
|
|
||||||
let spec = InstallSpec {
|
let spec = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
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();
|
let err = installer.install(&spec).unwrap_err();
|
||||||
match err {
|
match err {
|
||||||
|
|
@ -953,7 +1066,7 @@ exports = ["samplepkg"]
|
||||||
|
|
||||||
let spec = InstallSpec {
|
let spec = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
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();
|
let first = installer.install(&spec).unwrap();
|
||||||
// Drop a sentinel; idempotent re-install should not blow it away.
|
// Drop a sentinel; idempotent re-install should not blow it away.
|
||||||
|
|
@ -976,14 +1089,14 @@ exports = ["samplepkg"]
|
||||||
// First install at 1.0.0.
|
// First install at 1.0.0.
|
||||||
let spec_v1 = InstallSpec {
|
let spec_v1 = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
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();
|
installer.install(&spec_v1).unwrap();
|
||||||
|
|
||||||
// Second install at 1.1.0 to the same install path: refuse.
|
// Second install at 1.1.0 to the same install path: refuse.
|
||||||
let spec_v2 = InstallSpec {
|
let spec_v2 = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
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();
|
let err = installer.install(&spec_v2).unwrap_err();
|
||||||
assert!(matches!(err, InstallError::AlreadyInstalled { .. }));
|
assert!(matches!(err, InstallError::AlreadyInstalled { .. }));
|
||||||
|
|
@ -1057,7 +1170,7 @@ exports = ["samplepkg"]
|
||||||
|
|
||||||
let spec = InstallSpec {
|
let spec = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
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() {
|
match installer.install(&spec).unwrap_err() {
|
||||||
|
|
@ -1086,7 +1199,7 @@ exports = ["samplepkg"]
|
||||||
|
|
||||||
let spec = InstallSpec {
|
let spec = InstallSpec {
|
||||||
address: Address::Url(file_url(&bare)),
|
address: Address::Url(file_url(&bare)),
|
||||||
version: VersionReq::parse("^1.0.0").unwrap(),
|
pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
|
||||||
};
|
};
|
||||||
installer
|
installer
|
||||||
.install(&spec)
|
.install(&spec)
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,7 @@ pub mod manifest;
|
||||||
|
|
||||||
pub use address::{Address, AddressError};
|
pub use address::{Address, AddressError};
|
||||||
pub use fetcher::{FetchError, Fetcher, RefSpec};
|
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};
|
pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName};
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,13 @@
|
||||||
//! marker is `"\n[<basename(argv[0])> exited with code N]\n"`.
|
//! marker is `"\n[<basename(argv[0])> exited with code N]\n"`.
|
||||||
|
|
||||||
use pmacs::editor::EditorState;
|
use pmacs::editor::EditorState;
|
||||||
|
use std::fmt::Write as _;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
static PUMP_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
/// Locate a shell binary for tests that require one. Returns the
|
/// Locate a shell binary for tests that require one. Returns the
|
||||||
/// resolved path or `None` if the shell is neither at `PMACS_TEST_<NAME>`
|
/// resolved path or `None` if the shell is neither at `PMACS_TEST_<NAME>`
|
||||||
/// nor on `PATH`. Per-test selective skipping (rather than skipping
|
/// 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
|
/// `poll_until` pattern but routes through `tick_processes` so the M6.5
|
||||||
/// after-tick contract is exercised end-to-end.
|
/// after-tick contract is exercised end-to-end.
|
||||||
fn run_with_pump(setup_chunk: &str, predicate_chunk: &str, timeout_ms: u64) {
|
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();
|
let mut editor = EditorState::new();
|
||||||
editor
|
editor
|
||||||
.lua_host
|
.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
|
/// running-state (process must have started before we type) and then
|
||||||
/// on history matching the expected output.
|
/// on history matching the expected output.
|
||||||
fn run_shell_smoke_test(shell_path: &std::path::Path, argv_extra: &[&str]) {
|
fn run_shell_smoke_test(shell_path: &std::path::Path, argv_extra: &[&str]) {
|
||||||
use std::fmt::Write as _;
|
|
||||||
let mut argv_lua = String::new();
|
let mut argv_lua = String::new();
|
||||||
write!(&mut argv_lua, r#""{}""#, shell_path.display()).unwrap();
|
write!(&mut argv_lua, r#""{}""#, shell_path.display()).unwrap();
|
||||||
for a in argv_extra {
|
for a in argv_extra {
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,7 @@ use tempfile::TempDir;
|
||||||
/// (relative to the package root) with the supplied Lua body.
|
/// (relative to the package root) with the supplied Lua body.
|
||||||
/// Returns `(tempdir, bare_path)` --- the tempdir owns both the work
|
/// Returns `(tempdir, bare_path)` --- the tempdir owns both the work
|
||||||
/// tree and the bare clone.
|
/// tree and the bare clone.
|
||||||
fn make_package_with_entry(
|
fn make_package_with_entry(name: &str, entry_path: &str, entry_body: &str) -> (TempDir, PathBuf) {
|
||||||
name: &str,
|
|
||||||
entry_path: &str,
|
|
||||||
entry_body: &str,
|
|
||||||
) -> (TempDir, PathBuf) {
|
|
||||||
let td = tempfile::tempdir().expect("tempdir");
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
let work = td.path().join("work");
|
let work = td.path().join("work");
|
||||||
let bare = td.path().join("upstream.git");
|
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}"
|
"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}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue