diff --git a/PLAN.md b/PLAN.md index 2b85c45..151873a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,388 +1,11 @@ # gitlad.nvim Development Plan -## Context - -gitlad.nvim has a mature core git workflow (staging, commits, branches, rebasing, etc.) with 970+ tests. The next major evolution is **GitHub forge integration** and a **native diff viewer** that replaces our diffview.nvim dependency, creating a seamless experience where PR review is just "diff viewing with annotations." - -See TODOS.md for unfinished items from earlier phases (tag popup, run command popup, etc.). - -## Architecture Decisions - -- **`N` keybinding** for forge popup (evil-collection-magit convention) -- **GitHub GraphQL/REST API** called directly from Lua via `curl` + `vim.fn.jobstart` (async, same pattern as `git/cli.lua`) -- **`gh auth token`** to obtain auth token — no custom OAuth flow, user just needs `gh` installed and authenticated -- **`gh` CLI** used only for auth management and a few convenience operations (PR checkout, create, merge) -- **GitHub-first** but provider-agnostic interface for future GitLab/Gitea support -- **Native diff viewer** built into gitlad, replacing diffview.nvim for all diff viewing -- **Dedicated buffer** for PR list (not inline in status buffer, though status gets a summary line) - -### Why Direct API Instead of `gh` CLI for Everything? - -- **GraphQL** lets us fetch exactly the data we need in a single request (e.g., PR + comments + reviews + files in one query) -- **Fewer subprocess spawns** — one `curl` call instead of multiple `gh` invocations -- **Full control** over pagination, batching, and error handling -- **Simpler mocking** in tests — mock HTTP responses instead of a shell script -- `gh` CLI is still used for: `gh auth token` (auth), `gh pr checkout` (convenience), `gh pr create` (interactive), `gh pr merge` (convenience) - -## Directory Structure - -``` -lua/gitlad/ -├── forge/ -│ ├── init.lua # Provider detection from remote URL, auth check -│ ├── http.lua # Async HTTP client (curl + jobstart) -│ ├── types.lua # Shared forge types (PR, Review, Comment) -│ └── github/ -│ ├── init.lua # GitHub provider implementation -│ ├── graphql.lua # GraphQL queries and response parsing -│ ├── pr.lua # PR operations (list, view, create, merge) -│ └── review.lua # Review/comment operations -├── popups/ -│ └── forge.lua # N keybinding forge popup -├── ui/ -│ ├── views/ -│ │ ├── pr_list.lua # PR list buffer -│ │ ├── pr_detail.lua # PR detail/discussion buffer -│ │ └── diff/ -│ │ ├── init.lua # DiffView coordinator (layout, lifecycle) -│ │ ├── panel.lua # File panel sidebar -│ │ ├── buffer.lua # Diff buffer pair management -│ │ ├── hunk.lua # Hunk parsing, side-by-side alignment -│ │ └── review.lua # Review comment overlay -│ └── components/ -│ ├── pr_list.lua # PR list rendering component -│ └── comment.lua # Comment/thread rendering component -``` - -## Dependency Graph - -``` -Milestone 1 (Forge Foundation) Milestone 3 (CI Checks) Milestone 4 (Native Diff Viewer) - │ │ │ - ▼ │ │ -Milestone 2 (PR Management) ─────────────────►│ │ - │ │ - └──────────┬───────────────┘ - ▼ - Milestone 5 (PR Review) - │ - ▼ - Milestone 6 (Polish & Advanced) -``` - -Milestones 1-2 and Milestone 4 are **independent** and can proceed in parallel. -Milestone 3 (CI Checks) depends on Milestone 2 (PR views). - ---- - -## Milestone 1: Forge Foundation - -**Goal**: HTTP client, auth detection, GitHub provider, forge popup, basic PR listing. - -### 1.1 Async HTTP Client - -**Create**: `lua/gitlad/forge/http.lua`, `tests/unit/test_forge_http.lua` - -Async HTTP via `curl` + `vim.fn.jobstart` (mirrors `git/cli.lua` pattern): - -```lua ----@class HttpRequest ----@field url string ----@field method "GET"|"POST"|"PATCH"|"DELETE" ----@field headers table ----@field body string|nil -- JSON string for POST/PATCH ----@field timeout number|nil -- Default 15s - ----@class HttpResponse ----@field status number ----@field headers table ----@field body string ----@field json any|nil -- Parsed JSON - ---- Make async HTTP request ----@param request HttpRequest ----@param callback fun(response: HttpResponse|nil, err: string|nil) -function M.request(request, callback) -``` - -curl command construction: -``` -curl -s -w "\n%{http_code}" -X -H "Authorization: bearer " -H "Content-Type: application/json" -d '' -``` - -### 1.2 Provider Detection & Auth - -**Create**: `lua/gitlad/forge/init.lua`, `tests/unit/test_forge_init.lua` - -- Parse `git remote get-url origin` → detect provider from URL patterns -- Handle: HTTPS (`https://github.com/owner/repo.git`), SSH (`git@github.com:owner/repo.git`), SSH with path (`ssh://git@github.com/owner/repo`) -- Get auth token: shell out to `gh auth token` (fast, cached per session) -- If `gh` not installed or not authenticated: notify with instructions -- Return provider-agnostic interface: - -```lua ----@class ForgeProvider ----@field name string -- "github" ----@field owner string ----@field repo string ----@field api_url string -- "https://api.github.com" ----@field token string -- From gh auth token ----@field list_prs fun(opts, callback) ----@field get_pr fun(number, callback) ----@field pr_comments fun(number, callback) -``` - -### 1.3 Forge Types - -**Create**: `lua/gitlad/forge/types.lua` - -Core types with LuaCATS annotations: `ForgePullRequest`, `ForgeUser`, `ForgeComment`, `ForgeReview`, `ForgeReviewComment`, `ForgeFile`. - -### 1.4 GitHub Provider & GraphQL - -**Create**: `lua/gitlad/forge/github/init.lua`, `lua/gitlad/forge/github/graphql.lua`, `lua/gitlad/forge/github/pr.lua` -**Create**: `tests/unit/test_forge_github.lua`, `tests/unit/test_github_graphql.lua` - -GraphQL queries for efficient data fetching: - -```graphql -# PR list — single query for all data needed -query($owner: String!, $repo: String!, $states: [PullRequestState!]) { - repository(owner: $owner, name: $repo) { - pullRequests(first: 50, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}) { - nodes { - number, title, state, isDraft, additions, deletions, changedFiles - author { login } - headRefName, baseRefName - reviewDecision - labels(first: 10) { nodes { name } } - createdAt, updatedAt, url - } - } - } -} -``` - -`gh` CLI still used for convenience operations: -- `gh pr checkout ` (handles remote fetching, tracking branch setup) -- `gh pr create --title "..." --body "..." --base main` (interactive workflow) -- `gh pr merge [--squash|--rebase|--merge]` - -### 1.5 Forge Popup - -**Create**: `lua/gitlad/popups/forge.lua`, `tests/unit/test_forge_popup.lua`, `tests/e2e/test_forge_popup.lua` -**Modify**: `lua/gitlad/ui/views/status_keymaps.lua` (add `N`), `lua/gitlad/popups/help.lua` - -Initial popup: -``` -Forge (GitHub: owner/repo) - -Pull Requests - l List pull requests - v View current branch PR - c Checkout PR branch -``` - -### 1.6 PR List View - -**Create**: `lua/gitlad/ui/views/pr_list.lua`, `lua/gitlad/ui/components/pr_list.lua` -**Create**: `tests/unit/test_pr_list_component.lua`, `tests/e2e/test_pr_list.lua` -**Modify**: `lua/gitlad/ui/hl.lua` (add forge highlight groups) - -Follow `log.lua` singleton buffer pattern. Reusable `pr_list` component (like `log_list`). - -Display: `#123 Fix auth bug @author +10 -3 APPROVED` - -Keybindings: `gj`/`gk` navigate, `` view detail, `y` yank PR number, `gr` refresh, `q` close. - -Filtering: "My PRs" / "Review Requested" / "All Open" - -### E2E Testing Strategy - -For HTTP-based tests, mock at the HTTP layer — intercept `curl` calls by prepending a mock script to PATH, or mock `vim.fn.jobstart` in unit tests. Canned JSON responses in `tests/fixtures/github/`. - ---- - -## Milestone 2: PR Management - -**Goal**: Full PR detail view, discussion thread, comment CRUD, PR actions. -**Depends on**: Milestone 1. - -### 2.1 PR Detail View - -**Create**: `lua/gitlad/ui/views/pr_detail.lua`, `lua/gitlad/ui/components/comment.lua` -**Create**: `tests/unit/test_comment_component.lua`, `tests/e2e/test_pr_detail.lua` - -Layout: -``` -#123 Fix authentication bug in login flow -Author: @dave State: OPEN Reviews: APPROVED -Base: main <- feature/fix-auth +10 -3 2 files changed -Labels: bug, priority:high ---- -This PR fixes the authentication bug that was causing... ---- -Comments (3) - -@reviewer 2 days ago -Looks good but I have a question about error handling. - - @dave 1 day ago (reply) - Good point, I've updated the error handling. -``` - -Keybindings: `gj`/`gk` between comments, `c` add comment, `e` edit own comment, `d` open diff, `o` open in browser, `gr` refresh, `q` close. - -### 2.2 Comment CRUD - -**Create**: `lua/gitlad/forge/github/review.lua`, `tests/unit/test_forge_review.lua` - -- Add comment: GraphQL `addComment` mutation or REST `POST /repos/{owner}/{repo}/issues/{n}/comments` -- Edit comment: REST `PATCH /repos/{owner}/{repo}/issues/comments/{id}` -- Comment editor: scratch buffer with markdown filetype, `C-c C-c` submit, `C-c C-k` abort (same pattern as commit_editor) - -### 2.3 PR Actions - -**Modify**: `lua/gitlad/popups/forge.lua` - -Expand popup with: `n` create PR, `m` merge, `C` close, `R` reopen, `o` open in browser. - -Merge/close/reopen use `gh pr` CLI commands (simpler than raw API for these). - -### 2.4 Status Buffer PR Summary - -**Modify**: `lua/gitlad/ui/views/status_render.lua`, `lua/gitlad/config.lua` - -Optional header line when current branch has a PR: -``` -Pull Request: #123 Fix auth bug (APPROVED, +10 -3) -``` - -Config: `forge.show_pr_in_status = true` (default). Fetched lazily via GraphQL, cached until refresh. - ---- - -## Milestone 3: CI Checks Viewer (DONE) - -**Goal**: Show CI/CD check status in PR list, PR detail, and status buffer. -**Depends on**: Milestones 1-2 (forge foundation + PR views). -**Status**: Complete. - -### What was built: -- `ForgeCheck` / `ForgeChecksSummary` types normalized from GitHub's CheckRun + StatusContext -- GraphQL queries fetch `statusCheckRollup` for both PR list and PR detail -- Compact check indicators `[3/3]`, `[1/3]`, `[~1/3]` in PR list and status buffer -- Collapsible checks section in PR detail view with per-check status icons, app names, durations -- `gj`/`gk` navigate to check lines, `` opens check URL, `` toggles collapsed -- Highlight groups: `GitladForgeCheckSuccess`, `GitladForgeCheckFailure`, `GitladForgeCheckPending`, `GitladForgeCheckNeutral` - ---- - -## Milestone 4: Native Diff Viewer (DONE) - -**Goal**: Replace diffview.nvim with built-in side-by-side diff viewer. -**Depends on**: Nothing (independent, but required before Milestone 5). -**Status**: Complete. - -### What was built: - -- **Hunk parsing** (`diff/hunk.lua`): Transforms unified diff output into side-by-side `DiffSideBySideHunk` structures with paired context/add/delete/change lines -- **DiffSpec producers** (`diff/source.lua`): Generates `DiffSpec` for staged, unstaged, worktree, commit, range, stash, and PR diffs -- **File content + alignment** (`diff/content.lua`): Retrieves file content via `git show`, aligns left/right sides with filler lines for synchronized display -- **Side-by-side buffer pair** (`diff/buffer.lua`): Two `buftype=nofile` buffers with `scrollbind`/`cursorbind`, treesitter highlighting via filetype detection, diff extmarks for add/delete/change lines -- **File panel sidebar** (`diff/panel.lua`): 35-char sidebar listing changed files with status indicators, diff stats, selection highlighting; PR commit selector with "All changes" and per-commit entries -- **DiffView coordinator** (`diff/init.lua`): Tab-page layout `[panel | left | right]`, file selection, hunk navigation (`]c`/`[c`), file navigation (`gj`/`gk`), refresh, close/cleanup lifecycle -- **Word-level inline diff** (`diff/inline.lua`): LCS-based word diff highlighting within changed lines, `GitladDiffAddInline`/`GitladDiffDeleteInline` highlight groups -- **Diff popup wired to native viewer** (`popups/diff.lua`): All diff actions (staged, unstaged, worktree, commit, range, stash) route to native viewer -- **PR commit navigation**: ``/`` cycle through individual PR commits or "All changes" view -- **PR diff entry points**: `d` from PR detail view and `N d` from forge popup open native diff viewer with PR context -- **Type definitions** (`diff/types.lua`): `DiffSpec`, `DiffSource`, `DiffPRInfo`, `DiffPRCommit` types - ---- - -## Milestone 5: PR Review - -**Goal**: Inline review comments in the native diff viewer. -**Depends on**: Milestones 1-2 + Milestone 4. - -### 5.1 PR Diff in Native Viewer - -**Modify**: `lua/gitlad/ui/views/diff/init.lua`, `lua/gitlad/forge/github/pr.lua` - -PR diff source: fetch unified diff via `git diff ...`, full file content via `git show :`. - -### 5.2 Display Existing Review Comments - -**Create**: `lua/gitlad/ui/views/diff/review.lua`, `tests/unit/test_diff_review.lua` - -Fetch comments via GraphQL (single query for all review threads on a PR). - -Rendering: sign column indicator on commented lines, `virt_lines` below showing comment author + body. `` on comment line expands/collapses full thread. - -``` - 42 | function login(user, pass) [2 comments] - | > @reviewer: Should we add rate limiting here? - | > @dave: Good idea, I'll add that in a follow-up - 43 | if not validate(user) then -``` - -### 5.3 Add New Inline Comments - -**Modify**: `lua/gitlad/ui/views/diff/review.lua`, `lua/gitlad/forge/github/review.lua` - -`c` in review mode opens comment editor at cursor line. Determine path/line/side from buffer's line_map. Submit via GraphQL mutation. `r` on existing comment replies to thread. - -### 5.4 Submit Review - -Add review actions to forge popup (available in diff view): -- `a` approve, `r` request changes, `c` comment (via GraphQL `submitPullRequestReview` mutation) - -### 5.5 Pending Review (Batch Comments) - -Track pending comments in-memory, show with distinct highlight. Submit all as single review via GitHub API: create pending review → add comments → submit with event. - ---- - -## Milestone 6: Polish & Advanced - -**Goal**: 3-way merge, PR creation workflow, remaining polish. -**Depends on**: Milestones 1-5. - -### 6.1 3-Way Diff View (DONE) -Native 3-pane diff viewer with two use cases: -- **3-way staging** (`d 3`): HEAD | INDEX | WORKTREE — editable INDEX and WORKTREE panes, see changes through the staging pipeline -- **3-way merge** (`d m` during merge): OURS (read-only) | WORKTREE with conflict markers (editable) | THEIRS (read-only) — resolve merge conflicts by editing the middle buffer, `:w` to save, `s` to stage resolved files - -Implementation: `three_way.lua` (pure alignment algorithm, anchor-agnostic), `buffer_triple.lua` (3-pane buffer management with `"none"|"mid_only"|"mid_and_right"` editability modes), `source.produce_merge()` (WORKTREE-anchored diffs via `git diff --no-index`). Merge uses `_diff_content_vs_path` / `_diff_path_vs_content` helpers to diff OURS/THEIRS content against the worktree file. - -### 6.2 PR Creation Workflow -`n` in forge popup: prompt title (default: last commit), open body editor, select base branch, `gh pr create`. - -### 6.3 Notification Awareness -Badge on forge popup or status buffer section for unread GitHub notifications. - -### 6.4 Issue Management (basic) -List/view issues, create issues. Lower priority than PR workflow. - ---- - -## Key Patterns to Follow - -| Pattern | Reference File | Usage | -|---------|---------------|-------| -| Async CLI/HTTP | `git/cli.lua` | `forge/http.lua` | -| PopupBuilder | `ui/popup/init.lua` | `popups/forge.lua` | -| Singleton buffer view | `ui/views/log.lua` | `pr_list.lua`, `pr_detail.lua` | -| Reusable component | `ui/components/log_list.lua` | `components/pr_list.lua`, `components/comment.lua` | -| Two-buffer side-by-side | `ui/views/blame.lua` | `diff/buffer.lua` | -| Elm commands/reducer | `state/commands.lua` | Forge state if needed | -| Picker fallback | `utils/prompt.lua` | PR/branch selection | -| Config extension | `config.lua` | `forge` and `diff` config sections | - -## Verification - -After each milestone, verify: -1. `make test` passes (all existing + new tests) -2. `make lint` passes -3. New popup/keybinding documented in help popup -4. Manual smoke test with a real GitHub repo +The active development plan is at **`../WORKTRUNK_PLAN.md`** (one directory up). + +The forge/GitHub integration milestones that were previously tracked here are now complete: +- Milestone 1 (Forge Foundation) ✓ +- Milestone 2 (PR Management) ✓ +- Milestone 3 (CI Checks Viewer) ✓ +- Milestone 4 (Native Diff Viewer) ✓ +- Milestone 5 (PR Review) ✓ +- Milestone 6 (Polish & Advanced) ✓ diff --git a/lua/gitlad/git/parse.lua b/lua/gitlad/git/parse.lua index aee7a72..eac0f3a 100644 --- a/lua/gitlad/git/parse.lua +++ b/lua/gitlad/git/parse.lua @@ -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") diff --git a/lua/gitlad/state/init.lua b/lua/gitlad/state/init.lua index 291d2af..77b38d4 100644 --- a/lua/gitlad/state/init.lua +++ b/lua/gitlad/state/init.lua @@ -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) diff --git a/lua/gitlad/ui/views/status_sections.lua b/lua/gitlad/ui/views/status_sections.lua index adf8d73..1115246 100644 --- a/lua/gitlad/ui/views/status_sections.lua +++ b/lua/gitlad/ui/views/status_sections.lua @@ -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, ":.") @@ -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] = { @@ -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] = { diff --git a/lua/gitlad/worktrunk/parse.lua b/lua/gitlad/worktrunk/parse.lua index f31b588..a5cac1a 100644 --- a/lua/gitlad/worktrunk/parse.lua +++ b/lua/gitlad/worktrunk/parse.lua @@ -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. @@ -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 diff --git a/tests/e2e/test_worktree_status_enriched.lua b/tests/e2e/test_worktree_status_enriched.lua new file mode 100644 index 0000000..7414b5b --- /dev/null +++ b/tests/e2e/test_worktree_status_enriched.lua @@ -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 diff --git a/tests/unit/test_worktrunk_enrich.lua b/tests/unit/test_worktrunk_enrich.lua new file mode 100644 index 0000000..1a07e46 --- /dev/null +++ b/tests/unit/test_worktrunk_enrich.lua @@ -0,0 +1,205 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set() + +T["worktrunk.parse.merge"] = MiniTest.new_set() + +local function make_entry(branch, path) + return { + branch = branch, + path = path, + head = "abc123", + is_main = false, + is_bare = false, + locked = false, + prunable = false, + } +end + +local function make_info(branch, opts) + opts = opts or {} + return { + branch = branch, + path = opts.path or ("/repo/" .. branch), + kind = "worktree", + is_main = opts.is_main or false, + is_current = opts.is_current or false, + working_tree = opts.working_tree, + main = opts.main, + main_state = opts.main_state, + operation_state = opts.operation_state, + } +end + +T["worktrunk.parse.merge"]["attaches wt field to matching entry"] = function() + local parse = require("gitlad.worktrunk.parse") + local worktrees = { make_entry("feature/foo", "/repo/feature-foo") } + local infos = { make_info("feature/foo", { main = { ahead = 3, behind = 0 } }) } + + parse.merge(worktrees, infos) + + local wt = worktrees[1].wt + MiniTest.expect.no_equality(wt, nil) + eq(wt.main.ahead, 3) + eq(wt.main.behind, 0) +end + +T["worktrunk.parse.merge"]["does not attach wt field when no match"] = function() + local parse = require("gitlad.worktrunk.parse") + local worktrees = { make_entry("feature/foo", "/repo/feature-foo") } + local infos = { make_info("other/branch") } + + parse.merge(worktrees, infos) + + eq(worktrees[1].wt, nil) +end + +T["worktrunk.parse.merge"]["handles empty worktrees"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.merge({}, { make_info("main") }) + eq(#result, 0) +end + +T["worktrunk.parse.merge"]["handles empty infos"] = function() + local parse = require("gitlad.worktrunk.parse") + local worktrees = { make_entry("main", "/repo") } + local result = parse.merge(worktrees, {}) + eq(result[1].wt, nil) +end + +T["worktrunk.parse.merge"]["merges multiple entries correctly"] = function() + local parse = require("gitlad.worktrunk.parse") + local worktrees = { + make_entry("main", "/repo/main"), + make_entry("feature/a", "/repo/feature-a"), + make_entry("feature/b", "/repo/feature-b"), + } + local infos = { + make_info("main", { is_main = true }), + make_info("feature/a", { main = { ahead = 1, behind = 0 } }), + -- feature/b intentionally missing from infos + } + + parse.merge(worktrees, infos) + + MiniTest.expect.no_equality(worktrees[1].wt, nil) + MiniTest.expect.no_equality(worktrees[2].wt, nil) + eq(worktrees[3].wt, nil) + eq(worktrees[2].wt.main.ahead, 1) +end + +T["worktrunk.parse.merge"]["skips entries with nil branch (detached HEAD)"] = function() + local parse = require("gitlad.worktrunk.parse") + local entry = { + path = "/repo", + head = "abc123", + branch = nil, + is_main = false, + is_bare = false, + locked = false, + prunable = false, + } + local infos = { make_info("main") } + + parse.merge({ entry }, infos) + + eq(entry.wt, nil) +end + +T["worktrunk.parse.merge"]["working_tree fields are accessible"] = function() + local parse = require("gitlad.worktrunk.parse") + local worktrees = { make_entry("feature/x", "/repo/x") } + local infos = { + make_info("feature/x", { + working_tree = { staged = true, modified = false, untracked = true }, + }), + } + + parse.merge(worktrees, infos) + + eq(worktrees[1].wt.working_tree.staged, true) + eq(worktrees[1].wt.working_tree.modified, false) + eq(worktrees[1].wt.working_tree.untracked, true) +end + +T["worktrunk.parse.merge"]["operation_state is accessible"] = function() + local parse = require("gitlad.worktrunk.parse") + local worktrees = { make_entry("bugfix/x", "/repo/x") } + local infos = { make_info("bugfix/x", { operation_state = "conflicts" }) } + + parse.merge(worktrees, infos) + + eq(worktrees[1].wt.operation_state, "conflicts") +end + +T["worktrunk.parse.merge"]["returns the worktrees table"] = function() + local parse = require("gitlad.worktrunk.parse") + local worktrees = { make_entry("main", "/repo") } + local result = parse.merge(worktrees, {}) + eq(result, worktrees) +end + +-- ============================================================ +-- status_str tests +-- ============================================================ + +T["worktrunk.parse.status_str"] = MiniTest.new_set() + +T["worktrunk.parse.status_str"]["nil returns empty string"] = function() + local parse = require("gitlad.worktrunk.parse") + eq(parse.status_str(nil), "") +end + +T["worktrunk.parse.status_str"]["uses wt symbols when present"] = function() + local parse = require("gitlad.worktrunk.parse") + eq(parse.status_str({ symbols = "↑3", main_state = "ahead" }), "↑3") +end + +T["worktrunk.parse.status_str"]["falls back to main_state empty"] = function() + local parse = require("gitlad.worktrunk.parse") + eq(parse.status_str({ main_state = "empty" }), "_") +end + +T["worktrunk.parse.status_str"]["falls back to main_state integrated"] = function() + local parse = require("gitlad.worktrunk.parse") + eq(parse.status_str({ main_state = "integrated" }), "=") +end + +T["worktrunk.parse.status_str"]["falls back to main ahead/behind"] = function() + local parse = require("gitlad.worktrunk.parse") + eq(parse.status_str({ main_state = "ahead", main = { ahead = 3, behind = 0 } }), "↑3") + eq(parse.status_str({ main_state = "ahead", main = { ahead = 3, behind = 2 } }), "↑3↓2") + eq(parse.status_str({ main_state = "behind", main = { ahead = 0, behind = 1 } }), "↓1") +end + +T["worktrunk.parse.status_str"]["appends dirty indicator"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.status_str({ + symbols = "↑2", + working_tree = { staged = true, modified = false, untracked = false }, + }) + eq(result, "↑2 ●") +end + +T["worktrunk.parse.status_str"]["appends conflict indicator"] = function() + local parse = require("gitlad.worktrunk.parse") + local result = parse.status_str({ + main_state = "ahead", + main = { ahead = 1, behind = 0 }, + operation_state = "conflicts", + }) + eq(result, "↑1 [C]") +end + +T["worktrunk.parse.status_str"]["is_main with clean tree returns empty"] = function() + local parse = require("gitlad.worktrunk.parse") + eq(parse.status_str({ main_state = "is_main", is_main = true }), "") +end + +T["worktrunk.parse.status_str"]["symbols empty string falls back to structured"] = function() + local parse = require("gitlad.worktrunk.parse") + eq(parse.status_str({ symbols = "", main_state = "empty" }), "_") +end + +return T