mirror of
https://github.com/kristoferssolo/solorice.git
synced 2025-10-21 20:10:34 +00:00
387 lines
12 KiB
Lua
387 lines
12 KiB
Lua
---@param msg string
|
|
local notify_warn = function(msg)
|
|
ya.notify { title = "ZFS", content = msg, level = "warn", timeout = 5 }
|
|
end
|
|
|
|
---@param msg string
|
|
local notify_error = function(msg)
|
|
ya.notify { title = "ZFS", content = msg, level = "error", timeout = 5 }
|
|
end
|
|
|
|
---@param arr table
|
|
---@param predicate fun(value: any): boolean
|
|
---@return number|nil # index if found, nil if not found
|
|
local find_index = function(arr, predicate)
|
|
for i, value in ipairs(arr) do
|
|
if predicate(value) then
|
|
return i
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Verify if `sudo` is already authenticated
|
|
--- @return boolean
|
|
local function sudo_already()
|
|
local status = Command("sudo"):args({ "--validate", "--non-interactive" }):status()
|
|
assert(status, "Failed to run `sudo --validate --non-interactive`")
|
|
return status.success
|
|
end
|
|
|
|
--- Run a program with `sudo` privilege
|
|
--- @param program string
|
|
--- @param args table
|
|
--- @return Output|nil output
|
|
--- @return integer|nil err
|
|
--- nil: no error
|
|
--- 1: sudo failed
|
|
local function run_with_sudo(program, args)
|
|
local cmd = Command("sudo"):args({ program, table.unpack(args) }):stdout(Command.PIPED):stderr(Command.PIPED)
|
|
if sudo_already() then
|
|
return cmd:output()
|
|
end
|
|
|
|
local permit = ya.hide()
|
|
print(string.format("Sudo password required to run: `%s %s`", program, table.concat(args, " ")))
|
|
local output = cmd:output()
|
|
permit:drop()
|
|
|
|
if output.status.success or sudo_already() then
|
|
return output
|
|
end
|
|
return nil, 1
|
|
end
|
|
|
|
|
|
---@return string
|
|
local get_cwd = ya.sync(function()
|
|
return tostring(cx.active.current.cwd)
|
|
end)
|
|
|
|
---@param s string
|
|
---@return string
|
|
local trim = function(s)
|
|
return s:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
---@param cwd string
|
|
---@return string|nil
|
|
local get_filesystem_type = function(cwd)
|
|
local stat, _ = Command("stat"):args({ "-f", "-c", "%T", cwd }):output()
|
|
if not stat.status.success then
|
|
return nil
|
|
end
|
|
return trim(stat.stdout)
|
|
end
|
|
|
|
---@param cwd string
|
|
---@return string|nil
|
|
local zfs_dataset = function(cwd)
|
|
local df, _ = Command("df"):args({ "--output=source", cwd }):output()
|
|
local dataset = nil
|
|
for line in df.stdout:gmatch("[^\r\n]+") do
|
|
-- dataset is last line in output
|
|
dataset = line
|
|
end
|
|
return dataset
|
|
end
|
|
|
|
---@param dataset string
|
|
---@return string|nil
|
|
local zfs_mountpoint = function(dataset)
|
|
local zfs, _ = Command("zfs"):args({ "get", "-H", "-o", "value", "mountpoint", dataset }):output()
|
|
|
|
-- not a dataset!
|
|
if not zfs.status.success then
|
|
return nil
|
|
end
|
|
|
|
-- legacy mountpoint, search for actual mountpoint using df
|
|
if zfs.stdout == "legacy\n" then
|
|
local df, _ = Command("df"):output()
|
|
if not df.status.success then
|
|
return nil
|
|
end
|
|
|
|
for line in df.stdout:gmatch("[^\r\n]+") do
|
|
-- match start of line
|
|
if string.sub(line, 1, #dataset) == dataset then
|
|
local mountpoint = nil
|
|
for field in line:gmatch("%S+") do
|
|
-- mountpoint is last field in df output
|
|
mountpoint = field
|
|
end
|
|
return mountpoint
|
|
end
|
|
end
|
|
else
|
|
return zfs.stdout:gsub("\n$", "")
|
|
end
|
|
|
|
-- shouldn't be here
|
|
return nil
|
|
end
|
|
|
|
-- returns the path relative to the mountpoint / snapshot
|
|
---@param cwd string
|
|
---@param mountpoint string
|
|
local zfs_relative = function(cwd, mountpoint)
|
|
-- relative path to get mountpoint
|
|
local relative = (cwd:sub(0, #mountpoint) == mountpoint) and cwd:sub(#mountpoint + 1) or cwd
|
|
|
|
-- is a snapshot dir, strip everything after "/snapshot"
|
|
if cwd:find(".zfs/snapshot") ~= nil then
|
|
local snapshot_pos = cwd:find("/snapshot")
|
|
|
|
-- everything after the "/snapshot/"
|
|
local after = cwd:sub(snapshot_pos + #"/snapshot" + 1)
|
|
local first_slash = after:find("/")
|
|
-- root of snapshot?
|
|
if first_slash == nil then
|
|
return "/"
|
|
else
|
|
return after:sub(first_slash)
|
|
end
|
|
end
|
|
|
|
return relative
|
|
end
|
|
|
|
---@class Snapshot
|
|
---@field name string
|
|
---@field path string
|
|
|
|
---@param dataset string
|
|
---@param mountpoint string
|
|
---@param relative string
|
|
---@return Snapshot[]
|
|
local zfs_snapshots = function(dataset, mountpoint, relative)
|
|
-- -S is for reverse order
|
|
local zfs_snapshots, _ = Command("zfs"):args({ "list", "-H", "-t", "snapshot", "-o", "name", "-S", "creation",
|
|
dataset })
|
|
:output()
|
|
|
|
if not zfs_snapshots.status.success then
|
|
return {}
|
|
end
|
|
|
|
---@type Snapshot[]
|
|
local snapshots = {}
|
|
for snapshot in zfs_snapshots.stdout:gmatch("[^\r\n]+") do
|
|
-- in the format dataset@snapshot
|
|
local sep = snapshot:find("@")
|
|
local id = snapshot:sub(sep + 1)
|
|
|
|
table.insert(snapshots, {
|
|
id = id,
|
|
path = mountpoint .. "/.zfs/snapshot/" .. id .. relative,
|
|
})
|
|
end
|
|
return snapshots
|
|
end
|
|
|
|
---@param cwd string
|
|
---@return string|nil
|
|
local function btrfs_mountpoint(cwd)
|
|
local cmd, _ = Command("findmnt"):args({ "-no", "TARGET", "-T", cwd }):output()
|
|
if not cmd.status.success then
|
|
return nil
|
|
end
|
|
return trim(cmd.stdout)
|
|
end
|
|
|
|
---Returns the current uuid and the parent uuid
|
|
---@param cwd string
|
|
---@return string|nil, string|nil
|
|
local function btrfs_uuids(cwd)
|
|
local cmd, _ = run_with_sudo("btrfs", { "subvolume", "show", cwd })
|
|
if not cmd then
|
|
return nil
|
|
end
|
|
|
|
local parent_uuid = nil
|
|
local uuid = nil
|
|
for line in cmd.stdout:gmatch("[^\r\n]+") do
|
|
local parent_uuid_re = line:match("^%s*Parent UUID:%s*(%S+)")
|
|
if parent_uuid_re then
|
|
parent_uuid = trim(parent_uuid_re)
|
|
end
|
|
|
|
local uuid_re = line:match("^%s*UUID:%s*(%S+)")
|
|
if uuid_re then
|
|
uuid = trim(uuid_re)
|
|
end
|
|
end
|
|
return parent_uuid, uuid
|
|
end
|
|
|
|
---@param mountpoint string
|
|
---@param current_uuid string
|
|
---@param current_parent_uuid string|nil
|
|
---@return { snapshots: Snapshot[], latest_path: string, current_snapshot_id: string }
|
|
local function btrfs_snapshots(mountpoint, current_uuid, current_parent_uuid)
|
|
local snapshots_cmd, _ = run_with_sudo("btrfs", { "subvolume", "list", "-q", "-u", mountpoint })
|
|
if not snapshots_cmd then
|
|
return {}
|
|
end
|
|
|
|
local snapshots = {}
|
|
local latest_path = ""
|
|
local current_snapshot_id = ""
|
|
|
|
for line in snapshots_cmd.stdout:gmatch("[^\r\n]+") do
|
|
local pattern = "ID (%d+) gen %d+ top level %d+ parent_uuid ([%w-]+)%s+uuid ([%w-]+) path (%S+)"
|
|
-- Extract the fields
|
|
local subvol_id, parent_uuid, uuid, name = line:match(pattern)
|
|
parent_uuid = trim(parent_uuid)
|
|
|
|
local path = mountpoint .. "/" .. name
|
|
local is_parent = false
|
|
|
|
if current_parent_uuid == "-" then
|
|
if parent_uuid == "-" and uuid == current_uuid then
|
|
is_parent = true
|
|
end
|
|
else
|
|
if uuid == current_parent_uuid then
|
|
is_parent = true
|
|
end
|
|
end
|
|
|
|
if is_parent then
|
|
latest_path = path
|
|
end
|
|
|
|
if uuid == current_uuid and not is_parent then
|
|
current_snapshot_id = name
|
|
end
|
|
|
|
if not is_parent then
|
|
table.insert(snapshots, {
|
|
id = name,
|
|
subvol_id = subvol_id, -- used only for sorting
|
|
path = path,
|
|
})
|
|
end
|
|
end
|
|
|
|
-- Sort snapshots by time descending
|
|
table.sort(snapshots, function(a, b)
|
|
return a.subvol_id > b.subvol_id
|
|
end)
|
|
|
|
return { snapshots = snapshots, latest_path = latest_path, current_snapshot_id = current_snapshot_id }
|
|
end
|
|
|
|
return {
|
|
entry = function(_, job)
|
|
local action = job.args[1]
|
|
local cwd = get_cwd()
|
|
|
|
if action ~= "exit" and action ~= "prev" and action ~= "next" then
|
|
return notify_error("Invalid action: " .. action)
|
|
end
|
|
|
|
local fs_type = get_filesystem_type(cwd)
|
|
if fs_type ~= "zfs" and fs_type ~= "btrfs" then
|
|
return notify_error("Current directory is not on a BTRFS / ZFS filesystem.")
|
|
end
|
|
|
|
local current_snapshot_id = ""
|
|
local latest_path = ""
|
|
local snapshots = {}
|
|
|
|
if fs_type == "zfs" then
|
|
local dataset = zfs_dataset(cwd)
|
|
if dataset == nil then
|
|
return notify_error("Current directory is not within a ZFS dataset.")
|
|
end
|
|
|
|
if cwd:find(".zfs/snapshot") ~= nil then
|
|
-- in the format dataset@snapshot
|
|
local sep = dataset:find("@")
|
|
current_snapshot_id = dataset:sub(sep + 1)
|
|
dataset = dataset:sub(1, sep - 1)
|
|
end
|
|
|
|
local mountpoint = zfs_mountpoint(dataset)
|
|
if mountpoint == nil then
|
|
return notify_error("Current directory is not within a ZFS dataset.")
|
|
end
|
|
|
|
-- NOTE: relative already has leading "/"
|
|
local relative = zfs_relative(cwd, mountpoint)
|
|
|
|
latest_path = mountpoint .. relative
|
|
snapshots = zfs_snapshots(dataset, mountpoint, relative)
|
|
elseif fs_type == "btrfs" then
|
|
local mountpoint = btrfs_mountpoint(cwd)
|
|
local parent_uuid, uuid = btrfs_uuids(cwd)
|
|
|
|
if mountpoint == nil or uuid == nil then
|
|
return notify_error("Current directory is not within a BTRFS subvolume.")
|
|
end
|
|
|
|
local ret = btrfs_snapshots(mountpoint, uuid, parent_uuid)
|
|
snapshots = ret.snapshots
|
|
latest_path = ret.latest_path
|
|
current_snapshot_id = ret.current_snapshot_id
|
|
end
|
|
|
|
if action == "exit" then
|
|
ya.manager_emit("cd", { latest_path })
|
|
return
|
|
end
|
|
|
|
if #snapshots == 0 then
|
|
return notify_warn("No snapshots found.")
|
|
end
|
|
|
|
---@param start_idx integer
|
|
---@param end_idx integer
|
|
---@param step integer
|
|
local find_and_goto_snapshot = function(start_idx, end_idx, step)
|
|
if start_idx == 0 then
|
|
-- going from newest snapshot to current state
|
|
return ya.manager_emit("cd", { latest_path })
|
|
elseif start_idx < 0 then
|
|
return notify_warn("No earlier snapshots found.")
|
|
elseif start_idx > #snapshots then
|
|
return notify_warn("No earlier snapshots found.")
|
|
end
|
|
|
|
for i = start_idx, end_idx, step do
|
|
local snapshot_dir = snapshots[i].path
|
|
if io.open(snapshot_dir, "r") then
|
|
return ya.manager_emit("cd", { snapshot_dir })
|
|
end
|
|
end
|
|
|
|
local direction = action == "prev" and "earlier" or "later"
|
|
return notify_warn("No " .. direction .. " snapshots found.")
|
|
end
|
|
|
|
-- NOTE: latest snapshot is first in list
|
|
if current_snapshot_id == "" then
|
|
if action == "prev" then
|
|
-- go to latest snapshot
|
|
return find_and_goto_snapshot(1, #snapshots, 1)
|
|
elseif action == "next" then
|
|
return notify_warn("No later snapshots found.")
|
|
end
|
|
end
|
|
|
|
-- has current snapshot
|
|
local idx = find_index(snapshots, function(snapshot) return snapshot.id == current_snapshot_id end)
|
|
if idx == nil then
|
|
return notify_error("Snapshot not found.")
|
|
end
|
|
|
|
if action == "prev" then
|
|
find_and_goto_snapshot(idx + 1, #snapshots, 1)
|
|
elseif action == "next" then
|
|
find_and_goto_snapshot(idx - 1, 1, -1)
|
|
end
|
|
end,
|
|
}
|