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
395 changes: 9 additions & 386 deletions PLAN.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lua/gitlad/git/parse.lua
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ end
---@field lock_reason string|nil Reason for locking (if locked)
---@field prunable boolean Whether the worktree is prunable (stale)
---@field prune_reason string|nil Reason why it's prunable
---@field wt WorktreeInfo|nil Enriched data from wt list (when worktrunk is active)

---@class GitRemote
---@field name string Remote name (e.g., "origin")
Expand Down
14 changes: 13 additions & 1 deletion lua/gitlad/state/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -536,10 +536,22 @@ function RepoState:_fetch_extended_status(result, callback)
complete_one()
end)

-- 8. Worktree list
-- 8. Worktree list (+ optional wt enrichment in parallel)
start_op()
git.worktree_list(opts, function(worktrees, _err)
result.worktrees = worktrees or {}
local cfg = require("gitlad.config").get()
local wt = require("gitlad.worktrunk")
if wt.is_active(cfg.worktree) then
M.mark_operation_time(opts.cwd)
start_op()
wt.list(opts, function(infos, _wt_err)
if infos and #infos > 0 then
require("gitlad.worktrunk.parse").merge(result.worktrees, infos)
end
complete_one()
end)
end
complete_one()
end)

Expand Down
50 changes: 43 additions & 7 deletions lua/gitlad/ui/views/status_sections.lua
Original file line number Diff line number Diff line change
Expand Up @@ -268,21 +268,42 @@ local function render_worktrees(ctx, opts)
-- Normalize repo_root path for comparison (remove trailing slash)
local current_repo_root = repo_root

-- Compute max branch name length for tabular alignment
local max_branch_len = 0
local wt_parse = require("gitlad.worktrunk.parse")

-- Determine if any worktree has wt enrichment (to decide column layout)
local any_wt = false
if status.worktrees then
for _, worktree in ipairs(status.worktrees) do
if worktree.wt then
any_wt = true
break
end
end
end

-- First pass: compute branch length, status strings, and max status width
local max_branch_len = 0
local max_status_len = 0
local wt_statuses = {} -- indexed parallel to status.worktrees
if status.worktrees then
for i, worktree in ipairs(status.worktrees) do
local branch_info = worktree.branch or "(detached)"
max_branch_len = math.max(max_branch_len, #branch_info)
if any_wt then
local s = wt_parse.status_str(worktree.wt)
wt_statuses[i] = s
max_status_len = math.max(max_status_len, #s)
end
end
end
-- Account for pending add phantom lines (use "(creating...)" as branch placeholder)
-- Account for pending add phantom lines
for _ in ipairs(pending_adds) do
max_branch_len = math.max(max_branch_len, #"(creating...)")
end

-- Second pass: render rows with aligned columns
if status.worktrees then
for _, worktree in ipairs(status.worktrees) do
for i, worktree in ipairs(status.worktrees) do
local branch_info = worktree.branch or "(detached)"
-- Compute relative path (cwd-relative first, fallback to home-relative)
local short_path = vim.fn.fnamemodify(worktree.path, ":.")
Expand All @@ -293,7 +314,16 @@ local function render_worktrees(ctx, opts)
short_path = short_path .. "/"
end

local line_text = string.format("%-" .. max_branch_len .. "s %s", branch_info, short_path)
local line_text
if any_wt then
-- Tabular: branch | padded-status | path
local status_col = wt_statuses[i] or ""
local padded = string.format("%-" .. max_status_len .. "s", status_col)
line_text =
string.format("%-" .. max_branch_len .. "s %s %s", branch_info, padded, short_path)
else
line_text = string.format("%-" .. max_branch_len .. "s %s", branch_info, short_path)
end

table.insert(ctx.lines, line_text)
self.line_map[#ctx.lines] = {
Expand Down Expand Up @@ -325,8 +355,14 @@ local function render_worktrees(ctx, opts)
short_path = short_path .. "/"
end

local line_text =
string.format("%-" .. max_branch_len .. "s %s", "(creating...)", short_path)
local line_text
if any_wt then
local padded = string.format("%-" .. max_status_len .. "s", "")
line_text =
string.format("%-" .. max_branch_len .. "s %s %s", "(creating...)", padded, short_path)
else
line_text = string.format("%-" .. max_branch_len .. "s %s", "(creating...)", short_path)
end

table.insert(ctx.lines, line_text)
self.line_map[#ctx.lines] = {
Expand Down
64 changes: 63 additions & 1 deletion lua/gitlad/worktrunk/parse.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ local M = {}
---@field working_tree { staged: boolean, modified: boolean, untracked: boolean }|nil
---@field main { ahead: integer, behind: integer }|nil Commits ahead/behind main branch
---@field remote { ahead: integer, behind: integer, name: string, branch: string }|nil
---@field main_state string|nil e.g. "is_main", "ahead", "integrated"
---@field main_state string|nil e.g. "is_main", "ahead", "integrated", "empty"
---@field operation_state string|nil e.g. "conflicts"
---@field symbols string|nil Compact status symbols from wt (e.g. "↑3", "^", "_")

--- Parse output of `wt list --format=json`
--- wt outputs a JSON array spanning multiple lines.
Expand Down Expand Up @@ -56,4 +57,65 @@ function M.parse_list(output)
return result
end

--- Merge WorktreeInfo from wt list into WorktreeEntry list.
--- Matches by branch name and attaches wt data as a `.wt` field.
---@param worktrees WorktreeEntry[]
---@param infos WorktreeInfo[]
---@return WorktreeEntry[]
function M.merge(worktrees, infos)
local by_branch = {}
for _, info in ipairs(infos) do
if info.branch then
by_branch[info.branch] = info
end
end
for _, entry in ipairs(worktrees) do
if entry.branch then
entry.wt = by_branch[entry.branch]
end
end
return worktrees
end

--- Build a compact status string for display in the worktrees section.
--- Uses wt's own `symbols` field when present (e.g. "↑3", "^⇣", "_"),
--- falling back to structured data (main_state + main.ahead/behind).
--- Appends dirty (●) and conflict ([C]) indicators which wt doesn't include.
---@param wt_info WorktreeInfo|nil
---@return string -- empty string when no info
function M.status_str(wt_info)
if not wt_info then
return ""
end
local parts = {}
-- Use wt's own symbols if present
if wt_info.symbols and wt_info.symbols ~= "" then
table.insert(parts, wt_info.symbols)
elseif wt_info.main_state then
local ms = wt_info.main_state
if ms == "empty" then
table.insert(parts, "_")
elseif ms == "integrated" then
table.insert(parts, "=")
elseif wt_info.main then
if wt_info.main.ahead > 0 and wt_info.main.behind > 0 then
table.insert(parts, "↑" .. wt_info.main.ahead .. "↓" .. wt_info.main.behind)
elseif wt_info.main.ahead > 0 then
table.insert(parts, "↑" .. wt_info.main.ahead)
elseif wt_info.main.behind > 0 then
table.insert(parts, "↓" .. wt_info.main.behind)
end
end
end
-- Append dirty/conflict state (not in wt symbols)
local wk = wt_info.working_tree
if wk and (wk.staged or wk.modified or wk.untracked) then
table.insert(parts, "●")
end
if wt_info.operation_state == "conflicts" then
table.insert(parts, "[C]")
end
return table.concat(parts, " ")
end

return M
101 changes: 101 additions & 0 deletions tests/e2e/test_worktree_status_enriched.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
-- E2E tests for enriched worktrees section (wt list data merged into status)
-- Guarded: tests are skipped when `wt` is not in PATH
local MiniTest = require("mini.test")
local eq = MiniTest.expect.equality

local T = MiniTest.new_set()

if vim.fn.executable("wt") ~= 1 then
T["worktree status enriched e2e"] = MiniTest.new_set()
T["worktree status enriched e2e"]["SKIP: wt not in PATH"] = function()
-- Guard: wt binary not found, skipping e2e enriched worktree status tests
end
return T
end

local helpers = require("tests.helpers")

T["worktree status enriched e2e"] = MiniTest.new_set({
hooks = {
pre_case = function()
local child = MiniTest.new_child_neovim()
child.start({ "-u", "tests/minimal_init.lua" })
_G.child = child
end,
post_case = function()
if _G.child then
_G.child.stop()
_G.child = nil
end
end,
},
})

T["worktree status enriched e2e"]["wt list is called during status refresh when worktrunk active"] = function()
local child = _G.child
local repo = helpers.create_test_repo(child)

helpers.create_file(child, repo, "test.txt", "hello")
helpers.git(child, repo, "add test.txt")
helpers.git(child, repo, 'commit -m "Initial"')

-- Enable worktrunk mode
child.lua([[require("gitlad").setup({ worktree = { worktrunk = "auto" } })]])
child.lua(string.format([[vim.cmd("cd %s")]], repo))

-- Monkey-patch wt.list to track if it's called
child.lua([[
local wt = require("gitlad.worktrunk")
local orig = wt.list
_G.wt_list_called = false
wt.list = function(opts, callback)
_G.wt_list_called = true
orig(opts, callback)
end
]])

child.lua([[require("gitlad.ui.views.status").open()]])
helpers.wait_for_status(child)

local called = child.lua_get([[_G.wt_list_called]])
eq(called, true)

helpers.cleanup_repo(child, repo)
end

T["worktree status enriched e2e"]["wt list is NOT called when worktrunk = never"] = function()
local child = _G.child
local repo = helpers.create_test_repo(child)

helpers.create_file(child, repo, "test.txt", "hello")
helpers.git(child, repo, "add test.txt")
helpers.git(child, repo, 'commit -m "Initial"')

child.lua([[require("gitlad").setup({ worktree = { worktrunk = "never" } })]])
child.lua(string.format([[vim.cmd("cd %s")]], repo))

child.lua([[
local wt = require("gitlad.worktrunk")
local orig = wt.list
_G.wt_list_called_never = false
wt.list = function(opts, callback)
_G.wt_list_called_never = true
orig(opts, callback)
end
]])

child.lua([[require("gitlad.ui.views.status").open()]])
helpers.wait_for_status(child)

-- Give it extra time to ensure wt.list would have fired if it were going to
helpers.wait_short(child, 500)

local called = child.lua_get([[_G.wt_list_called_never]])
-- Should be false (not called) or vim.NIL (never set)
local not_called = (called == false or called == vim.NIL)
eq(not_called, true)

helpers.cleanup_repo(child, repo)
end

return T
Loading
Loading