Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
- 🔒 Lockfile `lazy-lock.json` to keep track of installed plugins
- 🔎 Automatically check for updates
- 📋 Commit, branch, tag, version, and full [Semver](https://devhints.io/semver) support
- 🛡️ Optional `minimum_release_age` to delay freshly published commits/tags (supply-chain safety, inspired by pnpm/mise/Renovate/Dependabot)
- 📈 Statusline component to see the number of pending updates
- 🎨 Automatically lazy-loads colorschemes

Expand Down
24 changes: 24 additions & 0 deletions lua/lazy/core/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ M.defaults = {
-- default `cond` you can use to globally disable a lot of plugins
-- when running inside vscode for example
cond = nil, ---@type boolean|fun(self:LazyPlugin):boolean|nil
-- Minimum age a commit or semver tag must have before it is considered
-- for updates. Mitigates supply-chain attacks by waiting until a ref has
-- been published for the specified period before adopting it. Accepts an
-- integer (seconds) or a single-unit string like "30m", "24h", "7d",
-- "2w", "1y". Combined forms ("7d12h", "1d 2h") are not supported --
-- use a single unit. Set to nil (default) to disable globally.
--
-- Per-plugin overrides are accepted on each spec entry:
-- minimum_release_age = "..." -- set a custom value for this plugin
-- minimum_release_age = false -- force-disable for this plugin even
-- -- when a global default is set
-- (Setting `false` globally is equivalent to nil.)
--
-- Explicit `commit=`/`tag=` pins and `pin = true` plugins are not
-- affected.
minimum_release_age = nil, ---@type string|number|nil
-- When true, allow :Lazy update to roll back to an older commit that
-- satisfies minimum_release_age, even if the currently installed commit
-- is newer. When false (default), an already-installed plugin keeps its
-- current commit when it is past what minimum_release_age would otherwise
-- pick; the newer commit is still surfaced via the
-- "Pending (minimum_release_age = ...)" UI section. Has no effect on
-- fresh installs, which always honor minimum_release_age.
minimum_release_age_downgrade = false, ---@type boolean
},
-- leave nil when passing the spec as the first argument to setup()
spec = nil, ---@type LazySpec
Expand Down
49 changes: 48 additions & 1 deletion lua/lazy/core/plugin.lua
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,53 @@ function M.find_local_spec()
end
end

--- Reads the origin URL of the lazy.nvim clone that the running process is
--- loaded from. Returns nil when the directory is not a git checkout or the
--- file cannot be read (e.g. an immutable /nix/store path).
---@return string?
local function read_self_origin()
local f = io.open(Config.me .. "/.git/config", "r")
if not f then
return nil
end
local config = f:read("*a")
f:close()
local in_origin = false
for line in config:gmatch("[^\n]+") do
local section = line:match("^%s*%[(.+)%]%s*$")
if section then
in_origin = section:match('^remote%s+"origin"$') ~= nil
elseif in_origin then
local key, value = line:match("^%s*(%S+)%s*=%s*(.-)%s*$")
if key == "url" then
return value
end
end
end
return nil
end

--- Builds the spec entry that lazy.nvim auto-injects for itself, so that
--- :Lazy can manage updates to the very clone it is running from. Forks
--- (and any non-folke clones) work out of the box without touching this
--- file.
---@return LazyPluginSpec
local function self_spec()
local origin = read_self_origin()
if origin and origin ~= "" then
-- Prefer the familiar "owner/repo" GitHub shorthand when possible.
local short = origin:match("github%.com[:/](.+)$")
if short then
short = short:gsub("%.git$", "")
if short:match("^[^/]+/[^/]+$") then
return { short }
end
end
return { url = origin, name = "lazy.nvim" }
end
return { "folke/lazy.nvim" }
end

function M.load()
M.loading = true
-- load specs
Expand All @@ -330,7 +377,7 @@ function M.load()
vim.deepcopy(Config.options.spec),
}
specs[#specs + 1] = M.find_local_spec()
specs[#specs + 1] = { "folke/lazy.nvim" }
specs[#specs + 1] = self_spec()

Config.spec:parse(specs)

Expand Down
7 changes: 6 additions & 1 deletion lua/lazy/manage/checker.lua
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,16 @@ function M.fast_check(opts)
-- only if local is behind upstream (if the git log task gives no output)
if plugin._.installed and not (plugin.pin or plugin._.is_local) then
plugin._.updates = nil
plugin._.pending_age = nil
local info = Git.info(plugin.dir)
local ok, target = pcall(Git.get_target, plugin)
if ok and info and target and not Git.eq(info, target) then
local raw_ok, raw_target = pcall(Git.get_target, plugin, true)
if ok and info and target and not Git.eq(info, target) and not Git.is_downgrade(plugin, info, target) then
plugin._.updates = { from = info, to = target }
end
if raw_ok and info then
plugin._.pending_age = Git.detect_pending_age(plugin, info, target, raw_target)
end
end
end
M.report(opts.report ~= false)
Expand Down
191 changes: 189 additions & 2 deletions lua/lazy/manage/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,170 @@ function M.get_commit(repo, branch, origin)
end

---@param plugin LazyPlugin
---@return integer? seconds, or nil when disabled for this plugin
function M.get_minimum_release_age(plugin)
local value = plugin.minimum_release_age
if value == nil then
value = Config.options.defaults.minimum_release_age
end
return Util.parse_age(value)
end

---@param plugin LazyPlugin
---@return boolean true when downgrades to an older mature commit are allowed
function M.allow_downgrade(plugin)
local v = plugin.minimum_release_age_downgrade
if v == nil then
v = Config.options.defaults.minimum_release_age_downgrade
end
return v == true
end

--- Returns the timestamp that the age filter uses for a GitInfo target.
--- For tag targets this is the tag's creatordate (matches get_target's
--- semver-path filter); otherwise it falls back to the commit's committer
--- date. Returns nil when neither can be resolved.
---@param repo string
---@param target GitInfo
---@return integer?
function M.target_time(repo, target)
if target.tag then
local tag_times = M.get_tag_times(repo)
local t = tag_times[target.tag]
if t then
return t
end
end
if target.commit then
return M.commit_time(repo, target.commit)
end
return nil
end

---@param repo string
---@param ancestor string commit SHA that should be the ancestor
---@param descendant string commit SHA that should be the descendant
---@return boolean true if `ancestor` is an ancestor of `descendant`
function M.is_ancestor(repo, ancestor, descendant)
local ok, code = pcall(function()
local _, c = Process.exec({ "git", "merge-base", "--is-ancestor", ancestor, descendant }, { cwd = repo })
return c
end)
return ok and code == 0
end

--- Returns true when applying `target` would roll `info` back to one of its
--- ancestors (i.e. info is already newer than what minimum_release_age would
--- pick) and the active policy disallows that downgrade. Fresh installs
--- (`plugin._.cloned == true`) are exempt and always return false so the
--- initial checkout honors the age constraint.
---@param plugin LazyPlugin
---@param info GitInfo
---@param target GitInfo
---@return boolean
function M.is_downgrade(plugin, info, target)
if M.allow_downgrade(plugin) then
return false
end
if plugin._.cloned then
return false
end
if not (info.commit and target.commit) then
return false
end
if M.eq(info, target) then
return false
end
return M.is_ancestor(plugin.dir, target.commit, info.commit)
end

--- Builds the `pending_age` state when `raw_target` (the age-ignoring target)
--- differs from what is effectively being applied. Returns nil when nothing
--- is being held back.
---@param plugin LazyPlugin
---@param info GitInfo
---@param target GitInfo?
---@param raw_target GitInfo?
---@return {from:GitInfo, to:GitInfo, eligible_at:integer?}?
function M.detect_pending_age(plugin, info, target, raw_target)
if not (raw_target and info) then
return nil
end
local effective = target or info
if M.eq(effective, raw_target) then
return nil
end
local age = M.get_minimum_release_age(plugin)
local source_time = M.target_time(plugin.dir, raw_target)
return {
from = effective,
to = raw_target,
eligible_at = source_time and age and (source_time + age) or nil,
}
end

---@param repo string
---@return table<string, integer>
function M.get_tag_times(repo)
---@type table<string, integer>
local ret = {}
local ok, lines = pcall(function()
return Process.exec(
{ "git", "for-each-ref", "--format=%(refname:strip=2) %(creatordate:unix)", "refs/tags" },
{ cwd = repo }
)
end)
if not ok then
return ret
end
for _, line in ipairs(lines) do
local tag, ts = line:match("^(.+) (%d+)$")
if tag then
ret[tag] = tonumber(ts)
end
end
return ret
end

---@param repo string
---@param ref string
---@return integer?
function M.commit_time(repo, ref)
local ok, lines = pcall(function()
return Process.exec({ "git", "show", "-s", "--format=%ct", ref }, { cwd = repo })
end)
if not ok then
return nil
end
return tonumber(lines[1])
end

---@param repo string
---@param branch string
---@param cutoff integer Unix timestamp; commit must be at or before this time
---@return string?
function M.last_commit_before(repo, branch, cutoff)
local ok, lines = pcall(function()
return Process.exec({
"git",
"log",
"-1",
"--format=%H",
"--before=@" .. cutoff,
"refs/remotes/origin/" .. branch,
}, { cwd = repo })
end)
if not ok then
return nil
end
local commit = lines[1]
return commit and commit ~= "" and commit or nil
end

---@param plugin LazyPlugin
---@param ignore_age? boolean If true, bypass minimum_release_age filtering.
---@return GitInfo?
function M.get_target(plugin)
function M.get_target(plugin, ignore_age)
if plugin._.is_local then
local info = M.info(plugin.dir)
local branch = assert(info and info.branch or M.get_branch(plugin))
Expand All @@ -138,9 +300,25 @@ function M.get_target(plugin)
}
end

local age = not ignore_age and M.get_minimum_release_age(plugin) or nil
local cutoff = age and (os.time() - age) or nil

local version = (plugin.version == nil and plugin.branch == nil) and Config.options.defaults.version or plugin.version
if version then
local last = Semver.last(M.get_versions(plugin.dir, version))
local versions = M.get_versions(plugin.dir, version)
if cutoff and #versions > 0 then
local tag_times = M.get_tag_times(plugin.dir)
local filtered = vim.tbl_filter(function(v)
local t = tag_times[v.tag]
return t ~= nil and t <= cutoff
end, versions)
-- An age constraint that rejects every candidate tag means "wait".
if #filtered == 0 then
return nil
end
versions = filtered
end
local last = Semver.last(versions)
if last then
return {
branch = branch,
Expand All @@ -150,6 +328,15 @@ function M.get_target(plugin)
}
end
end

if cutoff then
local commit = M.last_commit_before(plugin.dir, branch, cutoff)
if commit then
return { branch = branch, commit = commit }
end
return nil
end

return { branch = branch, commit = M.get_commit(plugin.dir, branch, true) }
end

Expand Down
38 changes: 34 additions & 4 deletions lua/lazy/manage/task/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,27 @@ M.log = {
table.insert(args, self.plugin._.updated.from .. ".." .. (self.plugin._.updated.to or "HEAD"))
elseif opts.check then
info = assert(Git.info(self.plugin.dir))
target = assert(Git.get_target(self.plugin))
target = Git.get_target(self.plugin)
local raw_target = Git.get_target(self.plugin, true)
self.plugin._.pending_age = Git.detect_pending_age(self.plugin, info, target, raw_target)
if not target then
-- minimum_release_age is blocking every candidate; fall back to info
-- so the log range becomes a no-op.
target = info
elseif Git.is_downgrade(self.plugin, info, target) then
-- info is already past what minimum_release_age would pick; don't
-- treat that as an update.
target = info
end
if not target.commit then
for k, v in pairs(target) do
error(k .. " '" .. v .. "' not found")
end
error("no target commit found")
end
assert(target.commit, self.plugin.name .. " " .. target.branch)
if not self.plugin._.is_local then
if Git.eq(info, target) then
if Config.options.checker.check_pinned then
if Config.options.checker.check_pinned and target.branch then
local last_commit = Git.get_commit(self.plugin.dir, target.branch, true)
if not Git.eq(info, { commit = last_commit }) then
self.plugin._.outdated = true
Expand Down Expand Up @@ -317,7 +327,27 @@ M.checkout = {
run = function(self, opts)
throttle.wait()
local info = assert(Git.info(self.plugin.dir))
local target = assert(Git.get_target(self.plugin))
local target = Git.get_target(self.plugin)

if not target then
if self.plugin._.cloned and Git.get_target(self.plugin, true) then
-- Fresh install where minimum_release_age blocks every candidate.
-- Refusing to silently land on whatever the clone happens to point
-- at (e.g. HEAD), since that defeats the constraint the user set.
error(
"minimum_release_age has no eligible commit/tag yet for "
.. self.plugin.name
.. "; relax the constraint or retry once a candidate matures"
)
end
-- For an already-installed plugin, keep the current commit so the
-- checkout is a no-op until the next mature candidate is reached.
target = info
elseif Git.is_downgrade(self.plugin, info, target) then
-- info is already past what minimum_release_age would pick; don't
-- downgrade it unless explicitly opted in.
target = info
end

-- if the plugin is pinned and we did not just clone it,
-- then don't update
Expand Down
Loading