feat: support for detached working trees

Added config.worktrees.

Array of tables with the keys 'gitdir' and 'toplevel'.

If attaching normally fails, then each entry in the table is attempted.

Example:

  worktrees = {
    {
      toplevel = vim.env.HOME,
      gitdir = vim.env.HOME .. '/projects/dotfiles/.git'
    }
  }

Resolves #397
This commit is contained in:
Lewis Russell 2022-08-09 13:04:22 +01:00 committed by Lewis Russell
parent 9c3ca02766
commit 50e32c6309
16 changed files with 377 additions and 68 deletions

View File

@ -28,6 +28,7 @@ Super fast git decorations implemented purely in lua/teal.
- Live intra-line word diff
- Ability to display deleted/changed lines via virtual lines.
- Support for [yadm](https://yadm.io/)
- Support for detached working trees.
## Requirements

View File

@ -518,6 +518,24 @@ keymaps *gitsigns-config-keymaps*
to `{}`, and |gitsigns-config-on_attach| can instead be used to define
mappings.
worktrees *gitsigns-config-worktrees*
Type: `table`, Default: `nil`
Detached working trees.
Array of tables with the keys `gitdir` and `toplevel`.
If normal attaching fails, then each entry in the table is attempted
with the work tree details set.
Example: >
worktrees = {
{
toplevel = vim.env.HOME,
gitdir = vim.env.HOME .. '/projects/dotfiles/.git'
}
}
on_attach *gitsigns-config-on_attach*
Type: `function`, Default: `nil`

42
lua/gitsigns.lua generated
View File

@ -1,3 +1,4 @@
local async = require('gitsigns.async')
local void = require('gitsigns.async').void
local scheduler = require('gitsigns.async').scheduler
@ -153,6 +154,37 @@ local function on_detach(_, bufnr)
M.detach(bufnr, true)
end
local function on_attach_pre(bufnr)
local gitdir, toplevel
if config._on_attach_pre then
local res = async.wrap(config._on_attach_pre, 2)(bufnr)
dprintf('ran on_attach_pre with result %s', vim.inspect(res))
if type(res) == "table" then
if type(res.gitdir) == 'string' then
gitdir = res.gitdir
end
if type(res.toplevel) == 'string' then
toplevel = res.toplevel
end
end
end
return gitdir, toplevel
end
local function try_worktrees(_bufnr, file, encoding)
if not config.worktrees then
return
end
for _, wt in ipairs(config.worktrees) do
local git_obj = git.Obj.new(file, encoding, wt.gitdir, wt.toplevel)
if git_obj and git_obj.object_name then
dprintf('Using worktree %s', vim.inspect(wt))
return git_obj
end
end
end
@ -190,6 +222,7 @@ local attach_throttled = throttle_by_id(function(cbuf, aucmd)
end
local file, commit = get_buf_path(cbuf)
local encoding = vim.bo[cbuf].fileencoding
local file_dir = util.dirname(file)
@ -198,7 +231,14 @@ local attach_throttled = throttle_by_id(function(cbuf, aucmd)
return
end
local git_obj = git.Obj.new(file, vim.bo[cbuf].fileencoding)
local gitdir_oap, toplevel_oap = on_attach_pre(cbuf)
local git_obj = git.Obj.new(file, encoding, gitdir_oap, toplevel_oap)
if not git_obj then
git_obj = try_worktrees(cbuf, file, encoding)
scheduler()
end
if not git_obj then
dprint('Empty git obj')
return

View File

@ -92,6 +92,7 @@ local M = {}
function M.wrap(func, argc)
assert(argc)
return function(...)
if not async_thread.running() then

View File

@ -24,7 +24,14 @@ local SchemaElem = {Deprecated = {}, }
local M = {Config = {DiffOpts = {}, SignConfig = {}, watch_gitdir = {}, current_line_blame_formatter_opts = {}, current_line_blame_opts = {}, yadm = {}, }, }
local M = {Config = {DiffOpts = {}, SignConfig = {}, watch_gitdir = {}, current_line_blame_formatter_opts = {}, current_line_blame_opts = {}, yadm = {}, Worktree = {}, }, }
@ -175,6 +182,49 @@ M.schema = {
]],
},
worktrees = {
type = 'table',
default = nil,
description = [[
Detached working trees.
Array of tables with the keys `gitdir` and `toplevel`.
If normal attaching fails, then each entry in the table is attempted
with the work tree details set.
Example: >
worktrees = {
{
toplevel = vim.env.HOME,
gitdir = vim.env.HOME .. '/projects/dotfiles/.git'
}
}
]],
},
_on_attach_pre = {
type = 'function',
default = nil,
description = [[
Asynchronous hook called before attaching to a buffer. Mainly used to
configure detached worktrees.
This callback must call its callback argument. The callback argument can
accept an optional table argument with the keys: 'gitdir' and 'toplevel'.
Example: >
on_attach_pre = function(bufnr, callback)
...
callback {
gitdir = ...,
toplevel = ...
}
end
<
]],
},
on_attach = {
type = 'function',
default = nil,

View File

@ -45,6 +45,7 @@ local function get_context(lvl)
ret.name = name0:gsub('(.*)%d+$', '%1')
end
ret.bufnr = getvarvalue('bufnr', lvl) or
getvarvalue('_bufnr', lvl) or
getvarvalue('cbuf', lvl) or
getvarvalue('buf', lvl)

91
lua/gitsigns/git.lua generated
View File

@ -23,7 +23,17 @@ local GJobSpec = {}
local M = {BlameInfo = {}, Version = {}, Repo = {}, FileProps = {}, Obj = {}, }
local M = {BlameInfo = {}, Version = {}, RepoInfo = {}, Repo = {}, FileProps = {}, Obj = {}, }
@ -242,7 +252,7 @@ local function normalize_path(path)
return path
end
M.get_repo_info = function(path, cmd)
M.get_repo_info = function(path, cmd, gitdir, toplevel)
local has_abs_gd = check_version({ 2, 13 })
@ -252,21 +262,36 @@ M.get_repo_info = function(path, cmd)
scheduler()
local results = M.command({
local args = {}
if gitdir then
vim.list_extend(args, { '--git-dir', gitdir })
end
if toplevel then
vim.list_extend(args, { '--work-tree', toplevel })
end
vim.list_extend(args, {
'rev-parse', '--show-toplevel', git_dir_opt, '--abbrev-ref', 'HEAD',
}, {
})
local results = M.command(args, {
command = cmd or 'git',
supress_stderr = true,
cwd = path,
})
local toplevel = normalize_path(results[1])
local gitdir = normalize_path(results[2])
if gitdir and not has_abs_gd then
gitdir = uv.fs_realpath(gitdir)
local ret = {
toplevel = normalize_path(results[1]),
gitdir = normalize_path(results[2]),
}
ret.abbrev_head = process_abbrev_head(ret.gitdir, results[3], path, cmd)
if ret.gitdir and not has_abs_gd then
ret.gitdir = uv.fs_realpath(ret.gitdir)
end
local abbrev_head = process_abbrev_head(gitdir, results[3], path, cmd)
return toplevel, gitdir, abbrev_head
ret.detached = ret.toplevel and ret.gitdir ~= ret.toplevel .. '/.git'
return ret
end
M.set_version = function(version)
@ -289,7 +314,18 @@ end
Repo.command = function(self, args, spec)
spec = spec or {}
spec.cwd = self.toplevel
return M.command({ '--git-dir=' .. self.gitdir, unpack(args) }, spec)
local args1 = {
'--git-dir', self.gitdir,
}
if self.detached then
vim.list_extend(args1, { '--work-tree', self.toplevel })
end
vim.list_extend(args1, args)
return M.command(args1, spec)
end
Repo.files_changed = function(self)
@ -322,21 +358,27 @@ Repo.get_show_text = function(self, object, encoding)
end
Repo.update_abbrev_head = function(self)
_, _, self.abbrev_head = M.get_repo_info(self.toplevel)
self.abbrev_head = M.get_repo_info(self.toplevel).abbrev_head
end
Repo.new = function(dir)
Repo.new = function(dir, gitdir, toplevel)
local self = setmetatable({}, { __index = Repo })
self.username = M.command({ 'config', 'user.name' })[1]
self.toplevel, self.gitdir, self.abbrev_head = M.get_repo_info(dir)
local info = M.get_repo_info(dir, nil, gitdir, toplevel)
for k, v in pairs(info) do
(self)[k] = v
end
if M.enable_yadm and not self.gitdir then
if vim.startswith(dir, os.getenv('HOME')) and
#M.command({ 'ls-files', dir }, { command = 'yadm' }) ~= 0 then
self.toplevel, self.gitdir, self.abbrev_head =
M.get_repo_info(dir, 'yadm')
M.get_repo_info(dir, 'yadm', gitdir, toplevel)
local yadm_info = M.get_repo_info(dir, 'yadm', gitdir, toplevel)
for k, v in pairs(yadm_info) do
(self)[k] = v
end
end
end
@ -352,9 +394,9 @@ Obj.command = function(self, args, spec)
return self.repo:command(args, spec)
end
Obj.update_file_info = function(self, update_relpath)
Obj.update_file_info = function(self, update_relpath, silent)
local old_object_name = self.object_name
local props = self:file_info()
local props = self:file_info(self.file, silent)
if update_relpath then
self.relpath = props.relpath
@ -368,7 +410,7 @@ Obj.update_file_info = function(self, update_relpath)
return old_object_name ~= self.object_name
end
Obj.file_info = function(self, file)
Obj.file_info = function(self, file, silent)
local results, stderr = self:command({
'-c', 'core.quotepath=off',
'ls-files',
@ -379,7 +421,7 @@ Obj.file_info = function(self, file)
file or self.file,
}, { supress_stderr = true })
if stderr then
if stderr and not silent then
if not stderr:match('^warning: could not open directory .*: No such file or directory') then
@ -540,7 +582,7 @@ Obj.has_moved = function(self)
end
end
Obj.new = function(file, encoding)
Obj.new = function(file, encoding, gitdir, toplevel)
if in_git_dir(file) then
dprint('In git dir')
return nil
@ -549,14 +591,17 @@ Obj.new = function(file, encoding)
self.file = file
self.encoding = encoding
self.repo = Repo.new(util.dirname(file))
self.repo = Repo.new(util.dirname(file), gitdir, toplevel)
if not self.repo.gitdir then
dprint('Not in git repo')
return nil
end
self:update_file_info(true)
local silent = gitdir ~= nil and toplevel ~= nil
self:update_file_info(true, silent)
return self
end

View File

@ -380,7 +380,9 @@ M.update_cwd_head = void(function()
end
if not head or not gitdir then
_, gitdir, head = git.get_repo_info(cwd)
local info = git.get_repo_info(cwd)
gitdir = info.gitdir
head = info.abbrev_head
end
scheduler()
@ -409,7 +411,7 @@ M.update_cwd_head = void(function()
end
dprint('Git cwd dir update')
local _, _, new_head = git.get_repo_info(cwd)
local new_head = git.get_repo_info(cwd).abbrev_head
scheduler()
update_cwd_head_var(new_head)
end))

View File

@ -1,3 +1,4 @@
local async = require('gitsigns.async')
local void = require('gitsigns.async').void
local scheduler = require('gitsigns.async').scheduler
@ -153,6 +154,37 @@ local function on_detach(_, bufnr: integer)
M.detach(bufnr, true)
end
local function on_attach_pre(bufnr: integer): string, string
local gitdir, toplevel: string, string
if config._on_attach_pre then
local res: any = async.wrap(config._on_attach_pre, 2)(bufnr)
dprintf('ran on_attach_pre with result %s', vim.inspect(res))
if res is table then
if type(res.gitdir) == 'string' then
gitdir = res.gitdir as string
end
if type(res.toplevel) == 'string' then
toplevel = res.toplevel as string
end
end
end
return gitdir, toplevel
end
local function try_worktrees(_bufnr: integer, file: string, encoding: string): git.Obj
if not config.worktrees then
return
end
for _, wt in ipairs(config.worktrees) do
local git_obj = git.Obj.new(file, encoding, wt.gitdir, wt.toplevel)
if git_obj and git_obj.object_name then
dprintf('Using worktree %s', vim.inspect(wt))
return git_obj
end
end
end
-- Ensure attaches cannot be interleaved.
-- Since attaches are asynchronous we need to make sure an attach isn't
-- performed whilst another one is in progress.
@ -190,6 +222,7 @@ local attach_throttled = throttle_by_id(function(cbuf: integer, aucmd: string)
end
local file, commit = get_buf_path(cbuf)
local encoding = vim.bo[cbuf].fileencoding
local file_dir = util.dirname(file)
@ -198,7 +231,14 @@ local attach_throttled = throttle_by_id(function(cbuf: integer, aucmd: string)
return
end
local git_obj = git.Obj.new(file, vim.bo[cbuf].fileencoding)
local gitdir_oap, toplevel_oap = on_attach_pre(cbuf)
local git_obj = git.Obj.new(file, encoding, gitdir_oap, toplevel_oap)
if not git_obj then
git_obj = try_worktrees(cbuf, file, encoding)
scheduler()
end
if not git_obj then
dprint('Empty git obj')
return

View File

@ -92,6 +92,7 @@ end
---@param argc number: The number of arguments of func. Must be included.
---@return function: Returns an async function
function M.wrap(func: function, argc: integer): function
assert(argc)
return function(...): any...
if not async_thread.running() then
-- print(debug.traceback('Warning: calling async function in non-async context', 2))

View File

@ -58,6 +58,7 @@ local record M
show_deleted: boolean
sign_priority: integer
keymaps: {string:any}
_on_attach_pre: function(bufnr: integer, callback: function(table))
on_attach: function(bufnr: integer)
record watch_gitdir
interval: integer
@ -100,6 +101,12 @@ local record M
trouble: boolean
record Worktree
toplevel: string
gitdir: string
end
worktrees: {Worktree}
-- Undocumented
word_diff: boolean
_refresh_staged_on_update: boolean
@ -175,6 +182,49 @@ M.schema = {
]]
},
worktrees = {
type = 'table',
default = nil,
description = [[
Detached working trees.
Array of tables with the keys `gitdir` and `toplevel`.
If normal attaching fails, then each entry in the table is attempted
with the work tree details set.
Example: >
worktrees = {
{
toplevel = vim.env.HOME,
gitdir = vim.env.HOME .. '/projects/dotfiles/.git'
}
}
]]
},
_on_attach_pre = {
type = 'function',
default = nil,
description = [[
Asynchronous hook called before attaching to a buffer. Mainly used to
configure detached worktrees.
This callback must call its callback argument. The callback argument can
accept an optional table argument with the keys: 'gitdir' and 'toplevel'.
Example: >
on_attach_pre = function(bufnr, callback)
...
callback {
gitdir = ...,
toplevel = ...
}
end
<
]]
},
on_attach = {
type = 'function',
default = nil,

View File

@ -45,6 +45,7 @@ local function get_context(lvl: integer): table
ret.name = name0:gsub('(.*)%d+$', '%1')
end
ret.bufnr = getvarvalue('bufnr', lvl)
or getvarvalue('_bufnr', lvl)
or getvarvalue('cbuf', lvl)
or getvarvalue('buf', lvl)

View File

@ -57,13 +57,23 @@ local record M
enable_yadm: boolean
set_version: function(string)
get_repo_info: function(path: string, cmd: string): string,string,string
record RepoInfo
gitdir: string
toplevel: string
detached: boolean
abbrev_head: string
end
get_repo_info: function(path: string, cmd: string, gitdir: string, toplevel: string): RepoInfo
command : function(args: {string}, spec: GJobSpec): {string}, string
diff : function(file_cmp: string, file_buf: string, indent_heuristic: boolean, diff_algo: string): {string}, string
record Repo
toplevel : string
gitdir : string
detached : boolean
abbrev_head: string
username : string
@ -71,7 +81,7 @@ local record M
files_changed : function(Repo): {string}
get_show_text : function(Repo, string, string): {string}, string
update_abbrev_head : function(Repo)
new : function(string): Repo
new : function(dir: string, gitdir: string, toplevel: string): Repo
end
record FileProps
@ -97,16 +107,16 @@ local record M
encoding : string
command : function(Obj, {string}, GJobSpec): {string}, string
update_file_info : function(Obj, boolean): boolean
update_file_info : function(Obj, boolean, silent: boolean): boolean
unstage_file : function(Obj, string, string)
run_blame : function(Obj, {string}, number, boolean): BlameInfo
file_info : function(Obj, string): FileProps
file_info : function(Obj, string, silent: boolean): FileProps
get_show_text : function(Obj, string): {string}, string
ensure_file_in_index : function(Obj)
stage_hunks : function(Obj, {Hunk}, boolean)
stage_lines : function(Obj, {string})
has_moved : function(Obj): string
new : function(string, string): Obj
new : function(path: string, enc: string, gitdir: string, toplevel: string): Obj
end
end
@ -242,7 +252,7 @@ local function normalize_path(path: string): string
return path
end
M.get_repo_info = function(path: string, cmd: string): string,string,string
M.get_repo_info = function(path: string, cmd: string, gitdir: string, toplevel: string): M.RepoInfo
-- Does git rev-parse have --absolute-git-dir, added in 2.13:
-- https://public-inbox.org/git/20170203024829.8071-16-szeder.dev@gmail.com/
local has_abs_gd = check_version{2,13}
@ -252,21 +262,36 @@ M.get_repo_info = function(path: string, cmd: string): string,string,string
-- https://github.com/lewis6991/gitsigns.nvim/pull/215
scheduler()
local results = M.command({
local args = {}
if gitdir then
vim.list_extend(args, {'--git-dir', gitdir})
end
if toplevel then
vim.list_extend(args, {'--work-tree', toplevel})
end
vim.list_extend(args, {
'rev-parse', '--show-toplevel', git_dir_opt, '--abbrev-ref', 'HEAD',
}, {
})
local results = M.command(args, {
command = cmd or 'git',
supress_stderr = true,
cwd = path
})
local toplevel = normalize_path(results[1])
local gitdir = normalize_path(results[2])
if gitdir and not has_abs_gd then
gitdir = uv.fs_realpath(gitdir)
local ret: M.RepoInfo = {
toplevel = normalize_path(results[1]),
gitdir = normalize_path(results[2]),
}
ret.abbrev_head = process_abbrev_head(ret.gitdir, results[3], path, cmd)
if ret.gitdir and not has_abs_gd then
ret.gitdir = uv.fs_realpath(ret.gitdir)
end
local abbrev_head = process_abbrev_head(gitdir, results[3], path, cmd)
return toplevel, gitdir, abbrev_head
ret.detached = ret.toplevel and ret.gitdir ~= ret.toplevel..'/.git'
return ret
end
M.set_version = function(version: string)
@ -289,7 +314,18 @@ end
Repo.command = function(self: Repo, args: {string}, spec: GJobSpec): {string}, string
spec = spec or {}
spec.cwd = self.toplevel
return M.command({'--git-dir='..self.gitdir, unpack(args)}, spec)
local args1 = {
'--git-dir', self.gitdir,
}
if self.detached then
vim.list_extend(args1, {'--work-tree', self.toplevel})
end
vim.list_extend(args1, args)
return M.command(args1, spec)
end
Repo.files_changed = function(self: Repo): {string}
@ -322,21 +358,27 @@ Repo.get_show_text = function(self: Repo, object: string, encoding: string): {st
end
Repo.update_abbrev_head = function(self: Repo)
_, _, self.abbrev_head = M.get_repo_info(self.toplevel)
self.abbrev_head = M.get_repo_info(self.toplevel).abbrev_head
end
Repo.new = function(dir: string): Repo
Repo.new = function(dir: string, gitdir: string, toplevel: string): Repo
local self = setmetatable({} as Repo, {__index = Repo})
self.username = M.command({'config', 'user.name'})[1]
self.toplevel, self.gitdir, self.abbrev_head = M.get_repo_info(dir)
local info = M.get_repo_info(dir, nil, gitdir, toplevel)
for k, v in pairs(info as {string:any}) do
(self as table)[k] = v
end
-- Try yadm
if M.enable_yadm and not self.gitdir then
if vim.startswith(dir, os.getenv('HOME'))
and #M.command({'ls-files', dir}, {command = 'yadm'}) ~= 0 then
self.toplevel, self.gitdir, self.abbrev_head =
M.get_repo_info(dir, 'yadm')
M.get_repo_info(dir, 'yadm', gitdir, toplevel)
local yadm_info = M.get_repo_info(dir, 'yadm', gitdir, toplevel)
for k, v in pairs(yadm_info as {string:any}) do
(self as table)[k] = v
end
end
end
@ -352,9 +394,9 @@ Obj.command = function(self: Obj, args: {string}, spec: GJobSpec): {string}, str
return self.repo:command(args, spec)
end
Obj.update_file_info = function(self: Obj, update_relpath: boolean): boolean
Obj.update_file_info = function(self: Obj, update_relpath: boolean, silent: boolean): boolean
local old_object_name = self.object_name
local props = self:file_info()
local props = self:file_info(self.file, silent)
if update_relpath then
self.relpath = props.relpath
@ -368,7 +410,7 @@ Obj.update_file_info = function(self: Obj, update_relpath: boolean): boolean
return old_object_name ~= self.object_name
end
Obj.file_info = function(self: Obj, file: string): M.FileProps
Obj.file_info = function(self: Obj, file: string, silent: boolean): M.FileProps
local results, stderr = self:command({
'-c', 'core.quotepath=off',
'ls-files',
@ -379,7 +421,7 @@ Obj.file_info = function(self: Obj, file: string): M.FileProps
file or self.file
}, {supress_stderr = true})
if stderr then
if stderr and not silent then
-- Supress_stderr for the cases when we run:
-- git ls-files --others exists/nonexist
if not stderr:match('^warning: could not open directory .*: No such file or directory') then
@ -540,7 +582,7 @@ Obj.has_moved = function(self: Obj): string
end
end
Obj.new = function(file: string, encoding: string): Obj
Obj.new = function(file: string, encoding: string, gitdir: string, toplevel: string): Obj
if in_git_dir(file) then
dprint('In git dir')
return nil
@ -549,14 +591,17 @@ Obj.new = function(file: string, encoding: string): Obj
self.file = file
self.encoding = encoding
self.repo = Repo.new(util.dirname(file))
self.repo = Repo.new(util.dirname(file), gitdir, toplevel)
if not self.repo.gitdir then
dprint('Not in git repo')
return nil
end
self:update_file_info(true)
-- When passing gitdir and toplevel, suppress stderr when resolving the file
local silent = gitdir ~= nil and toplevel ~= nil
self:update_file_info(true, silent)
return self
end

View File

@ -380,7 +380,9 @@ M.update_cwd_head = void(function()
end
if not head or not gitdir then
_, gitdir, head = git.get_repo_info(cwd)
local info = git.get_repo_info(cwd)
gitdir = info.gitdir
head = info.abbrev_head
end
scheduler()
@ -409,7 +411,7 @@ M.update_cwd_head = void(function()
end
dprint('Git cwd dir update')
local _, _, new_head = git.get_repo_info(cwd)
local new_head = git.get_repo_info(cwd).abbrev_head
scheduler()
update_cwd_head_var(new_head)
end)

View File

@ -292,6 +292,11 @@ describe('gitsigns', function()
end
it('doesn\'t error on untracked files', function()
local nvim_ver = exec_lua('return vim.version().minor')
if nvim_ver >= 8 then
pending()
end
setup_test_repo{no_add=true}
edit(newfile)
insert("line")
@ -341,7 +346,7 @@ describe('gitsigns', function()
p'run_job: git .* config user.name',
p'run_job: git .* rev%-parse %-%-show%-toplevel %-%-absolute%-git%-dir %-%-abbrev%-ref HEAD',
p'run_job: git .* rev%-parse %-%-short HEAD',
p'run_job: git .* %-%-git%-dir=.* %-%-stage %-%-others %-%-exclude%-standard %-%-eol.*',
p'run_job: git .* %-%-git%-dir .* %-%-stage %-%-others %-%-exclude%-standard %-%-eol.*',
'attach(1): User on_attach() returned false',
}
end)

View File

@ -142,11 +142,11 @@ function M.match_lines(lines, spec)
end
end
if i < #spec + 1 then
-- print('Lines:')
-- for _, l in ipairs(lines) do
-- print(string.format( '"%s"', l))
-- end
error(('Did not match pattern \'%s\''):format(spec[i]))
local msg = {'lines:'}
for _, l in ipairs(lines) do
msg[#msg+1] = string.format( '"%s"', l)
end
error(('Did not match pattern \'%s\' with %s'):format(spec[i], table.concat(msg, '\n')))
end
end
@ -173,11 +173,18 @@ local function match_lines2(lines, spec)
end
if i < #spec + 1 then
local unmatched = {}
for j = i, #spec do
table.insert(unmatched, spec[j].text or spec[j])
end
error(('Did not match patterns:\n - %s'):format(table.concat(unmatched, '\n - ')))
local unmatched_msg = table.concat(helpers.tbl_map(function(v)
return string.format(' - %s', v.text or v)
end, spec), '\n')
local lines_msg = table.concat(helpers.tbl_map(function(v)
return string.format(' - %s', v)
end, lines), '\n')
error(('Did not match patterns:\n%s\nwith:\n%s'):format(
unmatched_msg,
lines_msg
))
end
end