diff --git a/.config/nvim/lua/custom/worktree_picker.lua b/.config/nvim/lua/custom/worktree_picker.lua
index 7af2da4..b6beb8e 100644
--- a/.config/nvim/lua/custom/worktree_picker.lua
+++ b/.config/nvim/lua/custom/worktree_picker.lua
@@ -1,5 +1,36 @@
local M = {}
+local pipeline_icons = {
+ failed = '✗',
+ loading = '…',
+ none = '-',
+ passed = '✓',
+ pending = '○',
+ running = '●',
+ unknown = '?',
+}
+
+local pipeline_highlights = {
+ failed = 'DiagnosticError',
+ loading = 'Comment',
+ none = 'Comment',
+ passed = 'DiagnosticOk',
+ pending = 'DiagnosticWarn',
+ running = 'DiagnosticInfo',
+ unknown = 'Comment',
+}
+
+local dotenv_names = {
+ BITBUCKET_ACCESS_TOKEN = true,
+ BITBUCKET_API_TOKEN = true,
+ BITBUCKET_APP_PASSWORD = true,
+ BITBUCKET_USERNAME = true,
+ GH_TOKEN = true,
+ GITHUB_TOKEN = true,
+ GITLAB_TOKEN = true,
+ WORKTREE_CI_REMOTE = true,
+}
+
local function git(args, cwd)
local command = vim.list_extend({ 'git' }, args)
local result = vim.system(command, { cwd = cwd, text = true }):wait()
@@ -11,6 +42,273 @@ local function git(args, cwd)
return result.stdout
end
+local function decode_json(value)
+ local ok, decoded = pcall(vim.json.decode, value)
+ if not ok then
+ return nil
+ end
+
+ return decoded
+end
+
+local function lower_string(value)
+ if type(value) ~= 'string' then
+ return nil
+ end
+
+ return value:lower()
+end
+
+local function curl_config_value(value)
+ return value:gsub('\\', '\\\\'):gsub('"', '\\"')
+end
+
+local function dotenv_credentials(repo)
+ local path = repo .. '/.env'
+ local lines = vim.fn.filereadable(path) == 1 and vim.fn.readfile(path) or {}
+ local result = {}
+
+ for _, line in ipairs(lines) do
+ local assignment = line:gsub('^%s*export%s+', '')
+ local name, value = assignment:match '^%s*([%w_]+)%s*=%s*(.-)%s*$'
+ local existing = name and vim.env[name]
+ if name and dotenv_names[name] and (not existing or existing == '') then
+ local quote = value:sub(1, 1)
+ if (quote == '"' or quote == "'") and value:sub(-1) == quote then
+ value = value:sub(2, -2)
+ else
+ value = value:gsub('%s+#.*$', '')
+ end
+ result[name] = value
+ end
+ end
+
+ return result
+end
+
+local function credential(credentials, name)
+ local existing = vim.env[name]
+ if existing and existing ~= '' then
+ return existing
+ end
+
+ return credentials[name]
+end
+
+local function remote_provider(repo, settings)
+ local remote = credential(settings, 'WORKTREE_CI_REMOTE') or 'origin'
+ local output = git({ 'remote', 'get-url', remote }, repo)
+ if not output then
+ return nil
+ end
+
+ local url = vim.trim(output):gsub('%.git$', '')
+ local host, path = url:match '^git@([^:]+):(.+)$'
+ if not host then
+ host, path = url:match '^https?://([^/]+)/(.+)$'
+ end
+ if not host then
+ host, path = url:match '^ssh://git@([^/]+)/(.+)$'
+ end
+ if not host or not path then
+ return nil
+ end
+
+ if host == 'github.com' then
+ return { kind = 'github', repo = path }
+ end
+
+ if host == 'bitbucket.org' then
+ return { kind = 'bitbucket', repo = path }
+ end
+
+ if host == 'tangled.sh' or host == 'tangled.org' then
+ return { kind = 'tangled', repo = path }
+ end
+
+ if host == 'gitlab.com' or host:find('gitlab', 1, true) then
+ return { kind = 'gitlab', repo = url }
+ end
+
+ return nil
+end
+
+local function normalize_pipeline_status(status, conclusion)
+ local value = (conclusion and conclusion ~= '') and conclusion or status
+ local statuses = {
+ action_required = 'failed',
+ canceled = 'failed',
+ cancelled = 'failed',
+ completed = 'passed',
+ created = 'pending',
+ error = 'failed',
+ failed = 'failed',
+ failure = 'failed',
+ in_progress = 'running',
+ manual = 'pending',
+ neutral = 'passed',
+ paused = 'pending',
+ pending = 'pending',
+ preparing = 'pending',
+ queued = 'pending',
+ requested = 'pending',
+ running = 'running',
+ skipped = 'passed',
+ stale = 'failed',
+ stopped = 'failed',
+ success = 'passed',
+ successful = 'passed',
+ timed_out = 'failed',
+ waiting = 'pending',
+ waiting_for_resource = 'pending',
+ }
+
+ return statuses[value] or 'unknown'
+end
+
+local function pipeline_command(provider, credentials)
+ if provider.kind == 'github' then
+ return {
+ 'gh',
+ 'run',
+ 'list',
+ '--repo',
+ provider.repo,
+ '--limit',
+ '100',
+ '--json',
+ 'headBranch,status,conclusion,createdAt',
+ }
+ end
+
+ if provider.kind == 'gitlab' then
+ return { 'glab', 'ci', 'list', '--repo', provider.repo, '--per-page', '100', '--output', 'json' }
+ end
+
+ local endpoint
+ if provider.kind == 'bitbucket' then
+ endpoint = 'https://api.bitbucket.org/2.0/repositories/' .. provider.repo .. '/pipelines/?pagelen=100&sort=-created_on'
+ else
+ endpoint = 'https://tangled.org/' .. provider.repo .. '/pipelines?trigger=push'
+ end
+ local config = table.concat({ 'url = "' .. curl_config_value(endpoint) .. '"', 'fail', 'silent', 'show-error' }, '\n')
+
+ if provider.kind == 'tangled' then
+ return { 'curl', '--config', '-' }, config
+ end
+
+ local access_token = credential(credentials, 'BITBUCKET_ACCESS_TOKEN')
+ if access_token and access_token ~= '' then
+ local authorization = curl_config_value('Authorization: Bearer ' .. access_token)
+ return { 'curl', '--config', '-' }, config .. '\nheader = "' .. authorization .. '"'
+ end
+
+ local username = credential(credentials, 'BITBUCKET_USERNAME')
+ local api_token = credential(credentials, 'BITBUCKET_API_TOKEN') or credential(credentials, 'BITBUCKET_APP_PASSWORD')
+ if username and username ~= '' and api_token and api_token ~= '' then
+ local credentials = curl_config_value(username .. ':' .. api_token)
+ return { 'curl', '--config', '-' }, config .. '\nuser = "' .. credentials .. '"'
+ end
+
+ return nil
+end
+
+local function parse_tangled_pipelines(output)
+ local card_start = '
%s*([^<]+)' do
+ local normalized = normalize_pipeline_status(lower_string(workflow_status))
+ if normalized == 'failed' then
+ status = 'failed'
+ break
+ end
+ if normalized == 'running' or (normalized == 'pending' and status ~= 'running') then
+ status = normalized
+ elseif normalized == 'passed' and status == 'unknown' then
+ status = 'passed'
+ end
+ end
+ result[branch] = status
+ end
+ end
+
+ return result
+end
+
+local function parse_pipelines(provider, output)
+ if provider.kind == 'tangled' then
+ return parse_tangled_pipelines(output)
+ end
+
+ local decoded = decode_json(output)
+ if not decoded then
+ return nil
+ end
+
+ local pipelines = provider.kind == 'bitbucket' and decoded.values or decoded
+ if type(pipelines) ~= 'table' then
+ return nil
+ end
+
+ local result = {}
+ for _, pipeline in ipairs(pipelines) do
+ local branch
+ local status
+ local conclusion
+
+ if provider.kind == 'github' then
+ branch = pipeline.headBranch
+ status = pipeline.status
+ conclusion = pipeline.conclusion
+ elseif provider.kind == 'gitlab' then
+ branch = pipeline.ref
+ status = pipeline.status
+ else
+ branch = pipeline.target and pipeline.target.ref_name
+ status = pipeline.state and pipeline.state.name
+ conclusion = pipeline.state and pipeline.state.result and pipeline.state.result.name
+ end
+
+ if branch and not result[branch] then
+ result[branch] = normalize_pipeline_status(lower_string(status), lower_string(conclusion))
+ end
+ end
+
+ return result
+end
+
+local function fetch_pipelines(repo, callback)
+ local credentials = dotenv_credentials(repo)
+ local provider = remote_provider(repo, credentials)
+ if not provider then
+ callback(nil)
+ return
+ end
+
+ local command, stdin = pipeline_command(provider, credentials)
+ if not command or vim.fn.executable(command[1]) ~= 1 then
+ callback(nil)
+ return
+ end
+
+ vim.system(command, { cwd = repo, text = true, timeout = 10000, stdin = stdin, env = credentials }, function(process)
+ vim.schedule(function()
+ if process.code ~= 0 then
+ callback(nil)
+ return
+ end
+
+ callback(parse_pipelines(provider, process.stdout))
+ end)
+ end)
+end
+
local function relative_age(timestamp)
local elapsed = math.max(0, os.time() - timestamp)
local units = {
@@ -132,6 +430,7 @@ local function entries(repo)
worktree.name = worktree.branch or ('detached@' .. (worktree.head or ''):sub(1, 8))
worktree.freshness = branch and branch.freshness or '-'
worktree.age = branch and branch.age or '-'
+ worktree.pipeline = 'loading'
checked_out[worktree.name] = worktree
table.insert(result, worktree)
end
@@ -144,6 +443,7 @@ local function entries(repo)
freshness = branch.freshness,
age = branch.age,
timestamp = branch.timestamp,
+ pipeline = 'loading',
worktree = checked_out[branch.name],
})
end
@@ -151,6 +451,26 @@ local function entries(repo)
return result
end
+local function entry_display(item, displayer)
+ local selected = item.current and '●' or ' '
+ local kind = item.kind == 'worktree' and 'WT' or 'BR'
+ local state = item.kind == 'worktree' and (item.dirty and '*' or '✓') or ' '
+ local location = item.kind == 'worktree' and item.path or (item.worktree and '@ ' .. item.worktree.path or '')
+ local pipeline = item.pipeline or 'unknown'
+ local freshness_highlight = item.freshness:find('↓', 1, true) and 'DiagnosticWarn' or 'DiagnosticInfo'
+
+ return displayer {
+ { string.format('%s ', selected), item.current and 'DiagnosticInfo' or 'Comment' },
+ { string.format('%-2s ', kind), item.kind == 'worktree' and 'Type' or 'Identifier' },
+ { string.format('%s ', state), item.dirty and 'DiagnosticWarn' or 'DiagnosticOk' },
+ { string.format('%-28s ', item.name), item.current and 'TelescopeSelection' or 'Normal' },
+ { string.format('%-7s ', item.freshness), freshness_highlight },
+ { string.format('%-5s ', item.age), 'Comment' },
+ { string.format('%s %-8s ', pipeline_icons[pipeline], pipeline), pipeline_highlights[pipeline] },
+ { location, 'Directory' },
+ }
+end
+
local function notify_error(message)
vim.notify(message, vim.log.levels.ERROR, { title = 'Git worktrees' })
end
@@ -179,77 +499,97 @@ function M.open()
local actions = require 'telescope.actions'
local action_state = require 'telescope.actions.state'
local config = require('telescope.config').values
+ local displayer = require('telescope.pickers.entry_display').create {
+ separator = '',
+ items = {
+ { width = 2 },
+ { width = 3 },
+ { width = 3 },
+ { width = 30 },
+ { width = 9 },
+ { width = 7 },
+ { width = 12 },
+ { remaining = true },
+ },
+ }
- pickers
- .new(require('telescope.themes').get_dropdown { width = 0.9, previewer = false }, {
- prompt_title = 'Worktrees and branches [ fetch]',
- finder = finders.new_table {
- results = picker_entries,
- entry_maker = function(item)
- local selected = item.current and '●' or ' '
- local kind = item.kind == 'worktree' and 'WT' or 'BR'
- local state = item.kind == 'worktree' and (item.dirty and '*' or '✓') or ' '
- local location = item.kind == 'worktree' and item.path or (item.worktree and '@ ' .. item.worktree.path or '')
- local display = string.format('%s %-2s %s %-28s %-7s %-5s %s', selected, kind, state, item.name, item.freshness, item.age, location)
-
- return {
- value = item,
- display = display,
- ordinal = table.concat({ item.name, item.path or '', item.upstream or '' }, ' '),
- }
- end,
- },
- sorter = config.generic_sorter {},
- attach_mappings = function(prompt_bufnr, map)
- actions.select_default:replace(function()
- local selection = action_state.get_selected_entry()
- actions.close(prompt_bufnr)
- if not selection then
- return
- end
-
- local item = selection.value
- if item.kind == 'worktree' then
- change_directory(item.path)
- return
- end
-
- if item.worktree then
- change_directory(item.worktree.path)
- return
- end
-
- local _, switch_err = git({ 'switch', item.name }, repo)
- if switch_err then
- notify_error(switch_err)
- return
- end
-
- vim.cmd 'checktime'
- vim.notify('Switched to branch ' .. item.name, vim.log.levels.INFO, { title = 'Git worktrees' })
- end)
+ local picker
+ picker = pickers.new(require('telescope.themes').get_dropdown { width = 0.95, previewer = false }, {
+ prompt_title = 'Worktrees and branches [ fetch] CI: ✓ pass ✗ fail ● run',
+ finder = finders.new_table {
+ results = picker_entries,
+ entry_maker = function(item)
+ return {
+ value = item,
+ display = function()
+ return entry_display(item, displayer)
+ end,
+ ordinal = table.concat({ item.name, item.path or '', item.upstream or '' }, ' '),
+ }
+ end,
+ },
+ sorter = config.generic_sorter {},
+ attach_mappings = function(prompt_bufnr, map)
+ actions.select_default:replace(function()
+ local selection = action_state.get_selected_entry()
+ actions.close(prompt_bufnr)
+ if not selection then
+ return
+ end
- local refresh = function()
- actions.close(prompt_bufnr)
- vim.notify('Fetching remotes...', vim.log.levels.INFO, { title = 'Git worktrees' })
- vim.system({ 'git', 'fetch', '--all', '--prune' }, { cwd = repo, text = true }, function(result)
- vim.schedule(function()
- if result.code ~= 0 then
- notify_error(vim.trim(result.stderr or 'Fetch failed'))
- return
- end
-
- M.open()
- end)
- end)
+ local item = selection.value
+ if item.kind == 'worktree' then
+ change_directory(item.path)
+ return
end
- map('i', '', refresh)
- map('n', '', refresh)
- return true
- end,
- })
- :find()
+ if item.worktree then
+ change_directory(item.worktree.path)
+ return
+ end
+
+ local _, switch_err = git({ 'switch', item.name }, repo)
+ if switch_err then
+ notify_error(switch_err)
+ return
+ end
+
+ vim.cmd 'checktime'
+ vim.notify('Switched to branch ' .. item.name, vim.log.levels.INFO, { title = 'Git worktrees' })
+ end)
+
+ local refresh = function()
+ actions.close(prompt_bufnr)
+ vim.notify('Fetching remotes...', vim.log.levels.INFO, { title = 'Git worktrees' })
+ vim.system({ 'git', 'fetch', '--all', '--prune' }, { cwd = repo, text = true }, function(result)
+ vim.schedule(function()
+ if result.code ~= 0 then
+ notify_error(vim.trim(result.stderr or 'Fetch failed'))
+ return
+ end
+
+ M.open()
+ end)
+ end)
+ end
+
+ map('i', '', refresh)
+ map('n', '', refresh)
+ return true
+ end,
+ })
+ picker:find()
+
+ fetch_pipelines(repo, function(pipelines)
+ if not picker.prompt_bufnr or not vim.api.nvim_buf_is_valid(picker.prompt_bufnr) then
+ return
+ end
+
+ for _, item in ipairs(picker_entries) do
+ item.pipeline = pipelines and (pipelines[item.name] or 'none') or 'unknown'
+ end
+ picker:refresh(picker.finder, { reset_prompt = false })
+ end)
end
return M