From b93e12e49da2cc0f006d78848bba9bdac5fa7695 Mon Sep 17 00:00:00 2001 From: Jonathan Warykowski Date: Fri, 24 Jul 2026 21:23:05 +1000 Subject: [PATCH 1/2] feat: board-scoped workflow + board management + stats - rename project scoping to board (--board, ShepherdBoards, config.board) - board.lua: switcher with switch/rename/archive/delete + unarchive - ShepherdStats opens the native charts (shepherd 0.17.0: stats subcommand, not the removed --stats launch flag) - README swept project->board to match the code --- README.md | 52 ++++++--- lua/shepherd/board.lua | 100 +++++++++++++++++ lua/shepherd/init.lua | 231 ++++++++++++++++++++++++++++++++++------ tests/shepherd_spec.lua | 97 +++++++++++++---- 4 files changed, 413 insertions(+), 67 deletions(-) create mode 100644 lua/shepherd/board.lua diff --git a/README.md b/README.md index 509d1c1..8880e07 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,15 @@ plugin list: ```lua return { "jwarykowski/nvim-shepherd", - cmd = { "Shepherd", "ShepherdAdd", "ShepherdList", "ShepherdCapture" }, + cmd = { + "Shepherd", + "ShepherdAdd", + "ShepherdList", + "ShepherdCapture", + "ShepherdStats", + "ShepherdBoards", + "ShepherdBoardsArchived", + }, keys = { { "T", "Shepherd", desc = "shepherd board" }, { "tg", "Shepherd!", desc = "shepherd global view (all boards)" }, @@ -33,10 +41,12 @@ return { { "tl", "ShepherdList", desc = "shepherd list / pick" }, { "tc", "ShepherdCapture", desc = "shepherd capture line" }, { "tc", ":ShepherdCapture", mode = "x", desc = "shepherd capture selection" }, + { "ts", "ShepherdStats", desc = "shepherd stats" }, + { "tp", "ShepherdBoards", desc = "shepherd boards" }, }, opts = { - -- per-repo project board (own file under ~/.config/shepherd/projects/) - project = function() + -- per-repo board (own file under ~/.config/shepherd/boards/) + board = function() return vim.fn.fnamemodify(vim.fn.getcwd(), ":t") end, }, @@ -53,15 +63,27 @@ return { exits (`q`). - `:Shepherd work` — open with an explicit filter, overriding the configured one for that view. -- `:Shepherd!` — open the read-only global view across all project boards +- `:Shepherd!` — open the read-only global view across all boards (`shepherd --all`). - `:ShepherdAdd` — prompt for a todo, then `shepherd add` it. An open board reloads and shows it within ~2s. - `:ShepherdAdd deploy api @work !h due:tomorrow` — add directly, with the same quick-add tokens the board accepts. -- `:ShepherdList` — pick an item (`vim.ui.select`), then mark it done/undone or - remove it. Uses whatever `vim.ui.select` UI you have (dressing, snacks, - telescope-ui-select), or the built-in menu. +- `:ShepherdList` — pick an item (`vim.ui.select`), then act on it: toggle + done/undone, **edit** (a pre-filled quick-add line — text, `@category`, + `!priority`, `due:`, `defer:`, `link:`, `note:`), set a **status**, add a + **subtask**, **rm**, or **open link** when it has one. Subtasks show indented + under their parent and take the same actions. Uses whatever `vim.ui.select` UI + you have (dressing, snacks, telescope-ui-select), or the built-in menu. +- `:ShepherdList work` — pick within a filtered view. `:ShepherdList!` picks + across all boards (read-only — items show their `[board]`; only open-link is + offered). +- `:ShepherdStats` — open shepherd's stats charts in the floating terminal. +- `:ShepherdBoards` — pick a board (shows `open/total`, `*` marks current), + then **switch** the session to it, **rename**, **archive**, or **delete** it + (delete shows a dry-run preview and asks to confirm first). Switching overrides + the configured `board` until you switch again or restart. +- `:ShepherdBoardsArchived` — pick an archived board to unarchive. - `:ShepherdCapture` — turn the current line into a todo; in visual mode (`:'<,'>ShepherdCapture`) the selection. Strips a leading comment marker and `TODO:`/`FIXME:`, then opens the add prompt pre-filled so you can tweak it. @@ -91,8 +113,8 @@ Native statusline: vim.o.statusline = "%{v:lua.require'shepherd'.status()}" ``` -The count covers the configured project's board (all todos on it, independent -of `config.filter`); with no `project` set, the default board. A refresh +The count covers the configured board (all todos on it, independent +of `config.filter`); with no `board` set, the default board. A refresh fires the `User ShepherdStatusUpdate` autocmd — hook it if your statusline needs a manual redraw: @@ -111,7 +133,7 @@ Defaults: require("shepherd").setup({ cmd = "shepherd", -- binary name / path filter = nil, -- string | fun():string | nil — passed as --filter - project = nil, -- string | fun():string | nil — passed as --project + board = nil, -- string | fun():string | nil — passed as --board float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for status(); e.g. a nerd-font glyph }) @@ -119,10 +141,11 @@ require("shepherd").setup({ - `filter` — a string, or a function returning one (evaluated on each open, so it can track the current project). `nil`/empty means no filter. -- `project` — a shepherd project board name (string or function, e.g. derive it +- `board` — a shepherd board name (string or function, e.g. derive it from the cwd). Scopes everything — board, add, list/pick, statusline counts — - to `~/.config/shepherd/projects/.md`. `nil`/empty uses the default - board. `:Shepherd!` ignores it and shows all boards. + to `~/.config/shepherd/boards/.md`. `nil`/empty uses the default + board. `:Shepherd!` ignores it and shows all boards. `:ShepherdBoards` → + *switch* overrides it for the session. - `float` — fractions of the editor size, and the window border. - `status.icon` — prefix for `status()`. With an icon it renders ` 3`; empty renders `3 todo`. @@ -143,4 +166,5 @@ make check # lint + test CI (`.github/workflows/pull-request.yml`) runs lint plus the suite on Neovim stable and nightly. Tests cover the pure helpers (`tally`, `format_status`, `label`, -`clean`, `build_cmd`/filter resolution) via the `_internal` table. +`flatten`, `edit_seed`, `clean`, `build_cmd`/filter resolution) via the +`_internal` table. diff --git a/lua/shepherd/board.lua b/lua/shepherd/board.lua new file mode 100644 index 0000000..d2e4f80 --- /dev/null +++ b/lua/shepherd/board.lua @@ -0,0 +1,100 @@ +-- Board management: a board switcher and CRUD, driven by the shepherd CLI's +-- `boards --json` reads and `board ` verbs. Board verbs name their +-- target explicitly, so they run with { no_board = true } to bypass scoping. +local sh = require("shepherd") + +local M = {} + +-- fetch decodes `boards --json [args]` and hands the board array to cb. +local function fetch(args, cb) + local cmd = { sh.binary(), "boards", "--json" } + for _, a in ipairs(args or {}) do + cmd[#cmd + 1] = a + end + vim.system(cmd, { text = true }, function(r) + vim.schedule(function() + if r.code ~= 0 then + vim.notify("shepherd: " .. ((r.stderr or ""):gsub("%s+$", "")), vim.log.levels.ERROR) + return + end + local ok, boards = pcall(vim.json.decode, r.stdout) + if not ok then + vim.notify("shepherd: could not parse `boards --json`", vim.log.levels.ERROR) + return + end + cb(boards or {}) + end) + end) +end + +local function plabel(b) + return string.format("%s %s (%d/%d)", b.current and "*" or " ", b.name, b.open, b.total) +end + +-- confirm_delete previews the removal with --dry-run, then requires an explicit +-- "yes" before the destructive --force delete. +local function confirm_delete(name) + vim.system({ sh.binary(), "board", "delete", name, "--dry-run" }, { text = true }, function(r) + vim.schedule(function() + local preview = (r.stdout or ""):gsub("%s+$", "") + vim.ui.select({ "no", "yes" }, { prompt = "delete '" .. name .. "'? " .. preview }, function(ans) + if ans == "yes" then + sh._run({ "board", "delete", name, "--force" }, "deleted " .. name, { no_board = true }) + end + end) + end) + end) +end + +-- switch lists boards; selecting one offers switch/rename/archive/delete. +function M.switch() + fetch({}, function(boards) + if #boards == 0 then + vim.notify("shepherd: no boards") + return + end + vim.ui.select(boards, { prompt = "board", format_item = plabel }, function(b) + if not b then + return + end + vim.ui.select({ "switch", "rename", "archive", "delete" }, { prompt = b.name }, function(act) + if act == "switch" then + sh.set_active_board(b.name) + sh.refresh() + vim.notify("shepherd: board " .. b.name) + elseif act == "rename" then + vim.ui.input({ prompt = "rename to: ", default = b.name }, function(v) + if v and v ~= "" and v ~= b.name then + sh._run( + { "board", "rename", b.name, v }, + "renamed " .. b.name .. " -> " .. v, + { no_board = true } + ) + end + end) + elseif act == "archive" then + sh._run({ "board", "archive", b.name }, "archived " .. b.name, { no_board = true }) + elseif act == "delete" then + confirm_delete(b.name) + end + end) + end) + end) +end + +-- archived lists archived boards; selecting one unarchives it. +function M.archived() + fetch({ "--archived" }, function(boards) + if #boards == 0 then + vim.notify("shepherd: no archived boards") + return + end + vim.ui.select(boards, { prompt = "unarchive", format_item = plabel }, function(b) + if b then + sh._run({ "board", "unarchive", b.name }, "unarchived " .. b.name, { no_board = true }) + end + end) + end) +end + +return M diff --git a/lua/shepherd/init.lua b/lua/shepherd/init.lua index 288cda6..8c515fe 100644 --- a/lua/shepherd/init.lua +++ b/lua/shepherd/init.lua @@ -3,39 +3,56 @@ local M = {} local defaults = { cmd = "shepherd", filter = nil, -- string | fun():string | nil - project = nil, -- string | fun():string | nil + board = nil, -- string | fun():string | nil float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for M.status(), e.g. a nerd-font glyph } local config = vim.tbl_deep_extend("force", {}, defaults) +-- active_board is a session override set by the board switcher. When non-nil +-- it wins over config.board; "default" means the unscoped default board. +local active_board = nil + -- binary returns the configured shepherd command (used by the health check). function M.binary() return config.cmd end --- with_project appends "--project " to cmd when one is configured --- (string or function). Shepherd wants flags after the verb. -local function with_project(cmd) - local p = config.project - p = type(p) == "function" and p() or p - if p and p ~= "" then - cmd[#cmd + 1] = "--project" +-- set_active_board switches the board the session targets, overriding +-- config.board until changed again. +function M.set_active_board(name) + active_board = name +end + +-- with_board appends "--board " to cmd when one is in effect: the +-- session override if set, else the configured string/function. Shepherd wants +-- flags after the verb. +local function with_board(cmd) + local p = active_board + if p == nil then + p = config.board + p = type(p) == "function" and p() or p + end + if p and p ~= "" and p ~= "default" then + cmd[#cmd + 1] = "--board" cmd[#cmd + 1] = p end return cmd end -- run invokes the shepherd CLI async; notifies on failure, and on success only --- when ok_msg is given. Project-scoped so mutations land on the same board --- list()/pick() read. -local function run(args, ok_msg) +-- when ok_msg is given. Board-scoped so mutations land on the same board +-- list()/pick() read, unless opts.no_board is set (board verbs name their +-- target explicitly). +local function run(args, ok_msg, opts) local cmd = { config.cmd } for _, a in ipairs(args) do cmd[#cmd + 1] = a end - with_project(cmd) + if not (opts and opts.no_board) then + with_board(cmd) + end vim.system(cmd, { text = true }, function(r) vim.schedule(function() if r.code == 0 then @@ -65,7 +82,7 @@ local function build_cmd(filter_override, all) if all then parts[#parts + 1] = "--all" else - with_project(parts) + with_board(parts) end local f = resolve_filter(filter_override) if f and f ~= "" then @@ -75,7 +92,9 @@ local function build_cmd(filter_override, all) return parts end -function M.open(filter, all) +-- float_term runs argv in a terminal inside a centered floating window that +-- closes when the process exits, then refreshes counts. Shared by open/stats. +local function float_term(argv) local cols, rows = vim.o.columns, vim.o.lines local w = math.floor(cols * config.float.width) local h = math.floor(rows * config.float.height) @@ -89,7 +108,7 @@ function M.open(filter, all) style = "minimal", border = config.float.border, }) - vim.fn.jobstart(build_cmd(filter, all), { + vim.fn.jobstart(argv, { term = true, on_exit = function() if vim.api.nvim_win_is_valid(win) then @@ -101,6 +120,15 @@ function M.open(filter, all) vim.cmd("startinsert") end +function M.open(filter, all) + float_term(build_cmd(filter, all)) +end + +-- stats opens shepherd's native terminal chart view in the float. +function M.stats() + float_term({ config.cmd, "stats" }) +end + function M.add(text) if not text or text == "" then return @@ -114,10 +142,22 @@ function M.quick_add() end) end --- list fetches items via `list --json` (scoped to the configured project) and --- passes the decoded array to cb. -local function list(cb) - vim.system(with_project({ config.cmd, "list", "--json" }), { text = true }, function(r) +-- list fetches items via `list --json` and passes the decoded array to cb. +-- Scoped to the current board unless `all` (read-only aggregate across every +-- board). An explicit `filter` narrows the result; the configured config.filter +-- is deliberately NOT applied so counts cover the whole board. +local function list(cb, filter, all) + local cmd = { config.cmd, "list", "--json" } + if all then + cmd[#cmd + 1] = "--all" + else + with_board(cmd) + end + if filter and filter ~= "" then + cmd[#cmd + 1] = "--filter" + cmd[#cmd + 1] = filter + end + vim.system(cmd, { text = true }, function(r) vim.schedule(function() if r.code ~= 0 then vim.notify("shepherd: " .. ((r.stderr or ""):gsub("%s+$", "")), vim.log.levels.ERROR) @@ -133,11 +173,71 @@ local function list(cb) end) end +-- label renders an item for the picker, appending only non-empty fields: +-- subtask indent, [board] (--all view), , @category, !priority, and +-- due/defer dates. local function label(it) - local mark = it.done and "[x]" or "[ ]" - local cat = (it.category and it.category ~= "") and (" @" .. it.category) or "" - local pr = (it.priority and it.priority ~= "") and (" !" .. it.priority:lower()) or "" - return string.format("%s %s%s%s", mark, it.text, cat, pr) + local parts = { it._sub and (" " .. (it.done and "[x]" or "[ ]")) or (it.done and "[x]" or "[ ]"), it.text } + if it.board and it.board ~= "" then + parts[#parts + 1] = "[" .. it.board .. "]" + end + if it.status and it.status ~= "" and not it.done then + parts[#parts + 1] = "<" .. it.status .. ">" + end + if it.category and it.category ~= "" then + parts[#parts + 1] = "@" .. it.category + end + if it.priority and it.priority ~= "" then + parts[#parts + 1] = "!" .. it.priority:lower() + end + if it.due and it.due ~= "" then + parts[#parts + 1] = "due:" .. it.due + end + if it.defer and it.defer ~= "" then + parts[#parts + 1] = "defer:" .. it.defer + end + return table.concat(parts, " ") +end + +-- flatten yields picker rows: each parent followed by its subtasks. _ref is the +-- CLI address ("n" for parents, "n.m" for subtasks); _sub marks subtask rows. +local function flatten(items) + local rows = {} + for _, it in ipairs(items) do + it._ref = tostring(it.index) + rows[#rows + 1] = it + for _, sub in ipairs(it.subtasks or {}) do + sub._ref = it.index .. "." .. sub.index + sub._sub = true + rows[#rows + 1] = sub + end + end + return rows +end + +-- edit_seed reconstructs a quick-add line from an item so the edit prompt is +-- pre-filled. note: consumes the rest of the line, so it goes last. +local function edit_seed(it) + local parts = { it.text or "" } + if it.category and it.category ~= "" then + parts[#parts + 1] = "@" .. it.category + end + if it.priority and it.priority ~= "" then + parts[#parts + 1] = "!" .. it.priority:lower() + end + if it.due and it.due ~= "" then + parts[#parts + 1] = "due:" .. it.due + end + if it.defer and it.defer ~= "" then + parts[#parts + 1] = "defer:" .. it.defer + end + if it.link and it.link ~= "" then + parts[#parts + 1] = "link:" .. it.link + end + if it.note and it.note ~= "" then + parts[#parts + 1] = "note:" .. it.note + end + return table.concat(parts, " ") end -- statusline counts, refreshed async. `ready` gates the first render. @@ -172,7 +272,7 @@ end -- refresh recomputes the open/overdue counts and fires User -- ShepherdStatusUpdate so a statusline can redraw. Counts cover the configured --- project's whole board (not scoped to `config.filter`). +-- board's whole board (not scoped to `config.filter`). function M.refresh() list(function(items) counts.open, counts.overdue = tally(items, os.date("%Y-%m-%d")) @@ -191,25 +291,67 @@ function M.status() return format_status(counts.open, counts.overdue, config.status.icon) end --- pick shows all items, then a done/undone/rm action on the chosen one. -function M.pick() +-- pick shows items (with subtasks) then an action menu on the chosen one: +-- toggle done, edit, set status, add a subtask, remove, or open its link. +-- With `all`, the aggregate view is read-only (indexes aren't valid for +-- mutations) so only open-link is offered. An optional `filter` narrows the list. +function M.pick(filter, all) list(function(items) if #items == 0 then vim.notify("shepherd: no items") return end - vim.ui.select(items, { prompt = "shepherd", format_item = label }, function(choice) + vim.ui.select(flatten(items), { prompt = "shepherd", format_item = label }, function(choice) if not choice then return end + if all then + if choice.link and choice.link ~= "" then + vim.ui.open(choice.link) + else + vim.notify("shepherd: --all view is read-only") + end + return + end + local ref = choice._ref local toggle = choice.done and "undone" or "done" - vim.ui.select({ toggle, "rm" }, { prompt = choice.text }, function(act) - if act then - run({ act, tostring(choice.index) }, act .. " " .. choice.index) + local actions = { toggle, "edit", "status", "rm" } + if not choice._sub then + actions[#actions + 1] = "subtask" + end + if choice.link and choice.link ~= "" then + actions[#actions + 1] = "open link" + end + vim.ui.select(actions, { prompt = choice.text }, function(act) + if not act then + return + end + if act == "edit" then + vim.ui.input({ prompt = "edit: ", default = edit_seed(choice) }, function(v) + if v and v ~= "" then + run({ "edit", ref, v }, "edited " .. ref) + end + end) + elseif act == "status" then + vim.ui.input({ prompt = "status: ", default = choice.status or "" }, function(v) + if v ~= nil then + run({ "edit", ref, "status:" .. v }, "status " .. ref) + end + end) + elseif act == "subtask" then + vim.ui.input({ prompt = "subtask: " }, function(v) + if v and v ~= "" then + run({ "sub", tostring(choice.index), v }, "added subtask") + end + end) + elseif act == "open link" then + vim.ui.open(choice.link) + else + run({ act, ref }, act .. " " .. ref) end end) end) - end) + end, filter, all) end -- clean strips a leading comment marker and TODO/FIXME tag so a code comment @@ -257,14 +399,26 @@ function M.setup(opts) end end, { nargs = "*", desc = "add a todo (args or prompt)" }) - vim.api.nvim_create_user_command("ShepherdList", function() - M.pick() - end, { desc = "pick a todo and act on it" }) + vim.api.nvim_create_user_command("ShepherdList", function(a) + M.pick(a.args ~= "" and a.args or nil, a.bang) + end, { nargs = "?", bang = true, desc = "pick a todo and act on it (! = all boards, optional filter)" }) vim.api.nvim_create_user_command("ShepherdCapture", function(a) M.capture({ range = a.range, line1 = a.line1, line2 = a.line2 }) end, { nargs = 0, range = true, desc = "capture current line / selection as a todo" }) + vim.api.nvim_create_user_command("ShepherdStats", function() + M.stats() + end, { desc = "open the stats dashboard" }) + + vim.api.nvim_create_user_command("ShepherdBoards", function() + require("shepherd.board").switch() + end, { desc = "switch board or manage boards (rename/archive/delete)" }) + + vim.api.nvim_create_user_command("ShepherdBoardsArchived", function() + require("shepherd.board").archived() + end, { desc = "unarchive an archived board" }) + -- keep statusline counts fresh across external edits / other tabs vim.api.nvim_create_autocmd("FocusGained", { group = vim.api.nvim_create_augroup("shepherd", { clear = true }), @@ -274,16 +428,23 @@ function M.setup(opts) }) end +-- _run exposes the CLI runner to the board-management submodule +-- (shepherd.board), which passes { no_board = true } for board verbs. +M._run = run + -- _internal exposes pure helpers for the test suite; not part of the public API. M._internal = { tally = tally, format_status = format_status, label = label, + flatten = flatten, + edit_seed = edit_seed, clean = clean, build_cmd = build_cmd, resolve_filter = resolve_filter, - with_project = with_project, + with_board = with_board, set_config = function(c) + active_board = nil config = vim.tbl_deep_extend("force", {}, defaults, c or {}) end, } diff --git a/tests/shepherd_spec.lua b/tests/shepherd_spec.lua index 822c1bd..f9f594b 100644 --- a/tests/shepherd_spec.lua +++ b/tests/shepherd_spec.lua @@ -72,6 +72,67 @@ describe("label", function() assert.equals("[x] done thing", internal.label({ done = true, text = "done thing" })) assert.equals("[ ] bare", internal.label({ done = false, text = "bare", category = "", priority = "" })) end) + + it("shows status, due and defer", function() + assert.equals( + "[ ] ship it due:2026-07-20 defer:2026-07-18", + internal.label({ + done = false, + text = "ship it", + status = "blocked", + due = "2026-07-20", + defer = "2026-07-18", + }) + ) + end) + + it("hides status on done items", function() + assert.equals("[x] shipped", internal.label({ done = true, text = "shipped", status = "blocked" })) + end) + + it("shows the board tag in the aggregate view", function() + assert.equals("[ ] task [web]", internal.label({ done = false, text = "task", board = "web" })) + end) + + it("indents subtask rows", function() + assert.equals(" [ ] a sub", internal.label({ done = false, text = "a sub", _sub = true })) + end) +end) + +describe("flatten", function() + it("orders each parent before its subtasks with n / n.m refs", function() + local rows = internal.flatten({ + { index = 1, text = "parent", subtasks = { { index = 1, text = "kid" } } }, + { index = 2, text = "solo" }, + }) + assert.equals(3, #rows) + assert.equals("1", rows[1]._ref) + assert.equals("1.1", rows[2]._ref) + assert.is_true(rows[2]._sub) + assert.equals("2", rows[3]._ref) + assert.is_nil(rows[3]._sub) + end) +end) + +describe("edit_seed", function() + it("reconstructs a quick-add line with note last", function() + assert.equals( + "buy milk @home !h due:2026-07-20 defer:2026-07-18 link:http://x note:from the shop", + internal.edit_seed({ + text = "buy milk", + category = "home", + priority = "H", + due = "2026-07-20", + defer = "2026-07-18", + link = "http://x", + note = "from the shop", + }) + ) + end) + + it("omits empty fields", function() + assert.equals("bare", internal.edit_seed({ text = "bare", category = "", priority = "" })) + end) end) describe("build_cmd / resolve_filter", function() @@ -103,48 +164,48 @@ describe("build_cmd / resolve_filter", function() assert.same({ "shepherd", "--filter", "other" }, internal.build_cmd("other")) end) - it("passes a string project", function() - internal.set_config({ project = "web" }) - assert.same({ "shepherd", "--project", "web" }, internal.build_cmd()) + it("passes a string board", function() + internal.set_config({ board = "web" }) + assert.same({ "shepherd", "--board", "web" }, internal.build_cmd()) end) - it("evaluates a function project", function() + it("evaluates a function board", function() internal.set_config({ - project = function() + board = function() return "web" end, }) - assert.same({ "shepherd", "--project", "web" }, internal.build_cmd()) + assert.same({ "shepherd", "--board", "web" }, internal.build_cmd()) end) - it("combines project and filter", function() - internal.set_config({ project = "web", filter = "work" }) - assert.same({ "shepherd", "--project", "web", "--filter", "work" }, internal.build_cmd()) + it("combines board and filter", function() + internal.set_config({ board = "web", filter = "work" }) + assert.same({ "shepherd", "--board", "web", "--filter", "work" }, internal.build_cmd()) end) - it("emits --all for the global view, dropping the project", function() - internal.set_config({ project = "web" }) + it("emits --all for the global view, dropping the board", function() + internal.set_config({ board = "web" }) assert.same({ "shepherd", "--all" }, internal.build_cmd(nil, true)) end) it("keeps the filter in the global view", function() - internal.set_config({ project = "web", filter = "work" }) + internal.set_config({ board = "web", filter = "work" }) assert.same({ "shepherd", "--all", "--filter", "work" }, internal.build_cmd(nil, true)) end) end) -describe("with_project", function() +describe("with_board", function() after_each(function() internal.set_config({}) end) - it("appends --project after the verb args", function() - internal.set_config({ project = "web" }) - assert.same({ "shepherd", "add", "x", "--project", "web" }, internal.with_project({ "shepherd", "add", "x" })) + it("appends --board after the verb args", function() + internal.set_config({ board = "web" }) + assert.same({ "shepherd", "add", "x", "--board", "web" }, internal.with_board({ "shepherd", "add", "x" })) end) - it("is a no-op without a project", function() + it("is a no-op without a board", function() internal.set_config({}) - assert.same({ "shepherd", "list", "--json" }, internal.with_project({ "shepherd", "list", "--json" })) + assert.same({ "shepherd", "list", "--json" }, internal.with_board({ "shepherd", "list", "--json" })) end) end) From d65d97ab3880b84cd9cebb6baf6a9bc2e2559f04 Mon Sep 17 00:00:00 2001 From: Jonathan Warykowski Date: Fri, 24 Jul 2026 21:23:55 +1000 Subject: [PATCH 2/2] docs: bind boards to tb --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8880e07..4b64c27 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ return { { "tc", "ShepherdCapture", desc = "shepherd capture line" }, { "tc", ":ShepherdCapture", mode = "x", desc = "shepherd capture selection" }, { "ts", "ShepherdStats", desc = "shepherd stats" }, - { "tp", "ShepherdBoards", desc = "shepherd boards" }, + { "tb", "ShepherdBoards", desc = "shepherd boards" }, }, opts = { -- per-repo board (own file under ~/.config/shepherd/boards/)