diff --git a/lua/gitlad/config.lua b/lua/gitlad/config.lua index 5ca0bec..f2bf0e2 100644 --- a/lua/gitlad/config.lua +++ b/lua/gitlad/config.lua @@ -20,6 +20,9 @@ local M = {} --- "prompt" → always prompts for path with no default ---@class GitladWorktreeConfig ---@field directory_strategy "sibling"|"sibling-bare"|"prompt" How to suggest default worktree paths +---@field worktrunk "auto"|"always"|"never" Whether to use worktrunk (wt) CLI. "auto" = use if installed, "always" = require it, "never" = disable +---@field copy_ignored_on_create "always"|"never" Whether to run wt step copy-ignored after creating a worktree (default: "never"; use popup switch for per-invocation control) +---@field copy_ignored_from "trunk"|"current" Source worktree for copy-ignored: "trunk" = default branch, "current" = current worktree ---@class GitladWatcherConfig ---@field enabled boolean Whether to enable file watching for git state changes (default: true) @@ -67,6 +70,9 @@ local defaults = { status = {}, worktree = { directory_strategy = "sibling", -- "sibling", "sibling-bare", or "prompt" + worktrunk = "auto", -- "auto" | "always" | "never" + copy_ignored_on_create = "never", -- "always" | "never" + copy_ignored_from = "trunk", -- "trunk" | "current" }, watcher = { enabled = true, -- Can disable for performance-sensitive users diff --git a/lua/gitlad/popups/worktree.lua b/lua/gitlad/popups/worktree.lua index 1079375..8df2751 100644 --- a/lua/gitlad/popups/worktree.lua +++ b/lua/gitlad/popups/worktree.lua @@ -2,6 +2,9 @@ ---@brief [[ --- Transient-style worktree popup with switches, options, and actions. --- Follows magit worktree popup patterns (evil-collection keybind: %). +--- When worktrunk (wt) is installed and enabled, shows a worktrunk-oriented +--- popup with Switch/Merge/Remove/Steps sections plus a "Git Worktree" escape +--- hatch for raw git operations. Otherwise falls back to the standard git popup. ---@brief ]] local M = {} @@ -137,10 +140,23 @@ local function select_worktree(repo_state, prompt_text, include_current, callbac end) end ---- Create and show the worktree popup +--- Create and show the worktree popup, bifurcating into worktrunk or git mode. ---@param repo_state RepoState ---@param context? { worktree: WorktreeEntry } Optional context for operations function M.open(repo_state, context) + local cfg = config.get() + local wt = require("gitlad.worktrunk") + if wt.is_active(cfg.worktree) then + M._open_worktrunk_popup(repo_state, context, cfg) + else + M._open_git_popup(repo_state, context) + end +end + +--- Create and show the standard git worktree popup (no worktrunk). +---@param repo_state RepoState +---@param context? { worktree: WorktreeEntry } Optional context for operations +function M._open_git_popup(repo_state, context) local worktree_at_point = context and context.worktree or nil -- Build action labels with context info @@ -213,6 +229,246 @@ function M.open(repo_state, context) worktree_popup:show() end +--- Create and show the worktrunk-oriented worktree popup. +--- Shows Switch/Merge/Remove/Steps sections with a "Git Worktree" escape hatch. +---@param repo_state RepoState +---@param context? { worktree: WorktreeEntry } +---@param cfg GitladConfig +function M._open_worktrunk_popup(repo_state, context, cfg) + local wt = require("gitlad.worktrunk") + + local wt_popup = popup + .builder() + :name("Worktrees [worktrunk]") + -- Switches (Arguments) + :switch( + "i", + "copy-ignored", + "Copy ignored files on create", + { persist_key = "wt_copy_ignored" } + ) + :switch("v", "no-verify", "Skip hooks") + :switch("y", "yes", "Skip prompts") + -- Switch actions + :group_heading("Switch") + :action("s", "Switch to worktree", function(_popup_data) + wt.list({ cwd = repo_state.repo_root }, function(infos, err) + vim.schedule(function() + if err or not infos or #infos == 0 then + vim.notify("[gitlad] " .. (err or "No worktrees found"), vim.log.levels.WARN) + return + end + -- Filter out current worktree + local choices = vim.tbl_filter(function(info) + return info.path ~= repo_state.repo_root + end, infos) + if #choices == 0 then + vim.notify("[gitlad] No other worktrees to switch to", vim.log.levels.INFO) + return + end + vim.ui.select(choices, { + prompt = "Switch to worktree:", + format_item = function(info) + return info.branch .. " " .. info.path + end, + }, function(info) + if not info then + return + end + wt.switch(info.branch, { cwd = repo_state.repo_root }, function(ok, e) + vim.schedule(function() + if ok then + vim.notify("[gitlad] Switched to " .. info.path, vim.log.levels.INFO) + else + vim.notify("[gitlad] wt switch failed: " .. (e or ""), vim.log.levels.ERROR) + end + end) + end) + end) + end) + end) + end) + :action("S", "Create + switch", function(popup_data) + M._wt_create_and_switch(repo_state, popup_data, cfg) + end) + -- Merge + :group_heading("Merge") + :action("m", "Merge current branch...", function(_popup_data) + local merge_popup = require("gitlad.popups.worktree_merge") + merge_popup.open(repo_state) + end) + -- Remove + :group_heading("Remove") + :action("R", "Remove worktree", function(_popup_data) + M._wt_remove(repo_state) + end) + -- Steps + :group_heading("Steps") + :action("ci", "Copy ignored files (run now)", function(_popup_data) + local target_path = context and context.worktree_path or repo_state.repo_root + wt.copy_ignored({ cwd = target_path }, function(ok, err) + vim.schedule(function() + if ok then + vim.notify("[gitlad] copy-ignored complete", vim.log.levels.INFO) + else + vim.notify("[gitlad] copy-ignored failed: " .. (err or ""), vim.log.levels.ERROR) + end + end) + end) + end) + -- Git Worktree escape hatch + :group_heading("Git Worktree") + :action("b", "Add worktree", function(popup_data) + M._add_worktree(repo_state, popup_data) + end) + :action("c", "Create branch + worktree", function(popup_data) + M._add_branch_and_worktree(repo_state, popup_data) + end) + :action("k", "Delete", function(popup_data) + local worktree_at_point = context and context.worktree or nil + if worktree_at_point and not worktree_at_point.is_main then + M._delete_worktree_direct(repo_state, worktree_at_point, popup_data) + else + M._delete_worktree(repo_state, popup_data) + end + end) + :action("g", "Visit", function(_popup_data) + local worktree_at_point = context and context.worktree or nil + if worktree_at_point then + M._visit_worktree_direct(repo_state, worktree_at_point) + else + M._visit_worktree(repo_state) + end + end) + :action("l", "Lock worktree", function(_popup_data) + local worktree_at_point = context and context.worktree or nil + if worktree_at_point and not worktree_at_point.is_main then + M._lock_worktree_direct(repo_state, worktree_at_point) + else + M._lock_worktree(repo_state) + end + end) + :action("u", "Unlock worktree", function(_popup_data) + local worktree_at_point = context and context.worktree or nil + if worktree_at_point and worktree_at_point.locked then + M._unlock_worktree_direct(repo_state, worktree_at_point) + else + M._unlock_worktree(repo_state) + end + end) + :action("p", "Prune stale", function(_popup_data) + M._prune_worktrees(repo_state) + end) + :build() + + wt_popup:show() +end + +--- Switch to a new worktree using wt switch -c +--- After creating, runs wt step copy-ignored if the persistent switch is on +--- or cfg.worktree.copy_ignored_on_create = "always". +---@param repo_state RepoState +---@param popup_data PopupData +---@param cfg GitladConfig +function M._wt_create_and_switch(repo_state, popup_data, cfg) + vim.ui.input({ prompt = "Create + switch to branch: " }, function(branch) + if not branch or branch == "" then + return + end + + local wt = require("gitlad.worktrunk") + + -- Determine if copy-ignored should run after create + local copy_ignored_switch = false + for _, sw in ipairs(popup_data.switches) do + if sw.cli == "copy-ignored" and sw.enabled then + copy_ignored_switch = true + break + end + end + local copy_ignored_always = cfg.worktree.copy_ignored_on_create == "always" + local should_copy_ignored = copy_ignored_switch or copy_ignored_always + + vim.notify("[gitlad] Creating worktree for branch: " .. branch, vim.log.levels.INFO) + + wt.switch(branch, { cwd = repo_state.repo_root, create = true }, function(ok, err) + vim.schedule(function() + if not ok then + vim.notify("[gitlad] wt switch -c failed: " .. (err or ""), vim.log.levels.ERROR) + return + end + + vim.notify("[gitlad] Created worktree for branch: " .. branch, vim.log.levels.INFO) + repo_state:refresh_status(true) + + if should_copy_ignored then + -- Determine source branch for copy-ignored + local from_opt = cfg.worktree.copy_ignored_from + -- Run copy-ignored from the new worktree (we don't have its path here, + -- so we run from the main repo cwd; wt will target the new worktree) + local copy_opts = { cwd = repo_state.repo_root } + if from_opt == "current" then + -- "current" means the worktree we're running from + copy_opts.from = repo_state.repo_root + end + -- Note: when from = "trunk", wt uses its default trunk branch + wt.copy_ignored(copy_opts, function(ci_ok, ci_err) + vim.schedule(function() + if ci_ok then + vim.notify("[gitlad] copy-ignored complete", vim.log.levels.INFO) + else + vim.notify("[gitlad] copy-ignored failed: " .. (ci_err or ""), vim.log.levels.WARN) + end + end) + end) + end + end) + end) + end) +end + +--- Remove a worktree using wt remove (prompts with wt list) +---@param repo_state RepoState +function M._wt_remove(repo_state) + local wt = require("gitlad.worktrunk") + wt.list({ cwd = repo_state.repo_root }, function(infos, err) + vim.schedule(function() + if err or not infos or #infos == 0 then + vim.notify("[gitlad] " .. (err or "No worktrees found"), vim.log.levels.WARN) + return + end + -- Filter out main worktree + local choices = vim.tbl_filter(function(info) + return info.kind ~= "main" + end, infos) + if #choices == 0 then + vim.notify("[gitlad] No linked worktrees to remove", vim.log.levels.INFO) + return + end + vim.ui.select(choices, { + prompt = "Remove worktree:", + format_item = function(info) + return info.branch .. " " .. info.path + end, + }, function(info) + if not info then + return + end + wt.remove(info.branch, { cwd = repo_state.repo_root }, function(ok, e) + vim.schedule(function() + if ok then + vim.notify("[gitlad] Removed worktree: " .. info.branch, vim.log.levels.INFO) + repo_state:refresh_status(true) + else + vim.notify("[gitlad] wt remove failed: " .. (e or ""), vim.log.levels.ERROR) + end + end) + end) + end) + end) + end) +end + --- Add a worktree for an existing branch/commit ---@param repo_state RepoState ---@param popup_data PopupData diff --git a/lua/gitlad/popups/worktree_merge.lua b/lua/gitlad/popups/worktree_merge.lua new file mode 100644 index 0000000..9c88f19 --- /dev/null +++ b/lua/gitlad/popups/worktree_merge.lua @@ -0,0 +1,71 @@ +---@mod gitlad.popups.worktree_merge wt merge popup +---@brief [[ +--- Transient-style popup for running `wt merge`. +--- Invoked from the worktrunk worktree popup via the `m` action. +---@brief ]] + +local M = {} + +local popup = require("gitlad.ui.popup") + +--- Create and show the wt merge popup +---@param repo_state RepoState +function M.open(repo_state) + local merge_popup = popup + .builder() + :name("wt Merge") + -- Switches (Arguments) + :switch("s", "no-squash", "Skip squash") + :switch("r", "no-rebase", "Skip rebase") + :switch("R", "no-remove", "Keep worktree") + :switch("v", "no-verify", "Skip hooks") + -- Options + :option("t", "target", "", "Target branch", { cli_prefix = "", separator = "=" }) + -- Actions + :group_heading("Merge") + :action("m", "Merge current branch into target", function(popup_data) + M._run_merge(repo_state, popup_data) + end) + :build() + + merge_popup:show() +end + +--- Execute wt merge with the given popup state +---@param repo_state RepoState +---@param popup_data PopupData +function M._run_merge(repo_state, popup_data) + local wt = require("gitlad.worktrunk") + + -- Collect flags from switches + local args = {} + for _, sw in ipairs(popup_data.switches) do + if sw.enabled then + table.insert(args, "--" .. sw.cli) + end + end + + -- Target branch from option (empty = nil, let wt use its default) + local target = nil + for _, opt in ipairs(popup_data.options) do + if opt.cli == "target" and opt.value ~= "" then + target = opt.value + break + end + end + + vim.notify("[gitlad] Running wt merge...", vim.log.levels.INFO) + + wt.merge(target, args, { cwd = repo_state.repo_root }, function(success, err) + vim.schedule(function() + if success then + vim.notify("[gitlad] wt merge complete", vim.log.levels.INFO) + repo_state:refresh_status(true) + else + vim.notify("[gitlad] wt merge failed: " .. (err or ""), vim.log.levels.ERROR) + end + end) + end) +end + +return M diff --git a/lua/gitlad/worktrunk/init.lua b/lua/gitlad/worktrunk/init.lua new file mode 100644 index 0000000..7776a51 --- /dev/null +++ b/lua/gitlad/worktrunk/init.lua @@ -0,0 +1,196 @@ +---@mod gitlad.worktrunk Worktrunk CLI integration +---@brief [[ +--- Integration with the worktrunk (wt) CLI for worktree workflow management. +--- Provides async wrappers around wt commands, following the same pattern as git/cli.lua. +---@brief ]] + +local M = {} + +local parse = require("gitlad.worktrunk.parse") + +-- Allow injecting a custom executor for testing (same pattern as forge/http.lua) +local executor = nil + +--- Set a custom executor function for testing +---@param fn function|nil Custom executor (nil to reset to default) +function M._set_executor(fn) + executor = fn +end + +--- Reset executor to default vim.fn.jobstart +function M._reset_executor() + executor = nil +end + +-- Internal executable checker, can be overridden in tests +---@param name string +---@return boolean +M._executable = function(name) + return vim.fn.executable(name) == 1 +end + +--- Run a wt command asynchronously +---@param args string[] Arguments to pass to wt +---@param opts { cwd?: string } +---@param callback fun(stdout: string[], code: number) +local function run_async(args, opts, callback) + local cmd = { "wt" } + vim.list_extend(cmd, args) + local cwd = opts.cwd or vim.fn.getcwd() + local stdout_data = {} + local stderr_data = {} + + local fn = executor or vim.fn.jobstart + fn(cmd, { + cwd = cwd, + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + if data then + if #data > 0 and data[#data] == "" then + table.remove(data) + end + vim.list_extend(stdout_data, data) + end + end, + on_stderr = function(_, data) + if data then + if #data > 0 and data[#data] == "" then + table.remove(data) + end + vim.list_extend(stderr_data, data) + end + end, + on_exit = function(_, code) + vim.schedule(function() + -- On error, combine stderr into stdout for error messages + if code ~= 0 and #stderr_data > 0 then + callback(stderr_data, code) + else + callback(stdout_data, code) + end + end) + end, + }) +end + +--- Check if wt binary is in PATH +---@return boolean +function M.is_installed() + return M._executable("wt") +end + +--- Check if worktrunk should be used given current config +---@param worktree_cfg GitladWorktreeConfig +---@return boolean +function M.is_active(worktree_cfg) + local mode = worktree_cfg and worktree_cfg.worktrunk or "auto" + if mode == "never" then + return false + elseif mode == "always" then + if not M.is_installed() then + vim.notify("[gitlad] worktrunk = 'always' but wt is not in PATH", vim.log.levels.WARN) + end + return true + else -- "auto" + return M.is_installed() + end +end + +--- List worktrees via wt list --format=json (async) +---@param opts { cwd?: string } +---@param callback fun(infos: WorktreeInfo[]|nil, err: string|nil) +function M.list(opts, callback) + run_async({ "list", "--format=json" }, opts, function(stdout, code) + if code ~= 0 then + callback(nil, "wt list failed (exit " .. code .. "): " .. table.concat(stdout, "\n")) + return + end + local infos = parse.parse_list(stdout) + callback(infos, nil) + end) +end + +--- Switch to a worktree by branch (creates if needed). Uses --no-cd. +---@param branch string +---@param opts { cwd?: string, create?: boolean, base?: string } +---@param callback fun(success: boolean, err: string|nil) +function M.switch(branch, opts, callback) + local args = { "switch" } + if opts.create then + table.insert(args, "-c") + if opts.base then + table.insert(args, "--base") + table.insert(args, opts.base) + end + end + table.insert(args, "--no-cd") + table.insert(args, branch) + + run_async(args, opts, function(stdout, code) + if code ~= 0 then + callback(false, "wt switch failed (exit " .. code .. "): " .. table.concat(stdout, "\n")) + return + end + callback(true, nil) + end) +end + +--- Run wt merge pipeline +---@param target string|nil Target branch (nil = default trunk) +---@param args string[] Extra flags (--no-squash, --no-rebase, etc.) +---@param opts { cwd?: string } +---@param callback fun(success: boolean, err: string|nil) +function M.merge(target, args, opts, callback) + local cmd_args = { "merge" } + vim.list_extend(cmd_args, args) + if target then + table.insert(cmd_args, target) + end + + run_async(cmd_args, opts, function(stdout, code) + if code ~= 0 then + callback(false, "wt merge failed (exit " .. code .. "): " .. table.concat(stdout, "\n")) + return + end + callback(true, nil) + end) +end + +--- Remove a worktree by branch +---@param branch string +---@param opts { cwd?: string } +---@param callback fun(success: boolean, err: string|nil) +function M.remove(branch, opts, callback) + run_async({ "remove", branch }, opts, function(stdout, code) + if code ~= 0 then + callback(false, "wt remove failed (exit " .. code .. "): " .. table.concat(stdout, "\n")) + return + end + callback(true, nil) + end) +end + +--- Copy ignored files into the target worktree (wt step copy-ignored) +---@param opts { cwd?: string, from?: string } cwd = target worktree path +---@param callback fun(success: boolean, err: string|nil) +function M.copy_ignored(opts, callback) + local args = { "step", "copy-ignored" } + if opts.from then + table.insert(args, "--from") + table.insert(args, opts.from) + end + + run_async(args, opts, function(stdout, code) + if code ~= 0 then + callback( + false, + "wt step copy-ignored failed (exit " .. code .. "): " .. table.concat(stdout, "\n") + ) + return + end + callback(true, nil) + end) +end + +return M diff --git a/lua/gitlad/worktrunk/parse.lua b/lua/gitlad/worktrunk/parse.lua new file mode 100644 index 0000000..f31b588 --- /dev/null +++ b/lua/gitlad/worktrunk/parse.lua @@ -0,0 +1,59 @@ +---@mod gitlad.worktrunk.parse Worktrunk JSON output parser +---@brief [[ +--- Parses JSON output from `wt list --format=json`. +--- The wt CLI outputs a JSON array (not NDJSON) spanning multiple lines. +---@brief ]] + +local M = {} + +---@class WorktreeInfo +---@field branch string Branch name +---@field path string Absolute path to the worktree +---@field kind string "worktree" (all worktrees have this kind in wt output) +---@field is_main boolean Whether this is the main worktree +---@field is_current boolean Whether this is the currently active worktree +---@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 operation_state string|nil e.g. "conflicts" + +--- Parse output of `wt list --format=json` +--- wt outputs a JSON array spanning multiple lines. +--- Also accepts NDJSON (one JSON object per line) for compatibility. +---@param output string[] Lines from wt list --format=json +---@return WorktreeInfo[] +function M.parse_list(output) + if not output or #output == 0 then + return {} + end + + -- Join all lines into a single string + local json_str = table.concat(output, "\n"):gsub("^%s+", ""):gsub("%s+$", "") + if json_str == "" then + return {} + end + + -- Try to parse as a JSON array first (actual wt output format) + if vim.startswith(json_str, "[") then + local ok, decoded = pcall(vim.json.decode, json_str) + if ok and type(decoded) == "table" then + return decoded + end + return {} + end + + -- Fallback: try NDJSON (one JSON object per line) + local result = {} + for _, line in ipairs(output) do + if line and line ~= "" then + local ok, decoded = pcall(vim.json.decode, line) + if ok and decoded then + table.insert(result, decoded) + end + end + end + return result +end + +return M diff --git a/tests/e2e/test_worktree.lua b/tests/e2e/test_worktree.lua index d79444f..07940bd 100644 --- a/tests/e2e/test_worktree.lua +++ b/tests/e2e/test_worktree.lua @@ -27,6 +27,9 @@ T["worktree popup"]["opens from status buffer with % key"] = function() local child = _G.child local repo = helpers.create_test_repo(child) + -- Ensure git mode (disable worktrunk) so popup structure is predictable + child.lua([[require("gitlad").setup({ worktree = { worktrunk = "never" } })]]) + -- Create initial commit helpers.create_file(child, repo, "test.txt", "hello") helpers.git(child, repo, "add test.txt") @@ -87,6 +90,9 @@ T["worktree popup"]["has correct switches"] = function() local child = _G.child local repo = helpers.create_test_repo(child) + -- Ensure git mode (disable worktrunk) so popup structure is predictable + child.lua([[require("gitlad").setup({ worktree = { worktrunk = "never" } })]]) + -- Create initial commit helpers.create_file(child, repo, "test.txt", "hello") helpers.git(child, repo, "add test.txt") @@ -213,6 +219,9 @@ T["worktree popup"]["shows all action groups"] = function() local child = _G.child local repo = helpers.create_test_repo(child) + -- Ensure git mode (disable worktrunk) so popup structure is predictable + child.lua([[require("gitlad").setup({ worktree = { worktrunk = "never" } })]]) + -- Create initial commit helpers.create_file(child, repo, "test.txt", "hello") helpers.git(child, repo, "add test.txt") @@ -266,6 +275,9 @@ T["worktree popup"]["branch and worktree action available"] = function() local child = _G.child local repo = helpers.create_test_repo(child) + -- Ensure git mode (disable worktrunk) so popup structure is predictable + child.lua([[require("gitlad").setup({ worktree = { worktrunk = "never" } })]]) + -- Create initial commit helpers.create_file(child, repo, "test.txt", "hello") helpers.git(child, repo, "add test.txt") diff --git a/tests/e2e/test_worktrunk_ops.lua b/tests/e2e/test_worktrunk_ops.lua new file mode 100644 index 0000000..0a92bb1 --- /dev/null +++ b/tests/e2e/test_worktrunk_ops.lua @@ -0,0 +1,98 @@ +-- E2E tests for worktrunk operations (wt switch, wt list, wt remove) +-- Guarded: tests are skipped when `wt` is not in PATH +-- Note: these tests verify the async wt CLI wrappers work end-to-end. +-- Full workflow tests (switch+list+remove) depend on a properly configured +-- worktrunk repo, so we test the async wiring and error handling here. +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set() + +-- Skip all tests if wt is not installed +if vim.fn.executable("wt") ~= 1 then + T["worktrunk ops e2e"] = MiniTest.new_set() + T["worktrunk ops e2e"]["SKIP: wt not in PATH"] = function() + -- Guard: wt binary not found, skipping e2e worktrunk ops tests + end + return T +end + +local helpers = require("tests.helpers") + +T["worktrunk ops 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["worktrunk ops e2e"]["wt list callback is invoked (completes without crash)"] = 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"') + + -- Run wt list — it may fail for a non-worktrunk repo but callback must be called + -- Note: do NOT pre-initialize the var to false — wait_for_var checks ~= nil + child.lua(string.format( + [[ + local wt = require("gitlad.worktrunk") + wt.list({ cwd = %q }, function(infos, err) + _G.wt_list_done = true + _G.wt_list_infos = infos + _G.wt_list_err = err + end) + ]], + repo + )) + + -- The callback must always be invoked (success or error) + helpers.wait_for_var(child, "_G.wt_list_done", 5000) + local done = child.lua_get([[_G.wt_list_done]]) + eq(done, true) +end + +T["worktrunk ops e2e"]["wt remove callback is invoked for unknown branch (error path)"] = 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"') + + -- Remove a non-existent branch — should fail gracefully with callback invoked + -- Note: do NOT pre-initialize vars to false — wait_for_var checks ~= nil + child.lua(string.format( + [[ + local wt = require("gitlad.worktrunk") + wt.remove("nonexistent-branch-xyz", { cwd = %q }, function(ok, err) + _G.wt_remove_done = true + _G.wt_remove_ok = ok + _G.wt_remove_err = err + end) + ]], + repo + )) + + helpers.wait_for_var(child, "_G.wt_remove_done", 5000) + local done = child.lua_get([[_G.wt_remove_done]]) + local ok = child.lua_get([[_G.wt_remove_ok]]) + eq(done, true) + -- Should fail for unknown branch + eq(ok, false) + + helpers.cleanup_repo(child, repo) +end + +return T diff --git a/tests/e2e/test_worktrunk_popup.lua b/tests/e2e/test_worktrunk_popup.lua new file mode 100644 index 0000000..14ebc3e --- /dev/null +++ b/tests/e2e/test_worktrunk_popup.lua @@ -0,0 +1,141 @@ +-- E2E tests for worktrunk popup +-- 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() + +-- Skip all tests if wt is not installed +if vim.fn.executable("wt") ~= 1 then + T["worktrunk popup e2e"] = MiniTest.new_set() + T["worktrunk popup e2e"]["SKIP: wt not in PATH"] = function() + -- Guard: wt binary not found, skipping e2e worktrunk popup tests + end + return T +end + +local helpers = require("tests.helpers") + +T["worktrunk popup 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["worktrunk popup e2e"]["popup opens in worktrunk mode when wt installed and auto"] = 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"') + + -- Configure with worktrunk = "auto" (wt installed → worktrunk mode) + child.lua([[require("gitlad").setup({ worktree = { worktrunk = "auto" } })]]) + child.lua(string.format([[vim.cmd("cd %s")]], repo)) + child.lua([[require("gitlad.ui.views.status").open()]]) + helpers.wait_for_status(child) + + -- Track which open function is called via monkey-patching before pressing % + child.lua([[ + _G.worktrunk_popup_called = false + local worktree_popup = require("gitlad.popups.worktree") + local orig = worktree_popup._open_worktrunk_popup + worktree_popup._open_worktrunk_popup = function(rs, ctx, cfg) + _G.worktrunk_popup_called = true + orig(rs, ctx, cfg) + end + ]]) + + child.type_keys("%") + helpers.wait_for_popup(child) + + local called = child.lua_get([[_G.worktrunk_popup_called]]) + eq(called, true) + + child.type_keys("q") + helpers.cleanup_repo(child, repo) +end + +T["worktrunk popup e2e"]["popup opens in git mode 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([[require("gitlad.ui.views.status").open()]]) + helpers.wait_for_status(child) + + child.lua([[ + _G.git_popup_called = false + local worktree_popup = require("gitlad.popups.worktree") + local orig = worktree_popup._open_git_popup + worktree_popup._open_git_popup = function(rs, ctx) + _G.git_popup_called = true + orig(rs, ctx) + end + ]]) + + child.type_keys("%") + helpers.wait_for_popup(child) + + local called = child.lua_get([[_G.git_popup_called]]) + eq(called, true) + + child.type_keys("q") + helpers.cleanup_repo(child, repo) +end + +T["worktrunk popup e2e"]["Git Worktree escape hatch visible in worktrunk mode"] = 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 = "auto" } })]]) + child.lua(string.format([[vim.cmd("cd %s")]], repo)) + child.lua([[require("gitlad.ui.views.status").open()]]) + helpers.wait_for_status(child) + + child.type_keys("%") + helpers.wait_for_popup(child) + + -- Check popup buffer contains "Git Worktree" heading + child.lua([[ + local buf = vim.api.nvim_get_current_buf() + _G.popup_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + ]]) + local lines = child.lua_get([[_G.popup_lines]]) + + local found = false + if type(lines) == "table" then + for _, line in ipairs(lines) do + if type(line) == "string" and line:match("Git Worktree") then + found = true + break + end + end + end + eq(found, true) + + child.type_keys("q") + helpers.cleanup_repo(child, repo) +end + +return T diff --git a/tests/fixtures/wt_list.json b/tests/fixtures/wt_list.json new file mode 100644 index 0000000..2801f6b --- /dev/null +++ b/tests/fixtures/wt_list.json @@ -0,0 +1,61 @@ +[ + { + "branch": "main", + "path": "/home/user/repo/main", + "kind": "worktree", + "is_main": true, + "is_current": false, + "is_previous": false, + "working_tree": { + "staged": false, + "modified": false, + "untracked": false, + "renamed": false, + "deleted": false, + "diff": { "added": 0, "deleted": 0 } + }, + "main_state": "is_main", + "remote": { "name": "origin", "branch": "main", "ahead": 0, "behind": 0 }, + "worktree": { "detached": false } + }, + { + "branch": "feature/new-ui", + "path": "/home/user/repo/feature-new-ui", + "kind": "worktree", + "is_main": false, + "is_current": true, + "is_previous": false, + "working_tree": { + "staged": true, + "modified": true, + "untracked": true, + "renamed": false, + "deleted": false, + "diff": { "added": 10, "deleted": 5 } + }, + "main_state": "ahead", + "main": { "ahead": 4, "behind": 0 }, + "remote": { "name": "origin", "branch": "feature/new-ui", "ahead": 4, "behind": 0 }, + "worktree": { "detached": false } + }, + { + "branch": "bugfix/login-crash", + "path": "/home/user/repo/bugfix-login-crash", + "kind": "worktree", + "is_main": false, + "is_current": false, + "is_previous": true, + "working_tree": { + "staged": false, + "modified": false, + "untracked": false, + "renamed": false, + "deleted": false, + "diff": { "added": 0, "deleted": 0 } + }, + "main_state": "ahead", + "main": { "ahead": 1, "behind": 2 }, + "operation_state": "conflicts", + "worktree": { "detached": false } + } +] diff --git a/tests/unit/test_worktree_merge_popup.lua b/tests/unit/test_worktree_merge_popup.lua new file mode 100644 index 0000000..81dedf0 --- /dev/null +++ b/tests/unit/test_worktree_merge_popup.lua @@ -0,0 +1,174 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set() + +T["worktree_merge popup"] = MiniTest.new_set() + +local function find_switch(builder, key) + for _, sw in ipairs(builder._switches) do + if sw.key == key then + return sw + end + end + return nil +end + +local function find_option(builder, key) + for _, opt in ipairs(builder._options) do + if opt.key == key then + return opt + end + end + return nil +end + +local function find_action(builder, key) + for _, item in ipairs(builder._actions) do + if item.type == "action" and item.key == key then + return item + end + end + return nil +end + +T["worktree_merge popup"]["has no-squash switch (s)"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :switch("s", "no-squash", "Skip squash") + :switch("r", "no-rebase", "Skip rebase") + :switch("R", "no-remove", "Keep worktree") + :switch("v", "no-verify", "Skip hooks") + + local sw = find_switch(builder, "s") + eq(sw ~= nil, true) + eq(sw.cli, "no-squash") + eq(sw.description, "Skip squash") +end + +T["worktree_merge popup"]["has no-rebase switch (r)"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :switch("s", "no-squash", "Skip squash") + :switch("r", "no-rebase", "Skip rebase") + :switch("R", "no-remove", "Keep worktree") + :switch("v", "no-verify", "Skip hooks") + + local sw = find_switch(builder, "r") + eq(sw ~= nil, true) + eq(sw.cli, "no-rebase") +end + +T["worktree_merge popup"]["has no-remove switch (R)"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :switch("s", "no-squash", "Skip squash") + :switch("r", "no-rebase", "Skip rebase") + :switch("R", "no-remove", "Keep worktree") + :switch("v", "no-verify", "Skip hooks") + + local sw = find_switch(builder, "R") + eq(sw ~= nil, true) + eq(sw.cli, "no-remove") + eq(sw.description, "Keep worktree") +end + +T["worktree_merge popup"]["has no-verify switch (v)"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :switch("s", "no-squash", "Skip squash") + :switch("r", "no-rebase", "Skip rebase") + :switch("R", "no-remove", "Keep worktree") + :switch("v", "no-verify", "Skip hooks") + + local sw = find_switch(builder, "v") + eq(sw ~= nil, true) + eq(sw.cli, "no-verify") +end + +T["worktree_merge popup"]["has target branch option (t)"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup.builder():option("t", "target", "", "Target branch") + + local opt = find_option(builder, "t") + eq(opt ~= nil, true) + eq(opt.cli, "target") + eq(opt.description, "Target branch") + eq(opt.value, "") +end + +T["worktree_merge popup"]["has merge action (m)"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Merge") + :action("m", "Merge current branch into target", function() end) + + local action = find_action(builder, "m") + eq(action ~= nil, true) + eq(action.description, "Merge current branch into target") +end + +T["worktree_merge popup"]["popup name is wt Merge"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup.builder():name("wt Merge") + eq(builder._name, "wt Merge") +end + +T["worktree_merge popup"]["_run_merge collects switch flags"] = function() + -- Test that switch flags are assembled correctly via get_arguments + local popup = require("gitlad.ui.popup") + local popup_data = popup + .builder() + :switch("s", "no-squash", "Skip squash") + :switch("r", "no-rebase", "Skip rebase") + :switch("R", "no-remove", "Keep worktree") + :switch("v", "no-verify", "Skip hooks") + :build() + + -- Manually enable some switches + popup_data:toggle_switch("s") + popup_data:toggle_switch("R") + + local args = popup_data:get_arguments() + eq(#args, 2) + eq(args[1], "--no-squash") + eq(args[2], "--no-remove") +end + +T["worktree_merge popup"]["_run_merge uses nil target when option empty"] = function() + -- Simulate what _run_merge does with an empty target option + local popup = require("gitlad.ui.popup") + local popup_data = popup.builder():option("t", "target", "", "Target branch"):build() + + local target = nil + for _, opt in ipairs(popup_data.options) do + if opt.cli == "target" and opt.value ~= "" then + target = opt.value + break + end + end + eq(target, nil) +end + +T["worktree_merge popup"]["_run_merge uses provided target when option set"] = function() + local popup = require("gitlad.ui.popup") + local popup_data = popup.builder():option("t", "target", "", "Target branch"):build() + + popup_data:set_option("t", "main") + + local target = nil + for _, opt in ipairs(popup_data.options) do + if opt.cli == "target" and opt.value ~= "" then + target = opt.value + break + end + end + eq(target, "main") +end + +return T diff --git a/tests/unit/test_worktrunk_detect.lua b/tests/unit/test_worktrunk_detect.lua new file mode 100644 index 0000000..960524b --- /dev/null +++ b/tests/unit/test_worktrunk_detect.lua @@ -0,0 +1,101 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set() + +T["worktrunk.detect"] = MiniTest.new_set() + +-- Helper: override _executable and restore after +local function with_executable(installed, fn) + local wt = require("gitlad.worktrunk") + local orig = wt._executable + wt._executable = function(name) + if name == "wt" then + return installed + end + return orig(name) + end + fn() + wt._executable = orig +end + +T["worktrunk.detect"]["is_installed returns true when wt is executable"] = function() + local wt = require("gitlad.worktrunk") + with_executable(true, function() + eq(wt.is_installed(), true) + end) +end + +T["worktrunk.detect"]["is_installed returns false when wt not in PATH"] = function() + local wt = require("gitlad.worktrunk") + with_executable(false, function() + eq(wt.is_installed(), false) + end) +end + +T["worktrunk.detect"]["is_active auto mode: true when wt installed"] = function() + local wt = require("gitlad.worktrunk") + with_executable(true, function() + eq(wt.is_active({ worktrunk = "auto" }), true) + end) +end + +T["worktrunk.detect"]["is_active auto mode: false when wt not installed"] = function() + local wt = require("gitlad.worktrunk") + with_executable(false, function() + eq(wt.is_active({ worktrunk = "auto" }), false) + end) +end + +T["worktrunk.detect"]["is_active always mode: true regardless of installation"] = function() + local wt = require("gitlad.worktrunk") + with_executable(false, function() + -- Suppress the warning notification in tests + local orig_notify = vim.notify + vim.notify = function() end + local result = wt.is_active({ worktrunk = "always" }) + vim.notify = orig_notify + eq(result, true) + end) +end + +T["worktrunk.detect"]["is_active always mode with wt installed: true"] = function() + local wt = require("gitlad.worktrunk") + with_executable(true, function() + eq(wt.is_active({ worktrunk = "always" }), true) + end) +end + +T["worktrunk.detect"]["is_active never mode: false regardless of installation"] = function() + local wt = require("gitlad.worktrunk") + with_executable(true, function() + eq(wt.is_active({ worktrunk = "never" }), false) + end) +end + +T["worktrunk.detect"]["is_active never mode when not installed: false"] = function() + local wt = require("gitlad.worktrunk") + with_executable(false, function() + eq(wt.is_active({ worktrunk = "never" }), false) + end) +end + +T["worktrunk.detect"]["is_active with nil config defaults to auto behavior"] = function() + local wt = require("gitlad.worktrunk") + -- nil config → defaults to "auto" + with_executable(true, function() + eq(wt.is_active(nil), true) + end) + with_executable(false, function() + eq(wt.is_active(nil), false) + end) +end + +T["worktrunk.detect"]["is_active with empty config defaults to auto behavior"] = function() + local wt = require("gitlad.worktrunk") + with_executable(true, function() + eq(wt.is_active({}), true) + end) +end + +return T diff --git a/tests/unit/test_worktrunk_parse.lua b/tests/unit/test_worktrunk_parse.lua new file mode 100644 index 0000000..fb494a5 --- /dev/null +++ b/tests/unit/test_worktrunk_parse.lua @@ -0,0 +1,118 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set() + +T["worktrunk.parse"] = MiniTest.new_set() + +-- Load fixture lines (path relative to project root) +local function load_fixture_lines() + local lines = {} + for line in io.lines("tests/fixtures/wt_list.json") do + table.insert(lines, line) + end + return lines +end + +T["worktrunk.parse"]["parse_list returns WorktreeInfo array from JSON array fixture"] = function() + local parse = require("gitlad.worktrunk.parse") + local lines = load_fixture_lines() + local result = parse.parse_list(lines) + eq(#result, 3) +end + +T["worktrunk.parse"]["first entry is main worktree"] = function() + local parse = require("gitlad.worktrunk.parse") + local lines = load_fixture_lines() + local result = parse.parse_list(lines) + eq(result[1].branch, "main") + eq(result[1].path, "/home/user/repo/main") + eq(result[1].is_main, true) +end + +T["worktrunk.parse"]["linked entry has working_tree stats"] = function() + local parse = require("gitlad.worktrunk.parse") + local lines = load_fixture_lines() + local result = parse.parse_list(lines) + eq(result[2].branch, "feature/new-ui") + eq(result[2].is_main, false) + eq(result[2].working_tree.staged, true) + eq(result[2].working_tree.modified, true) + eq(result[2].working_tree.untracked, true) +end + +T["worktrunk.parse"]["linked entry has main ahead/behind"] = function() + local parse = require("gitlad.worktrunk.parse") + local lines = load_fixture_lines() + local result = parse.parse_list(lines) + eq(result[2].main.ahead, 4) + eq(result[2].main.behind, 0) +end + +T["worktrunk.parse"]["entry with operation_state parses correctly"] = function() + local parse = require("gitlad.worktrunk.parse") + local lines = load_fixture_lines() + local result = parse.parse_list(lines) + eq(result[3].operation_state, "conflicts") +end + +T["worktrunk.parse"]["entry has main_state field"] = function() + local parse = require("gitlad.worktrunk.parse") + local lines = load_fixture_lines() + local result = parse.parse_list(lines) + eq(result[1].main_state, "is_main") + eq(result[2].main_state, "ahead") +end + +T["worktrunk.parse"]["is_current field is set correctly"] = function() + local parse = require("gitlad.worktrunk.parse") + local lines = load_fixture_lines() + local result = parse.parse_list(lines) + eq(result[1].is_current, false) + eq(result[2].is_current, true) +end + +T["worktrunk.parse"]["empty output returns empty array"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.parse_list({}) + eq(#result, 0) +end + +T["worktrunk.parse"]["empty lines return empty array"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.parse_list({ "", " ", "" }) + eq(#result, 0) +end + +T["worktrunk.parse"]["single-line JSON array parses correctly"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.parse_list({ + '[{"branch":"main","path":"/repo","kind":"worktree","is_main":true}]', + }) + eq(#result, 1) + eq(result[1].branch, "main") + eq(result[1].is_main, true) +end + +T["worktrunk.parse"]["NDJSON fallback: single valid line returns one entry"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.parse_list({ + '{"branch":"main","path":"/repo","kind":"worktree","is_main":true}', + }) + eq(#result, 1) + eq(result[1].branch, "main") +end + +T["worktrunk.parse"]["NDJSON fallback: mixed valid and empty lines"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.parse_list({ + "", + '{"branch":"main","path":"/repo","kind":"worktree","is_main":true}', + "", + '{"branch":"feat","path":"/repo2","kind":"worktree","is_main":false}', + "", + }) + eq(#result, 2) +end + +return T diff --git a/tests/unit/test_worktrunk_popup.lua b/tests/unit/test_worktrunk_popup.lua new file mode 100644 index 0000000..474fadc --- /dev/null +++ b/tests/unit/test_worktrunk_popup.lua @@ -0,0 +1,325 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set() + +T["worktrunk popup"] = MiniTest.new_set() + +-- Helper: build a mock repo_state for popup tests +local function make_repo_state() + return { + repo_root = "/fake/repo", + refresh_status = function() end, + mark_stale = function() end, + last_operation_time = 0, + git_dir = "/fake/repo/.git", + } +end + +-- Helper: collect action keys from a popup's _actions array +local function get_action_keys(builder) + local keys = {} + for _, item in ipairs(builder._actions) do + if item.type == "action" then + table.insert(keys, item.key) + end + end + return keys +end + +-- Helper: collect heading texts from a popup's _actions array +local function get_headings(builder) + local headings = {} + for _, item in ipairs(builder._actions) do + if item.type == "heading" then + table.insert(headings, item.text) + end + end + return headings +end + +-- Helper: find an action by key +local function find_action(builder, key) + for _, item in ipairs(builder._actions) do + if item.type == "action" and item.key == key then + return item + end + end + return nil +end + +-- Helper: find a switch by key +local function find_switch(builder, key) + for _, sw in ipairs(builder._switches) do + if sw.key == key then + return sw + end + end + return nil +end + +-- ── Git mode (worktrunk = "never") ────────────────────────────────────────── + +T["worktrunk popup"]["git mode popup name is Worktree"] = function() + local worktree = require("gitlad.popups.worktree") + local wt = require("gitlad.worktrunk") + local orig = wt._executable + wt._executable = function() + return false + end + + -- We can't easily test popup name without building the full popup here, + -- so we test that is_active returns false in git mode + local result = wt.is_active({ worktrunk = "never" }) + wt._executable = orig + eq(result, false) +end + +-- ── Worktrunk mode ─────────────────────────────────────────────────────────── + +T["worktrunk popup"]["worktrunk popup has Switch heading"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :name("Worktrees [worktrunk]") + :switch("v", "no-verify", "Skip hooks") + :group_heading("Switch") + :action("s", "Switch to worktree", function() end) + :action("S", "Create + switch", function() end) + :group_heading("Merge") + :action("m", "Merge current branch...", function() end) + :group_heading("Remove") + :action("R", "Remove worktree", function() end) + :group_heading("Git Worktree") + :action("b", "Add worktree", function() end) + :action("c", "Create branch + worktree", function() end) + :action("k", "Delete", function() end) + :action("g", "Visit", function() end) + :action("l", "Lock worktree", function() end) + :action("u", "Unlock worktree", function() end) + :action("p", "Prune stale", function() end) + + local headings = get_headings(builder) + local found_switch = false + for _, h in ipairs(headings) do + if h == "Switch" then + found_switch = true + break + end + end + eq(found_switch, true) +end + +T["worktrunk popup"]["worktrunk popup has Merge heading"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Merge") + :action("m", "Merge current branch...", function() end) + + local headings = get_headings(builder) + eq(headings[1], "Merge") +end + +T["worktrunk popup"]["worktrunk popup has Remove heading"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Remove") + :action("R", "Remove worktree", function() end) + + local headings = get_headings(builder) + eq(headings[1], "Remove") +end + +T["worktrunk popup"]["worktrunk popup has Git Worktree escape hatch heading"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Git Worktree") + :action("b", "Add worktree", function() end) + + local headings = get_headings(builder) + eq(headings[1], "Git Worktree") +end + +T["worktrunk popup"]["worktrunk popup has s and S switch actions"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Switch") + :action("s", "Switch to worktree", function() end) + :action("S", "Create + switch", function() end) + + local action_s = find_action(builder, "s") + local action_S = find_action(builder, "S") + eq(action_s ~= nil, true) + eq(action_S ~= nil, true) + eq(action_s.description, "Switch to worktree") + eq(action_S.description, "Create + switch") +end + +T["worktrunk popup"]["worktrunk popup has m merge action"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Merge") + :action("m", "Merge current branch...", function() end) + + local action_m = find_action(builder, "m") + eq(action_m ~= nil, true) + eq(action_m.description, "Merge current branch...") +end + +T["worktrunk popup"]["worktrunk popup has R remove action"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Remove") + :action("R", "Remove worktree", function() end) + + local action_R = find_action(builder, "R") + eq(action_R ~= nil, true) +end + +T["worktrunk popup"]["worktrunk popup git escape hatch has b c k g l u p actions"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Git Worktree") + :action("b", "Add worktree", function() end) + :action("c", "Create branch + worktree", function() end) + :action("k", "Delete", function() end) + :action("g", "Visit", function() end) + :action("l", "Lock worktree", function() end) + :action("u", "Unlock worktree", function() end) + :action("p", "Prune stale", function() end) + + local keys = get_action_keys(builder) + local expected = { "b", "c", "k", "g", "l", "u", "p" } + eq(#keys, #expected) + for i, key in ipairs(expected) do + eq(keys[i], key) + end +end + +T["worktrunk popup"]["worktrunk popup has v and y switches"] = function() + local popup = require("gitlad.ui.popup") + local builder = + popup.builder():switch("v", "no-verify", "Skip hooks"):switch("y", "yes", "Skip prompts") + + local sw_v = find_switch(builder, "v") + local sw_y = find_switch(builder, "y") + eq(sw_v ~= nil, true) + eq(sw_y ~= nil, true) + eq(sw_v.cli, "no-verify") + eq(sw_y.cli, "yes") +end + +T["worktrunk popup"]["worktrunk popup has -i copy-ignored switch with persist_key"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup.builder():switch( + "i", + "copy-ignored", + "Copy ignored files on create", + { persist_key = "wt_copy_ignored" } + ) + + local sw = find_switch(builder, "i") + eq(sw ~= nil, true) + eq(sw.cli, "copy-ignored") + eq(sw.persist_key, "wt_copy_ignored") + eq(sw.description, "Copy ignored files on create") +end + +T["worktrunk popup"]["worktrunk popup has ci copy-ignored step action"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Steps") + :action("ci", "Copy ignored files (run now)", function() end) + + local action = find_action(builder, "ci") + eq(action ~= nil, true) + eq(action.description, "Copy ignored files (run now)") +end + +T["worktrunk popup"]["worktrunk popup has Steps heading"] = function() + local popup = require("gitlad.ui.popup") + local builder = popup + .builder() + :group_heading("Steps") + :action("ci", "Copy ignored files (run now)", function() end) + + local headings = {} + for _, item in ipairs(builder._actions) do + if item.type == "heading" then + table.insert(headings, item.text) + end + end + eq(headings[1], "Steps") +end + +-- ── is_active bifurcation logic ───────────────────────────────────────────── + +T["worktrunk popup"]["open calls _open_worktrunk_popup when wt active"] = function() + local worktree = require("gitlad.popups.worktree") + local wt = require("gitlad.worktrunk") + local orig = wt._executable + wt._executable = function(name) + return name == "wt" + end + + local called_worktrunk = false + local orig_wt_popup = worktree._open_worktrunk_popup + worktree._open_worktrunk_popup = function(rs, ctx, c) + called_worktrunk = true + _ = rs + _ = ctx + _ = c + end + + -- Need config to have worktrunk = "auto" + local config = require("gitlad.config") + config.reset() + -- defaults have worktrunk = "auto" + + worktree.open(make_repo_state(), nil) + + worktree._open_worktrunk_popup = orig_wt_popup + wt._executable = orig + + eq(called_worktrunk, true) +end + +T["worktrunk popup"]["open calls _open_git_popup when worktrunk never"] = function() + local worktree = require("gitlad.popups.worktree") + local wt = require("gitlad.worktrunk") + local orig = wt._executable + wt._executable = function() + return true + end + + local called_git = false + local orig_git_popup = worktree._open_git_popup + worktree._open_git_popup = function(rs, ctx) + called_git = true + _ = rs + _ = ctx + end + + local config = require("gitlad.config") + config.reset() + -- Set up config with worktrunk = "never" + config.setup({ worktree = { worktrunk = "never" } }) + + worktree.open(make_repo_state(), nil) + + worktree._open_git_popup = orig_git_popup + wt._executable = orig + config.reset() + + eq(called_git, true) +end + +return T